JIT 컴파일러 탄생배경 및 정의
자바는 bytecode 명령어로 이루어진 .class 까지 javac 로 컴파일링 한 뒤 JVM 위에서 한 줄씩 bytecode > binary code 로 interpreting 한다. 반복하는 곳을 런타임 때 바이너리 코드로 미리 변환해두면 성능이 더 좋아질 것이라는 아이디어에서 JIT 컴파일러가 등장했다. JIT 컴파일러는 자주 실행되는 바이트코드를 감지하여 네이티브 머신 코드로 변환하고, 이후 동일한 바이트코드가 실행될 때는 미리 변환된 네이티브 바이너리 코드를 사용하여 변환을 스킵해서 성능을 향상시킨다. 이 JIT 은 Java HotSpot VM 내부에 포함된 컴파일러로, “런타임 시점”에 바이트코드를 분석하여 자주 호출되는 메서드를 식별하고, 해당 메서드들을 네이티브 코드로 변환하여 캐싱한다. 여기서 Java HotSpot VM 은 JVM 구현체고 openJDK, oracle 에서 이를 사용한다. wiki List_of_Java_virtual_machines 해당 VM 의 주 목적은 어떤 메소드가 자주 실행되는지 모니터링하면서 캐싱하고 최적화한다. architect-evans-pt1 JIT 컴파일러는 c1, c2 두 가지 컴파일링을 진행하는데 c1 은 short term, c2 는 long running 용 jit 컴파일러다. 보통 GUI 나 초기 캐싱이 필요한 작업의 경우 c1 에 의해 최적화 되고, 서버 사이드 작업의 경우 c2 에 의해 최적화 된다.
-XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation 옵션을 키고 JVM 을 실행하면 jit 컴파일러가 어떻게 동작하는지 아래와 같이 로그를 확인해보자.
<task compile_id='14227' method='org.ghkdqhrbals.client.domain.monitoring.RedisStreamMonitoringService$NodeMemoryInfo getUsedMemory ()J' bytes='5' count='128' iicount='128' stamp='14.308'>
<phase name='setup' stamp='14.308'>
<phase_done stamp='14.308'/>
</phase>
‘count’ is the invocation count as recorded by the method invocation counters. This may not appear in more recent versions of the log output. Note that these counters are mainly used for triggering compiles and are not guaranteed to be an accurate reflection of the number of times a method has actually executed. Multiple threads may be updated these counters and sometimes the VM will reset a counter to a lower value to delay retriggering of compiles.
‘iicount’ is the interpreter invocation count. This is a separate copy of the invocation count which is maintained by the profiling support. Again it’s not guaranteed to be accurate since multiple threads may update it but it’s never reset so it’s reasonably accurate. https://wiki.openjdk.org/spaces/HotSpot/pages/11829268/LogCompilation+overview
로그의 지표에 iicount, count 가 있는데 count 는 각각 메서드가 호출된 횟수, iicount 는 인터프리터가 호출된 횟수다. 그리고 c1 컴파일러가 자주 호출되는 메서드를 아래와 같이 네이티브 코드로 변환하고 호출한다.
<nmethod compile_id='1' compiler='c1' level='1' entry='0x000000010390c8c0' size='1000' address='0x000000010390c710' relocation_offset='336' insts_offset='432' stub_offset='792' scopes_data_offset='904' scopes_pcs_offset='928' dependencies_offset='992' metadata_offset='880' method='java.lang.String hashCode ()I' bytes='60' count='269' iicount='269' stamp='0.020'/>
level 은 Tiered Compilation 으로 그냥 단계별로 프로파일링을 구체화해서 컴파일링 한다. level 0 은 바이너리 코드 생성 없이 인터프리팅만 수행, level 1~3 까진 C1 컴파일러로 프로파일링을 구체화하고 최적화한다. 마지막 level 4 는 C2 컴파일러로 수행하고 더 이상 승격을 위한 프로파일링을 하지 않는다. https://devblogs.microsoft.com/java/how-tiered-compilation-works-in-openjdk/
- 종합하면 Hotspot VM 은 내부에 Jit compiler 가지고 있고 > 최적화를 위해 tiered compilation 으로 런타임 시 단계 최적화를 진행 > level 0~4 까지 있고 c1, c2 컴파일러를 사용한다.
GraalVM
앞서서 JIT 으로 부분적으로 bytecode > 네이티브 코드로 캐싱해서 사용하는 것을 확인했다. 하지만 여전히 JVM 오버헤드가 존재하고 그냥 모두 다 네이티브 코드로 변환시켜버리면 실행이 더 빠르지 않을까? 라는 아이디어에서 GraalVM 이 등장했다. GraalVM 은 JIT c2 를 대체할 수 있는 컴파일러로도 사용되고 AOT 컴파일러로도 사용되는 컴파일러이다. AOT 로 사용할 시 쌩 네이티브 코드로 변환시키니까 JVM 이 필요없고 JVM 띄우는 런타임 오버헤드가 줄어드니 시작 속도도 빨라지며(실제로 보면 0.039초 업타임) 메모리 사용량도 감소(300MB -> 100MB 로 66% 감소)하는 장점이 있다. 다만 JIT 의 다이나믹한 최적화가 불가능하니 성능이 떨어질 수 있고 빌드시간이 길어진다. 최신 자료는 아니지만 2019 년 기준 GraalVM AOT vs Hotspot JIT 성능 비교 자료는 아래와 같다. 처리량이 높아질 수록 GraalVM 의 최적화가 약간 떨어지는 것을 볼 수 있다. 여기서 PGO(미리 실행해보고 수집된 프로파일가지고 native image 재빌드)를 수행하면 어느정도 극복이 가능하다고 한다. reference: Cloud Native Java: GraalVM

그래서 GraalVM 은 어떨 때 쓰면 좋을까?
확실한 건 시작속도가 빨라진다는 점이다. 실행에 걸리는 시간은 1분 36초 > 0.039초로 실행하자마자 어플리케이션이 떠오른다. 배치 잡의 경우 실행속도가 전체 러닝타임 중 큰 비중을 차지할 것 같고 이 때 graalVM 이 좋은 선택이 될 듯 하다.


물론 @Profile 같이 런타임때 빈 등록을 결정하는 특정 어노테이션들은 사용하지 못하고 모두 교체해주어야한다. GC 는 Serial GC 가 기본 제공되고 stop-the-world 를 막아주는 G1 GC 를 지원한다.
GraalVM native image 는 메모리를 3배 절약시켜준다
여러 레퍼런스를 봤는데, spring boot/kotlin 환경에서 GraalVM native image 로 빌드했을 때 메모리 이점이 있는지 확실치 않았다. 이론적으로는 바이트 코드 대신 네이티브 코드를 메모리에 올리고 인터프리터 없어도 되니 메모리 사용량이 줄어들 것 같지만, GraalVM 이 분은 graalVM native image 로 빌드했을 때 메모리 사용량이 더 늘어났다고 한다. 실제로 확인해보자.
GraalVM native image 로 빌드한 뒤 관찰해봤는데, 아래는 spring boot app 을 graalVM native image 로 빌드한 뒤 메모리 사용량을 확인한 것이다.

왼쪽이 GraalVM native image, 오른쪽이 Hotspot JVM 이다. 로드 테스트는 Jmeter 로 1000 users/100 loop 진행했으며 GraalVM native image 쪽이 메모리 사용량이 낮게 나오는 것을 볼 수 있다. 로드 테스팅 시 GraalVM 은 약 100MB 근처에서 머무는 반면, JVM 은 300MB 근처까지 올라가는 것을 볼 수 있다. thread 는 graalVM 이 한번에 증가하고 JVM 에선 천천히 증가하는 것을 볼 수 있다.
메모리 사용량을 비교해보았을 때 역시 약 66% 정도 메모리 사용량이 줄어드는 것을 볼 수 있다. 하지만 완전 높은 스트레스 테스트를 진행할 시 결과는 JVM 이 최적화로 인해 더 나아진다(맨 위의 표로 보아).