JMH로 알아보는 오토 박싱의 부하

쉽게 쓰여진 자바 코드에서는 일반적으로 정수 값의 목록을 List 클래스로 관리합니다. 자바 컬렉션 프레임워크는 구조적으로 잘 설계된 편이지만, 자바 언어의 한계로 프리미티브 타입을 자바 컬렉션으로 관리하는 경우 성능 상 많은 불이익을 받게 됩니다. 이는 자바 컬렉션이 설계적으로 Object 타입만 받아들일 수 있고, 프리미티브는 Object를 상속하지 않기 때문에 자동으로 객체 변환(Boxing)을 수행하기 때문입니다.

64비트 시스템에서 int 프리미티브는 8바이트를 차지하는 반면, Integer 객체는 포인터 8바이트, 객체 헤더 16바이트, 데이터 8바이트로 총 32바이트를 소모하므로 메모리량을 많이 차지할 뿐더러, GC 작업에 있어서도 하나의 프리미티브 배열에 비해 개별 Integer 객체를 모두 추적해야 하므로 많은 비용이 들어갑니다.

자바 컬렉션으로 인한 성능 페널티는 계산량이 많은 응용프로그램일수록 더욱 두드러지는데, 수십-수백 테라바이트의 역인덱스 포스팅 리스트를 스캔해야 하는 검색엔진이나, 대량의 연산을 수행해야 하는 데이터베이스에서도 마찬가지로 큰 성능 저하를 유발합니다.

오래된 예이지만, 루씬 3.5 시절의 아래 이슈는 객체 참조로 인한 메모리 사용량을 줄이고 프리미티브 배열로 처리하는게 어느 정도의 성능 향상 효과를 가져올 수 있는지 잘 보여줍니다:

JMH: Java Microbenchmark Harness

이제 오토박싱으로 인한 부하를 실제 측정해보기에 앞서, JMH를 소개하도록 하겠습니다. JMH는 JDK에서 공식적으로 제공하는 마이크로 벤치마크 프레임워크입니다. JMH를 기반으로 벤치마크 테스트를 작성하는 것을 권장하는 이유는, 조심스럽게 테스트 코드를 작성하지 않으면 JVM에서 제공하는 다양한 최적화 기법 때문에 벤치마크 테스트가 의도한대로 동작하지 않고 왜곡된 결과를 내놓을 수 있기 때문입니다.

아래와 같이 몇 가지 잘못된 예를 생각해볼 수 있습니다:

  • JIT 컴파일 여부: 핫스팟 컴파일러는 일정 횟수 이상 실행되는 메소드를 컴파일하는데, 만약 웜업 단계를 생략하게 되면 결과에 왜곡이 발생하게 됩니다.
  • 데드코드 제거: 벤치마크 작성 시 성능 측정 대상 코드만 간단히 루프에 넣어 돌리는 경우가 흔한데, 핫스팟 컴파일러는 참조되지 않는 무의미한 코드를 자동으로 삭제하기 때문에 왜곡된 결과를 얻을 수 있습니다.
  • 버추얼테이블 최적화: 인터페이스 구현체가 1개인 경우에는 실행 시 분기할 필요가 없기 때문에 네이티브 코드가 최적화됩니다. 그러나 코드 실행에 따라 동일 인터페이스를 구현하는 클래스가 추가로 로드되는 경우, 이전 실행과 달리 왜곡된 결과를 얻을 수 있습니다.

JMH 벤치마크 프로젝트 만들기

Maven을 이용해서 새 JMH 프로젝트를 생성합니다.

$ mvn archetype:generate -DinteractiveMode=false 
    -DarchetypeGroupId=org.openjdk.jmh -DarchetypeArtifactId=jmh-java-benchmark-archetype 
    -DgroupId=com.logpresso -DartifactId=benchmark -Dversion=1.0

그러면 아래와 같이 샘플 코드가 생성됩니다.

package com.logpresso;

import org.openjdk.jmh.annotations.Benchmark;

public class MyBenchmark {
    @Benchmark
    public void testMethod() {
        // This is a demo/sample template for building your JMH benchmarks. Edit as needed.
        // Put your benchmark code here.
    }
}

아래와 같이 두 개의 벤치마크 테스트 코드를 작성합니다.

package com.logpresso;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

public class MyBenchmark {
	private static final int MAX_SIZE = 10000000;

	@Benchmark
	public int[] testPrimitive() {
		int[] array = new int[MAX_SIZE];
		for (int i = 0; i < MAX_SIZE; i++)
			array[i] = i;

		return array;
	}

	@Benchmark
	public Integer[] textBoxing() {
		Integer[] array = new Integer[MAX_SIZE];
		for (int i = 0; i < MAX_SIZE; i++)
			array[i] = i;

		return array;
	}
}

컴파일 후 아래와 같이 실행합니다:

$ mvn clean package
$ java -jar target/benchmark-1.0.jar

이제 실행하면 아래와 같은 결과를 볼 수 있습니다:

# Run complete. Total time: 00:14:20

Benchmark                   Mode  Cnt    Score   Error  Units
MyBenchmark.testPrimitive  thrpt  200  166.054 ± 1.265  ops/s
MyBenchmark.textBoxing     thrpt  200   32.191 ± 0.484  ops/s

프리미티브 배열의 처리량이 객체 배열에 비해 약 5배 정도 높은 것을 확인할 수 있습니다. 전체 실행 결과는 이 링크에서 볼 수 있습니다.

JMH 프레임워크에서 적용할 수 있는 옵션은 여러가지인데, 이번 글에서는 가장 간단한 테스트 구성만 알아보았습니다. 자세한 내용은 OpenJDK: JMH 페이지를 참고하시기 바랍니다.

둘러보기

더보기

10배 빠르게 통계 처리하기

자바로 개발된 많은 엔터프라이즈 응용프로그램들은 일반적으로 데이터베이스에 의존하여 데이터를 처리하지만, 처리해야 할 데이터가 너무 크거나 실시간 집계가 필요한 경우 자바로 직접 개발하여 통계를 내기도 합니다. 현재는 데스크탑에서도 4개 이상의 멀티코어를 사용하기 때문에, 서버에서는 수십 개의 스레드가 데이터를 처리하여 집계하는 경우를 흔하게 볼 수 있습니다. 중급 이상의 개발자라면 프로세서가 지원하는 CAS (Compare-And-Swap) 인스트럭션을 기반으로 한 AtomicInteger나 AtomicLong 클래스를 이용하여 병렬 집계 처리를 하겠지만, 자바 8은 멀티코어 환경에서 성능이 더 나은 옵션을 제공합니다. 바로 LongAdder 클래스입니다. **AtomicLong 테스트 코드** ```java import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; public class Benchmark { private AtomicLong counter = new AtomicLong(0); public class Counter implements Runnable { @Override public void run() { for (int i = 0; i < 10000000; i++) counter.incrementAndGet(); } } public void run() throws InterruptedException { int cores = Runtime.getRuntime().availableProcessors(); ExecutorService pool = Executors.newFixedThreadPool(cores); long begin = System.currentTimeMillis(); for (int i = 0; i < cores; i++) pool.submit(new Counter()); pool.shutdown(); pool.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS); long end = System.currentTimeMillis(); System.out.println(counter.get()); System.out.println((end - begin) + "ms"); } public static void main(String[] args) throws Exception { new Benchmark().run(); } } ``` **LongAdder 테스트 코드** ```java import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.LongAdder; public class Benchmark { private LongAdder counter = new LongAdder(); public class Counter implements Runnable { @Override public void run() { for (int i = 0; i < 10000000; i++) counter.increment(); } } public void run() throws InterruptedException { int cores = Runtime.getRuntime().availableProcessors(); ExecutorService pool = Executors.newFixedThreadPool(cores); long begin = System.currentTimeMillis(); for (int i = 0; i < cores; i++) pool.submit(new Counter()); pool.shutdown(); pool.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS); long end = System.currentTimeMillis(); System.out.println(counter.sum()); System.out.println((end - begin) + "ms"); } public static void main(String[] args) throws Exception { new Benchmark().run(); } } ``` **측정 결과 #1** - 인텔 코어 i7-4770 3.4GHz, 4 코어, 8 스레드 - AtomicLong: 1546ms, 1542ms, 1534ms - LongAdder: 155ms, 141ms, 143ms **측정 결과 #2** - 인텔 제온 E5-2687W v3 3.1GHz, 20 코어, 40 스레드 - AtomicLong: 6701ms, 7126ms, 6880ms - LongAdder: 351ms, 349ms, 332ms ### 성능 차이의 원인 멀티스레드 응용에서 AtomicLong을 이용해서 집계를 수행하는 경우, 모든 코어의 CPU 사용율이 100%에 근접하기 때문에 최대 속도로 동작하고 있다고 단순하게 생각하기 쉽습니다. 실제로는 같은 메모리 변수에 대해 다수의 코어가 경합을 벌이게 되면서, 값 변경이 성공하게 될 때까지 무한히 재시도 하는 상태가 됩니다. 스레드 수가 많을수록 더 극심한 경합이 발생하고 성능이 더 떨어집니다. LongAdder는 여러 개의 카운터로 구성된 배열을 이용해서 코어 간 경합이 발생할 확률을 떨어뜨립니다. 마지막에 각 카운터를 합산하여 최종 결과를 계산하는 방식으로, 집계를 수행할 때는 경합을 최소화하고 정확한 결과를 얻을 수 있습니다. LongAdder는 멀티스레드 집계 상황에서 성능이 좋은 대신, 메모리를 상대적으로 많이 사용합니다. 아래 코드에서 보이듯이 LongAdder는 경합이 발생하면 2배씩 카운터 배열을 확장합니다. ```java else if (cellsBusy == 0 && casCellsBusy()) { try { if (cells == cs) // Expand table unless stale cells = Arrays.copyOf(cs, n << 1); } finally { cellsBusy = 0; } collide = false; continue; // Retry with expanded table } ``` 집계 카운터가 많은 경우, 이러한 메모리 사용량은 받아들이기 어려울 수 있습니다. 로그프레소는 하드웨어 환경에 따라 적절한 메모리를 사용하면서 백오프(Backoff)를 이용해 경합을 감소시키는 알고리즘을 사용합니다.

2016-12-28

GC를 회피하는 메모리 관리 기술

자바는 프로그래머가 메모리 관리를 직접 하지 않도록 설계된 언어입니다. 가비지 컬렉션 기술(이하 GC)은 이전 세대의 프로그램에서 있었던 많은 메모리 참조 오류들을 원천적으로 해결하여 크래시나 메모리 오염으로부터 프로그래머들을 해방시켰습니다. 그러나 이러한 특성은 웹 애플리케이션 서버처럼 상태가 별로 없는 프로그램의 동작에는 적합하나, 데이터베이스처럼 대량의 상태를 유지관리해야 하는 프로그램에는 근본적인 한계를 가지고 있습니다. 메모리에 많은 개체들이 유지될수록, GC를 수행하는데 더 많은 시간이 소요됩니다. 단순히 사용되지 않는 쓰레기 개체를 찾는 것 뿐만 아니라, 단편화를 해결하기 위해 메모리 재배치를 수행하면서 많은 메모리 복사를 유발하기 때문입니다. 많은 웹 서비스들이 수천만 건 이상의 세션 정보나 데이터들을 자체 캐싱하는 대신 C로 구현된 memcached나 redis 같은 외부 데몬에 캐싱하는 것은 이런 이유도 깔려있습니다. 그렇다면 아예 방법이 없는 것인가? 그렇지 않습니다. 바로 오프힙 메모리입니다. ### 다이렉트 버퍼의 유래 DirectByteBuffer는 자바 1.4 시절 NIO 기술과 함께 등장했습니다. JNI로 직접 C 라이브러리를 자바에 링크시켜 본 경험이 있다면 쉽게 이해하겠지만, 자바 월드에서 네이티브 월드로 넘어갈 때는 메모리 복사가 일어납니다. 자바 코드에서 참조하는 배열의 메모리는 [불연속적이거나 네이티브 콜을 한 직후에 GC로 삭제될 수도 있기 때문에](http://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/design.html#wp1265), 자바가상머신은 시스템 콜 호출 시 별도의 메모리 공간에 입력 데이터를 복사해서 사용합니다. 초기 버전의 자바는 디스크나 네트워크 등 I/O를 수행하면서 빈번한 메모리 복사가 발생하므로 많은 성능 저하가 있을 수 밖에 없었고, 자바 1.4부터 I/O 시 반복적으로 복사를 하지 않도록 다이렉트 버퍼를 제공하기 시작했습니다. 다이렉트 버퍼는 불필요한 복사를 회피할 수 있는 대신, 연속적인 메모리 공간 할당, 참조 정보 관리로 인해 자바 힙 메모리에 비해 할당과 접근 모두 상대적으로 느립니다. 그러나 여기서 중요한 특징은 [GC 범위의 바깥](http://docs.oracle.com/javase/8/docs/api/java/nio/ByteBuffer.html)에 있다는 사실입니다. 다이렉트 버퍼는 GC의 영향에서 벗어날 수 있으므로, 메모리 재배치로 인한 수십 초 단위의 GC 수행을 회피할 수 있습니다. > The buffers returned by this method typically have somewhat higher allocation and deallocation costs than non-direct buffers. **The contents of direct buffers may reside outside of the normal garbage-collected heap**, and so their impact upon the memory footprint of an application might not be obvious. ### 다이렉트 버퍼의 생명주기 제어 위의 인용문구에서 보이듯이, 다이렉트 메모리의 사용량은 단순 힙 사용량으로는 관측되지 않습니다. 이러한 특성은 GC 자체에도 영향을 주는데, 예를 들어 다이렉트 메모리를 더 이상 어디에서도 참조하지 않는데도 불구하고 즉시 GC로 제거되지 않는 현상이 발생합니다. 실제로는 가용 메모리가 있는데도 불구하고 아래와 같이 다이렉트 메모리 할당에 실패할 수 있습니다. ``` java.lang.OutOfMemoryError: Direct buffer memory at java.nio.Bits.reserveMemory(Bits.java:658) at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123) at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:306) ``` 강제로 System.gc()를 호출하지 않는 이상, 다이렉트 바이트버퍼를 해제하려면 다른 방법을 고안해야 합니다. 이 이슈 때문에 다이렉트 메모리를 응용하는 애플리케이션들은 흔히 문서화되지 않은 아래 방법을 사용합니다. ```java ByteBuffer bb = ByteBuffer.allocateDirect(10000); Method cleanerMethod = bb.getClass().getMethod("cleaner"); cleanerMethod.setAccessible(true); Object cleaner = cleanerMethod.invoke(bb); Method cleanMethod = cleaner.getClass().getMethod("clean"); cleanMethod.setAccessible(true); cleanMethod.invoke(cleaner); ``` 본래는 Finalizer에 의해 호출될 다이렉트 바이트버퍼의 내부 Cleaner 메소드를 리플렉션을 이용해서 직접 호출하는 것입니다. 그러나 이제 문제는 새로운 차원으로 옮겨가게 됩니다. 다이렉트 바이트버퍼의 메모리를 직접 할당하고 해제한다는 것은, 기존의 C/C++와 같은 언어에서 직접 메모리를 할당하고 해제했던 것처럼 메모리에 대한 모든 생명주기를 직접 책임져야 한다는 의미입니다. 특히, 멀티스레드 환경에서는 개체의 참조를 정확하게 계산하도록 레퍼런스 카운터 등을 이용해야 합니다. 만약, 이미 해제해버린 메모리를 대상으로 데이터를 쓰거나 읽으려고 시도한다면, 크래시를 피할 수 없습니다. 예를 들어 아래의 코드는 아주 쉽게 크래시를 유도합니다. ```java for (int i = 0; i < 1000; i++) { ByteBuffer bb = ByteBuffer.allocateDirect(10000); Method cleanerMethod = bb.getClass().getMethod("cleaner"); cleanerMethod.setAccessible(true); Object cleaner = cleanerMethod.invoke(bb); Method cleanMethod = cleaner.getClass().getMethod("clean"); cleanMethod.setAccessible(true); cleanMethod.invoke(cleaner); byte[] buf = new byte[10000]; bb.put(buf); } ``` 윈도우 환경의 크래시 발생 예 ``` # # A fatal error has been detected by the Java Runtime Environment: # # EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007fff1c899f58, pid=10756, tid=16228 # # JRE version: Java(TM) SE Runtime Environment (8.0_60-b27) (build 1.8.0_60-b27) # Java VM: Java HotSpot(TM) 64-Bit Server VM (25.60-b23 mixed mode windows-amd64 compressed oops) # Problematic frame: # C [ntdll.dll+0x39f58] # # Failed to write core dump. Minidumps are not enabled by default on client versions of Windows # # An error report file with more information is saved as: # hs_err_pid10756.log ``` 이 뿐만 아니라, 오프힙 메모리는 바이트 배열에 불과하기 때문에 반드시 바이트 직렬화 과정을 거쳐야 합니다. 예를 들어, 16909060라는 정수 값이 있다면, 01 02 03 04로 인코딩해야 합니다. 여기에 더해서 다이렉트 바이트버퍼의 할당과 해제는 상대적으로 훨씬 느리기 때문에, 오프힙 메모리의 응용에는 버퍼 풀링을 포함한 정교한 아키텍처 설계가 필요합니다. 로그프레소는 오프힙 메모리 기술을 통해 GC 문제를 회피하고 수백 기가에 이르는 데이터를 압축된 상태로 캐싱하며 쿼리 실행을 가속합니다.

2017-01-03

스레드 스택 덤프를 내장하는 방법

자바 응용프로그램의 장애는 크게 2가지로 분류됩니다. 첫번째는 과도한 힙 메모리 사용에 따른 잦은 GC 발생이고, 두번째는 I/O로 인한 블록 혹은 잠금으로 인한 스레드 대기 혹은 교착 문제입니다. 개발 환경에서는 디버거를 통해서 쉽게 WAS나 자바 애플리케이션의 내부 동작을 확인할 수 있지만, 운영 환경에서는 보안 문제로 터미널 접속조차 쉽지 않은 경우가 많습니다. 운영 환경에 JRE 대신 JDK를 설치한 경우라면, 잘 알려진대로 jstack 도구를 사용할 수 있습니다. 다음과 같이 현재 동작 중인 JVM 프로세스 PID를 매개변수로 넘겨서 실행합니다. ``` $ jstack <PID> ``` 그러면 아래와 같은 형식으로 스레드 스택이 출력됩니다. ``` 2017-02-01 23:01:35 Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.51-b03 mixed mode): "[iPOJO] pool-5-thread-1" #53 prio=5 os_prio=0 tid=0x000000000428a000 nid=0x7dcd waiting on condition [0x00007f3b2cf74000] java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) - parking to wait for <0x0000000080c04348> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039) at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442) at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1067) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1127) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617) at java.lang.Thread.run(Thread.java:745) "FelixStartLevel" #36 daemon prio=5 os_prio=0 tid=0x000000000263d000 nid=0x7dbc in Object.wait() [0x00007f3b2d67b000] java.lang.Thread.State: WAITING (on object monitor) at java.lang.Object.wait(Native Method) at java.lang.Object.wait(Object.java:502) at org.apache.felix.framework.FrameworkStartLevelImpl.run(FrameworkStartLevelImpl.java:283) - locked <0x0000000080f65660> (a java.util.ArrayList) at java.lang.Thread.run(Thread.java:745) ``` jstack 덤프는 스레드 이름, 스택, 동작 상태 뿐 아니라, 현재 각 스레드가 소유하고 있는 잠금 인스턴스, 대기하고 있는 인스턴스 정보까지 포함합니다. 이를 확인하면 현재 어떤 이유로 스레드가 대기하고 있는지 진단할 수 있습니다. 그러나 보안 문제로 운영 환경에서 jstack 도구를 실행하기 어렵거나, 진단 정보를 자동으로 수집하려는 경우에는 [ThreadMXBean](https://docs.oracle.com/javase/8/docs/api/java/lang/management/ThreadMXBean.html)을 사용해서 애플리케이션 자체에 잠금 상태를 포함한 스택 덤프 기능을 내장할 수 있습니다. [JstackHelper.java 전체 코드 보기](https://gist.github.com/logpresso/c7928aefac3f094bef5905c5284eb9cb) ```java ThreadMXBean bean = ManagementFactory.getThreadMXBean(); long[] tids = bean.getAllThreadIds(); writer.write("Thread dump (total=" + tids.length + ")" + LF); writer.write("------------------------------------------" + LF); for (ThreadInfo t : bean.getThreadInfo(tids, true, true)) { if (t == null) continue; writer.write("\"" + t.getThreadName() + "\" tid=" + t.getThreadId() + ": (state = " + t.getThreadState() + ")" + LF); writer.write(mergeStackTrace(t)); writer.write(LF); } ``` 그런데 스레드 풀을 활용하는 경우 보통 스레드 풀을 대표하는 이름만 부여되기 때문에 스택 덤프를 봐도 현재 실행 중인 작업의 맥락을 쉽게 식별하기 어려운 경우가 종종 있습니다. 많은 개발자들이 스레드 이름은 스레드를 생성할 때만 설정할 수 있다고 생각하지만 실제로는 실행 중에 스레드 이름을 임의로 변경할 수 있습니다. [Thread.setName(String name)](https://docs.oracle.com/javase/8/docs/api/java/lang/Thread.html#setName-java.lang.String-) 따라서 Runnable 혹은 Callable을 구현할 때, 시작 부분에 현재 컨텍스트에 해당하는 문자열을 스레드 이름으로 설정하고, finally 블록으로 원래 스레드 이름을 복구하도록 코딩하면 현장에서 빠르게 장애를 진단하고 대응할 수 있습니다. 로그프레소는 system threads 쿼리를 통해서 스레드 실행 상태를 진단할 수 있는 기능을 제공하며, 외부 시스템이 흔히 연동되는 스트림 엔진에서는 실행 중인 스트림 쿼리 이름을 스레드 이름으로 설정함으로써 필드 엔지니어의 장애 진단을 지원합니다. 예를 들어 인덱스되지 않은 외부 데이터베이스의 테이블을 dblookup 커맨드로 참조하는 경우 낮은 SQL 조회 성능으로 인해 스트림이 JDBC 드라이버 스택에서 소켓 수신을 대기하고 있는 모습을 쉽게 확인할 수 있고, 해당 스트림 쿼리에 설정된 SQL 쿼리를 확인한 후 즉시 대응할 수 있습니다.

2017-02-02