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 페이지를 참고하시기 바랍니다.

둘러보기

더보기

GraalVM 소개

로그프레소 빅데이터 플랫폼은 복잡한 사용자 분석 기능 확장을 지원하기 위하여 2014년 이래 그루비, 자바스크립트 엔진을 내장하여 지원하고 있습니다. 지금까지는 자바스크립트를 구동하는데 Nashorn 엔진을 사용했는데요. 자바 11이 출시되면서 Nashorn은 제거 예정 상태로 변경되었고 GraalVM으로 대체를 권고하고 있습니다. 이번 글에서는 GraalVM에 대한 전반적인 개요를 소개합니다. ## GraalVM 개발 배경 GraalVM은 2005년에 썬 마이크로시스템즈에서 Maxine 가상머신 프로젝트로 시작되었습니다. 자바 가상머신(JVM)은 C++ 언어로 구현되어 있는데, 이 프로젝트의 목표는 자바 가상머신 전체를 자바 언어로 다시 작성하는 것이었습니다. 그러나 모든 코드를 한 번에 다 갈아엎는다는 것이 현실적으로 매우 달성하기 어렵기 때문에, 기존 핫스팟 런타임을 최대한 재사용하면서 플러그인으로 JIT 컴파일러를 끼워넣는 방향으로 선회하여 오늘에 이르렀습니다. ## GraalVM 구성 ![](/media/ko/2020-05-10-graalvm/graal-arch.png) GraalVM JIT 컴파일러는 자바 9 버전에 추가된 JVMCI (JVM 컴파일러 인터페이스)를 이용하여 기존 핫스팟 런타임에 플러그인 되는 구조로 동작합니다. 자바나 스칼라 같은 JVM 기반 언어는 GraalVM JIT 컴파일러의 최적화를 통해 성능 향상을 기대할 수 있습니다. 그 위에 Truffle 프레임워크가 올라가는데, 이는 자바스크립트, R, 파이썬, 루비 등 JVM 기반이 아닌 기존 언어의 새로운 구현을 지원합니다. ## GraalVM 활용 분야 * 기존 자바 응용 프로그램의 성능 향상 * 트위터의 경우 GraalVM을 적용해서 기존 Scala 코드에 대해 약 20%의 성능 향상 달성 * 다양한 언어 확장 * 자바 코드에서 자바스크립트, R, 파이썬, LLVM IR, 웹 어셈블리 실행 가능 * 각 언어별 라이브러리 활용 가능 (예: R이나 파이썬에서 데이터 분석 후 자바스크립트로 출력) * 고성능이 필요한 모듈을 C/C++로 구현 * 호스트 접근 필터링 기능으로 스크립트 실행 시 보안성 향상 * 네이티브 이미지 생성 * AOT 컴파일을 통해 부팅 시간 단축, 이미지 크기 최소화 * 특히 최근의 컨테이너 기반 마이크로서비스 아키텍처에 활용성 높음 * 기존 언어의 대량 메모리 사용 지원 * 자바 가상머신은 수십 년간 GC를 개선하여 테라바이트 단위의 힙 메모리까지 지원 가능 * GraalVM 기반으로 구현된 기존 언어는 대량 메모리 사용 시나리오도 지원할 수 있음 ## GraalVM 구동 방법 OpenJDK 11 버전 이상을 사용하고 있다면 아래와 같이 부팅 스위치를 추가하여 GraalVM JIT을 활성화 할 수 있습니다. 아래 구성은 Graal JIT만 사용하는 최소 구성입니다: ``` -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI --module-path=graalvm --upgrade-module-path=graalvm/compiler.jar ``` 아래 3개의 파일이 graalvm 위치에 있어야 합니다. (20.0.0 버전 기준으로 약 21MB) * [compiler.jar](https://repo1.maven.org/maven2/org/graalvm/compiler/compiler/20.0.0/compiler-20.0.0.jar) * [graal-sdk.jar](https://repo1.maven.org/maven2/org/graalvm/sdk/graal-sdk/20.0.0/graal-sdk-20.0.0.jar) * [truffle-api.jar](https://repo1.maven.org/maven2/org/graalvm/truffle/truffle-api/20.0.0/truffle-api-20.0.0.jar) 시스템 프로퍼티에 아래 속성들이 추가되면 정상적으로 GraalVM이 핫스팟 런타임에 플러그인 된 것입니다. ``` jdk.internal.vm.ci.enabled=true jdk.module.path=graalvm jdk.module.upgrade.path=graalvm/compiler.jar ``` 다음 글에서는 자바스크립트, 파이썬 코드를 실제 구동하는 방법에 대해 알아보겠습니다. ### 레퍼런스 * [Maxine Virtual Machine](https://en.wikipedia.org/wiki/Maxine_Virtual_Machine) * [Running GraalJS on stock JDK11](https://github.com/graalvm/graal-js-jdk11-maven-demo) * [Understanding How Graal Works - a Java JIT Compiler Written in Java](https://chrisseaton.com/truffleruby/jokerconf17/)

2020-05-10

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

자바 응용프로그램의 장애는 크게 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

ETS 모델 기반 시계열 예측

미래를 예측하고 대비하는 일은 비즈니스의 가장 핵심적인 부분입니다. 전력 수요를 예측해야 발전소를 증설할 수 있고, 콜센터의 통화수를 예측해야 적정한 수의 상담원을 배치할 수 있습니다. 예측 모델은 여러가지 변수를 포함할 수 있지만, 과거의 이력을 기반으로 미래의 추세를 예측한다는 점에서는 공통적입니다. 가장 간단한 단순회귀분석부터, 다중회귀분석, 딥러닝에 이르기까지 여러가지 모델링 방법이 있지만, 여기에서는 시계열 데이터에 쉽게 적용할 수 있으면서도 좋은 결과를 보여주는 ETS 모델을 알아보도록 하겠습니다. ETS 모델은 업계에서 널리 사용되는 시계열 예측 모델로서, 지수평활법(Exponential Smoothing)을 기반으로 합니다. 지수평활법은 과거의 관측치에 시간의 흐름에 따른 가중치를 주고 합산하여 미래를 예측하는 방식입니다. 단순 지수평활법 (Single Exponential Smoothing) 에서 출발하여 하나씩 살펴보면 ETS 모델을 이해할 수 있습니다. ## 지수평활법: Exponential Smoothing 단순 지수평활법 (Single Exponential Smoothing)은 다음 예측치 (St)를 현재 값 (yt−1)과 이전 예측치(St−1)의 합산으로 계산합니다. 알파(α)는 0보다 크고 1보다 작은 스무딩 매개변수입니다: <blockquote>S<sub>t</sub> = α y<sub>t−1</sub> + (1−α) S<sub>t−1</sub></blockquote> 실제 이 수식이 어떻게 동작하는지 예제 값을 넣어서 직관적으로 이해해볼 수 있습니다. 아래의 예제는 엑셀로 수식을 만들어서 스무딩 매개변수의 조정에 따른 변화를 표현한 것입니다. α = 0.1 ![](/media/ko/2017-01-17-time-series/alpha_0.1.png) α = 0.5 ![](/media/ko/2017-01-17-time-series/alpha_0.5.png) α = 0.9 ![](/media/ko/2017-01-17-time-series/alpha_0.9.png) 매우 단순한 수식이지만 스무딩 매개변수에 따라 원본 그래프에 근접하게 변화하는 모습을 볼 수 있습니다. 최적의 스무딩 매개변수를 찾으면 해당 수식을 이용하여 미래의 값도 재귀적으로 예측할 수 있습니다. ![](/media/ko/2017-01-17-time-series/single_exp_graph.png) 13번 행부터는 관측치가 없기 때문에 y를 마지막 값으로 고정하고 계산하면 위와 같이 예측치가 계산됩니다. 즉, 시계열 예측이 스무딩 매개변수에 따른 모형의 에러를 최소화하는 최적화 문제로 변환된 것입니다. 그러나, 단순 지수평활법의 단점은 추세가 있는 경우 잘 모델링하지 못한다는 점입니다. 이중 지수평활법 (Double Exponential Smoothing) 은 이러한 단점을 보완합니다. 아래의 예제는 이미 각 모델에 대해 최적으로 선정된 스무딩 매개변수 값을 사용하여 계산된 결과를 보여줍니다. ![](/media/ko/2017-01-17-time-series/double_exp_graph.png) 이중 지수평활법은 두 개의 방정식을 사용합니다. <blockquote>S<sub>t</sub> = α y<sub>t</sub> + (1 − α) (S<sub>t−1</sub> + b<sub>t−1</sub>)<br><br>b<sub>t</sub> = γ (S<sub>t</sub> - S<sub>t-1</sub>) + (1 − γ) b<sub>t−1</sub><br><br>F<sub>t+m</sub> = S<sub>t</sub> + mb<sub>t</sub></blockquote> 첫번째 수식은 이전 St-1 값에 추세변화량을 더하여 기저를 생성합니다. 두번째 수식은 추세변화량을 보정하는 역할을 수행합니다. 예측치는 기저와 추세변화량을 합산한 값입니다. 위의 그래프를 통해 단순 지수평활법과 이중 지수평활법의 예측 특성 차이를 확인할 수 있습니다. 이렇게 이중 지수평활법은 추세를 반영하지만 여기에 더해서 계절성 (Seasonality)이 있는 경우를 잘 반영하지 못합니다. 이 때문에 삼중 지수평활법 (Triple Exponential Smoothing) 혹은 홀트-윈터스 모델 (Holt-Winters) 이라 불리는 방법이 제안되게 됩니다. ## ETS 모델 ETS 모델은 Error, Trend, Seasonality 3가지 요소로 구성된 모델을 의미합니다. 시계열 데이터는 추세의 특성과, 계절성을 각각 조합하여 다음과 같이 12가지의 유형을 상정할 수 있습니다. ![](/media/ko/2017-01-17-time-series/ets_model.png) 각 모델을 수식으로 표현하면 아래와 같습니다. 예를 들어, 에러, 추세, 계절성이 모두 가산적인 모델이라면 ETS(A,A,A) 혹은 ETS AAA 모델로 표기합니다. 계절성이 없는 가산적 모델이라면 ETS(A,A,N)에 해당됩니다. * N: 상수 모델 (Constant) * A: 가산적 모델 (Additive) * A<sub>d</sub>: 감쇄하는 가산적 모델 (Damped Additive) * M: 승법적 모델 (Multiplicative) * M<sub>d</sub>: 감쇄하는 승법적 모델 (Damped Multiplicative) ![](/media/ko/2017-01-17-time-series/ets_error_model.png) ## forecast 커맨드 로그프레소는 주어진 시계열 데이터에 대해 위와 같이 다양한 ETS 모델을 대상으로 에러를 최소화하는 시계열 모형을 자동으로 탐색합니다. 예측된 값은 _future 필드로 출력되며, 추세 (_trend), 상위 95% 신뢰구간 (_upper), 하위 95% 신뢰구간 (_lower) 필드를 동시에 출력합니다. ```query table order=asc passengers | forecast passengers ``` ![](/media/ko/2017-01-17-time-series/forecast.png)

2017-01-17