-XX:+PrintCompilation 로그를 처음 켜 보면, 같은 메서드 이름이 컴파일 레벨만 3에서 4로 바뀐 채 두 번 찍히고, 곧이어 made not entrant라는 줄과 함께 사라진다. "JIT가 핫스팟을 한 번 컴파일한다"는 머릿속 그림으로는 이 장면이 설명되지 않는다. 왜 JVM은 멀쩡한 코드를 두 번 컴파일하고, 잘 돌던 컴파일 코드를 스스로 폐기할까. 그 답이 티어드 컴파일레이션(tiered compilation)과 역최적화(deoptimization)다.
HotSpot에는 성격이 정반대인 두 JIT 컴파일러가 있다.
옛날에는 둘 중 하나를 골라 썼다(-client/-server). 티어드 컴파일레이션은 둘을 단계적으로 같이 쓴다. 시작은 빠른 C1으로 일단 돌리면서 프로파일을 모으고, 충분히 뜨거워진 메서드만 C2로 다시 컴파일한다. 빠른 워밍업과 높은 정점 성능을 동시에 노리는 절충이다.
HotSpot은 실행 코드를 다섯 단계로 나눈다.
| 레벨 | 실행 주체 | 프로파일링 | 성격 |
|---|---|---|---|
| 0 | 인터프리터 | 카운터만 | 시작점 |
| 1 | C1 | 없음 | 완전 최적화 C1 (더 프로파일 안 함) |
| 2 | C1 | 제한적 (호출·백엣지 카운터) | 큐 포화 시 임시 경유지 |
| 3 | C1 | 완전 (MDO 수집) | 대부분의 메서드가 거치는 길 |
| 4 | C2 | 없음 (수집한 프로파일 소비) | 최종 고성능 코드 |
핵심 경로는 레벨 0 → 3 → 4 다. C2(레벨 4)가 좋은 코드를 뽑는 이유는 레벨 3에서 C1이 모아둔 타입·분기 프로파일을 입력으로 먹기 때문이다. C1을 건너뛰면 C2는 추측할 근거가 없다.
호출/루프 카운터 증가
│
[0] 인터프리터
│ Tier3 임계치 도달
▼
[3] C1 + 풀 프로파일 ──── MDO에 타입/분기 데이터 축적
│ Tier4 임계치 도달 (프로파일 충분)
▼
[4] C2 (최고 최적화)
│ speculation 깨짐 (uncommon trap)
▼ 역최적화
[0] 인터프리터로 복귀 → 재수집 → 재컴파일
전이는 임계치만 보는 게 아니다. C2 컴파일러 큐가 포화되면, 본래 레벨 3으로 갈 메서드를 잠시 레벨 2(제한적 프로파일 C1) 로 돌려 인터프리터보다는 빠르게 실행하다가, 큐가 비면 3→4로 끌어올린다. 큐 길이에 반응하는 적응형 동작이다.
메서드마다 두 카운터가 있다. 호출 카운터(메서드 진입 횟수)와 백엣지 카운터(루프가 뒤로 점프한 횟수)다. OpenJDK HotSpot 문서에 따르면 레벨 3 진입은 Tier3InvocationThreshold(기본 200 부근), 레벨 4 진입은 Tier4InvocationThreshold(기본 5000 부근)의 조합으로 판정된다. 정확한 상수는 버전·플랫폼마다 다르므로 "수백 회면 C1, 수천 회면 C2" 정도의 자릿수로만 잡는 게 안전하다.
단순 총합이 아니라 단위 시간당 증가율까지 본다는 게 AdvancedThresholdPolicy의 요점이다. 같은 1만 회라도 짧은 구간에 몰린 호출은 "지금 뜨거운" 코드이고, 프로그램 전체에 흩뿌려진 호출은 C2까지 끌어올릴 가치가 낮다. 그래서 정책은 큐가 한가하면 임계치를 낮춰 적극적으로, 바쁘면 높여 보수적으로 승급한다.
레벨 3가 모으는 프로파일은 메서드마다 붙는 MethodData(MDO) 에 쌓인다. 호출 지점·분기마다 카운터와 관측된 타입(보통 상위 2개)을 기록한다. 그래서 레벨 3 코드는 레벨 1 코드보다 느리다 — 매 분기마다 MDO를 갱신하는 계측이 끼어 있기 때문이다. "프로파일을 충분히 모았으면 빨리 레벨 4로 졸업시키고 싶은" 동기가 여기서 나온다.
또 하나의 빈틈: 메서드 호출은 드문데 루프 하나가 수백만 번 도는 경우(예: main의 거대한 for), 호출 카운터로는 영영 컴파일 대상이 안 된다. 이때 백엣지 카운터가 임계치를 넘으면 HotSpot은 이미 실행 중인 인터프리터 스택 프레임의 그 루프를 컴파일 코드로 갈아끼운다. 이것이 OSR(On-Stack Replacement)이다. 살아있는 지역 변수·표현식 스택을 컴파일 프레임 레이아웃으로 매핑해 점프해야 하므로, OSR 코드는 일반 메서드 진입용 코드와 별도로 생성된다.
C2의 최적화는 대부분 추측(speculation) 에 기반한다.
ArrayList였다"를 보면 그 타입을 가정한다.if는 한 번도 안 탔다" → 그 분기를 트랩으로 둔다.문제는 이 가정이 나중에 깨질 수 있다는 것이다. 새 서브클래스가 로드돼 CHA가 무효화되거나, 처음 보는 타입이 들어오면 컴파일 코드는 더 이상 정확하지 않다. 이때 HotSpot은 uncommon trap으로 역최적화한다.
처리 방식은 두 갈래로 알려져 있다. 가정이 일시적으로 어긋난 것이면 인터프리터로 돌려 그대로 다시 실행(reinterpret)하고, 같은 트랩이 반복되면 그 추측 자체를 폐기하도록 재컴파일을 예약한다. 후자가 반복되면 해당 call site는 "이 추측은 더 이상 하지 말라"로 표시돼, C2가 다음 컴파일 때 같은 함정을 피한다.
즉 역최적화는 "JIT가 실패했다"가 아니다. C2가 애초에 공격적으로 가정할 수 있는 이유 자체가 "틀리면 deopt로 되돌리면 된다"는 안전망 덕분이다.
$ java -XX:+PrintCompilation Demo
78 1 3 Demo::hot (12 bytes) # 레벨 3 (프로파일 C1)
91 2 4 Demo::hot (12 bytes) # 레벨 4 (C2)로 승급
91 1 3 Demo::hot (12 bytes) made not entrant # 레벨3 코드 폐기
102 3 % 4 Demo::loop @ 5 (40 bytes) # %=OSR, @5=백엣지 bci
출력의 숫자가 컴파일 레벨, %는 OSR, @5는 OSR이 일어난 바이트코드 인덱스다. % 4는 "루프 자체를 레벨 4로 OSR 컴파일했다"는 뜻으로, 메서드가 아니라 루프가 컴파일 대상이 됐음을 보여준다. made not entrant가 찍히는 순간이 곧 역최적화/재컴파일 경계다.
티어드 컴파일레이션은 빠른 C1으로 일단 돌려 프로파일을 모으고, 뜨거워진 메서드만 C2로 다시 컴파일하는 5단계 상태 머신이다. 역최적화는 그 공격적 최적화를 가능하게 하는 전제다.
직접 따라가며 정정한 오해 셋:
더 파고들 만한 것: C2의 인라이닝 휴리스틱(MaxInlineSize, FreqInlineSize)과 메가모픽 call site에서의 인라이닝 포기, 그리고 같은 티어드 구조에서 최상위 컴파일러만 Graal(JVMCI)로 교체하는 방식.
globals.hpp의 TierNInvocationThreshold 플래그군.-XX:+PrintCompilation 출력 포맷.