JIT 컴파일 튜닝 — 임계값과 컴파일러 제어

JIT 컴파일러가 언제 어떤 메서드를 컴파일하는지 결정하는 호출 임계값과 단계형 컴파일을 살펴보고, PrintCompilation으로 컴파일 동작을 관찰하는 법, 그리고 섣부른 플래그 변경의 위험과 웜업의 중요성을 정리합니다.

· 7 min read · PALDYN Team

지난 글로 애너테이션 편을 마무리하고, 이번 글부터는 JVM이 우리 코드를 실행하면서 어떻게 점점 더 빠르게 만드는지를 다룹니다. 자바가 “느리다”는 오해는 대개 이 메커니즘을 모르는 데서 옵니다. JVM은 처음엔 바이트코드를 인터프리터로 한 줄씩 실행하지만, 자주 도는 코드(핫코드)를 발견하면 그것을 네이티브 기계어로 컴파일해 C/C++에 근접한 속도로 끌어올립니다. 이 실행 중 컴파일이 바로 JIT(Just-In-Time)이고, 이번 글은 그 동작을 어떻게 관찰하고 제어하는지에 초점을 맞춥니다.

핫코드는 어떻게 판별되나

JVM은 모든 메서드에 호출 카운터백엣지 카운터(루프 반복 횟수)를 둡니다. 이 값이 임계값을 넘으면 “이 코드는 컴파일할 가치가 있다”고 판단해 컴파일 큐에 넣습니다. 핵심 임계값은 -XX:CompileThreshold(단계형 컴파일에서는 -XX:Tier4InvocationThreshold 등)입니다.

호출 카운터와 컴파일 임계값

오늘날 HotSpot은 기본적으로 단계형 컴파일(Tiered Compilation) 을 씁니다. 인터프리터 → C1(빠르게 컴파일하되 프로파일 수집) → C2(프로파일을 활용한 강력한 최적화)로 메서드가 점점 승급합니다. C1은 컴파일이 빨라 초반 응답성을 살리고, C2는 시간이 걸리지만 최고 성능 코드를 만듭니다. 둘을 합쳐 “빠른 시작”과 “높은 최고 성능”을 모두 얻는 전략입니다.

컴파일 동작 관찰하기

튜닝의 첫걸음은 추측이 아니라 관찰입니다. -XX:+PrintCompilation을 붙이면 어떤 메서드가 언제 어느 티어로 컴파일됐는지 로그가 찍힙니다.

java -XX:+PrintCompilation -jar app.jar

출력의 각 줄은 대략 이렇게 읽습니다.

  1234   456       4       com.example.Hot::loop (43 bytes)
# │      │         │       └ 컴파일된 메서드
# │      │         └ 티어 (4 = C2)
# │      └ 컴파일 ID
# └ JVM 시작 후 경과(ms)

% 표시는 OSR(On-Stack Replacement) 컴파일로, 실행 중인 긴 루프를 컴파일된 버전으로 갈아끼웠다는 뜻입니다. made not entrantmade zombie가 보이면 해당 코드가 역최적화되어 폐기됐다는 신호입니다.

역최적화 — 되돌릴 수 있어서 과감할 수 있다

C2는 프로파일을 근거로 과감한 가정을 합니다. “이 호출지점은 항상 같은 타입이더라” 같은 가정 아래 가상 호출을 직접 호출로 바꾸거나 인라이닝합니다. 그런데 런타임에 그 가정이 깨지면(예: 처음 보는 하위 타입 등장) 컴파일된 코드는 잘못된 것이 됩니다. 이때 JVM은 역최적화(deoptimization) 로 안전하게 인터프리터로 돌아간 뒤, 새 프로파일로 다시 컴파일합니다. 과감한 최적화가 가능한 이유는 바로 이 안전망 덕분입니다.

튜닝 플래그 — 그리고 함정

JIT 동작을 바꾸는 플래그는 많지만, 대부분의 경우 건드리지 않는 것이 정답입니다.

JIT 튜닝 플래그와 원칙

가장 흔한 실수는 웜업을 무시한 측정입니다. 프로그램 시작 직후 몇 번 돌린 결과는 아직 인터프리터 또는 C1 단계의 성능일 뿐, 안정 상태의 성능이 아닙니다.

// 잘못된 측정: 웜업 없이 첫 실행을 잼
long start = System.nanoTime();
result = compute();          // 아직 C2로 컴파일되기 전
long elapsed = System.nanoTime() - start;

이 함정 때문에 마이크로벤치마크는 손으로 짜면 거의 틀립니다. 충분한 웜업, 데드코드 제거 방지, 다중 포크 같은 장치가 필요하고, 이를 표준화한 것이 다음에 다룰 JMH입니다. 또 -Xcomp(모든 메서드를 즉시 C2 컴파일)는 시작이 느려지고 프로파일이 없어 오히려 나쁜 코드를 만들 수 있으니, 디버깅 외에는 쓰지 마세요.

정리

JIT 컴파일러는 실행 중 핫코드를 골라 단계적으로 컴파일하고, 가정이 깨지면 역최적화로 되돌립니다. 단계형 컴파일과 기본 임계값은 이미 대부분의 워크로드에 잘 튜닝되어 있으므로, 우리가 할 일은 보통 플래그를 바꾸는 것이 아니라 동작을 관찰하고 웜업을 존중하는 것입니다. 다음 글에서는 C2가 수행하는 대표적 최적화 중 하나인 이스케이프 분석을 다룹니다 — 객체 할당을 아예 없애 버리는 영리한 기법입니다.


지난 글: 애너테이션 프로세싱 — 컴파일 시점에 코드 읽고 생성하기

다음 글: 이스케이프 분석 — 힙 할당을 없애는 JIT 최적화


읽어주셔서 감사합니다. 😊