JVM JIT Compiler 내부 동작 — C1, C2, Tiered Compilation, 그리고 OSR
자바 코드가 빠르게 도는 이유는 단순히 "JIT가 컴파일해주니까"가 아니에요. HotSpot은 인터프리터로 시작해서, C1으로 빠르게 한 번 컴파일하고, 다시 C2로 깊이 최적화하는 4단계 계층 구조를 거쳐요. 그 사이에는 OSR로 한참 돌고 있는 루프를 갈아끼우고, 가정이 깨지면 Deoptimization으로 다시 인터프리터로 되돌리는 정교한 안전장치가 작동해요. 이 글에서는 HotSpot JIT 컴파일러가 메서드 하나를 어떻게 단계적으로 최적화하는지, 그리고 그 안에서 C1·C2·OSR·Deoptimization이 어떤 역할을 맡는지 정리해봐요.
왜 JIT가 필요한가요
자바 바이트코드는 JVM이라는 가상 머신 위에서 도는 추상 명령어예요. 가장 단순한 실행 방식은 인터프리터로, 바이트코드 한 줄을 읽어 그에 해당하는 동작을 매번 수행해요. 시작은 빠르지만 같은 코드를 백만 번 실행해도 매번 디코딩과 디스패치 비용을 새로 내야 해요.
반대로, 처음부터 전체 프로그램을 네이티브 코드로 컴파일해버리면 실행은 빨라지지만 시작이 느려져요. 게다가 자바 코드는 클래스 로딩 시점이나 런타임 프로파일 정보에 따라 최적화 가능성이 크게 달라지기 때문에, 미리 모든 걸 컴파일하면 좋은 결정을 내릴 수가 없어요.
HotSpot은 이 둘의 절충점을 택했어요. 인터프리터로 빠르게 출발해 자주 쓰이는 코드(hot spot)를 찾아내고, 그 부분만 컴파일해서 네이티브 코드로 바꿔놓아요. 이 "실시간 컴파일"이 바로 JIT(Just-In-Time) 컴파일이에요.
flowchart LR
Source[Java Source] --> Bytecode[.class Bytecode]
Bytecode --> Interp[Interpreter]
Interp -->|hot method detected| JIT[JIT Compiler]
JIT --> Native[Native Code in Code Cache]
Native -->|invocation| CPU[CPU executes]
Interp -->|cold path| CPU
두 개의 컴파일러 — C1과 C2
HotSpot은 컴파일러를 하나만 두지 않고 두 개를 갖고 있어요. C1(Client Compiler) 과 C2(Server Compiler) 라고 불러요. 이름은 옛날 JVM이 클라이언트 모드와 서버 모드를 따로 두던 시절에 굳어진 거고, 지금은 하나의 JVM 안에 둘 다 들어 있어요.
C1 — 빠르게 컴파일하고 끝내는 쪽
C1은 컴파일을 빠르게 끝내는 데 초점이 맞춰져 있어요. 기본적인 최적화만 적용하고 결과 코드를 빨리 내놓아요. 시작 직후, 아직 프로파일 정보가 충분히 쌓이지 않은 상태에서 자주 쓰이는 메서드를 일단 인터프리터보다 빠른 네이티브 코드로 바꿔놓는 역할을 맡아요.
C1은 SSA 기반의 단순한 IR을 쓰고, 최적화 단계가 짧아요. 그래서 컴파일 자체에 드는 시간과 메모리가 적어요. 일반적으로 같은 메서드를 컴파일할 때 C1이 C2보다 한 자릿수 배 빠르게 끝내요.
C1이 적용하는 최적화는 비교적 단순한 것들이에요. 상수 폴딩, 죽은 코드 제거, 짧은 메서드의 인라이닝, 단순한 루프 최적화, 그리고 카운터 갱신 코드 삽입 정도예요. 핵심은 "충분히 빠른 코드를 빨리"예요. C1이 만든 코드 안에는 호출 횟수, 분기 방향, 호출되는 구체 타입 같은 정보를 기록하는 MDO(Method Data Object) 갱신 코드가 같이 박혀 있어요. 이 데이터가 나중에 C2가 공격적인 가정을 하는 근거가 돼요.
C2 — 시간 들여 깊게 최적화하는 쪽
C2는 반대 방향이에요. 컴파일에 시간이 더 걸리더라도 최종 코드의 성능을 끌어올리는 게 목적이에요. 메서드 인라이닝, 이스케이프 분석, 루프 언롤링, 죽은 코드 제거 같은 공격적인 최적화를 적용해요.
C2는 Sea of Nodes 라는 독특한 IR을 써요. 제어 흐름과 데이터 흐름을 하나의 그래프로 합쳐 표현하기 때문에, 노드를 자유롭게 이동시키며 최적화하기에 유리해요. Cliff Click이 90년대 후반에 설계한 구조로, 지금도 HotSpot C2의 핵심이에요.
C2가 좋은 성능을 내려면 충분한 프로파일 정보가 있어야 해요. 어떤 분기가 자주 타는지, 어떤 타입이 실제로 호출되는지를 알아야 그 가정 위에서 공격적으로 최적화할 수 있어요. 그래서 C2는 보통 충분히 프로파일링이 끝난 코드에 대해서만 동작해요.
컴파일러 스레드
C1과 C2는 메인 스레드가 아니라 별도의 컴파일러 스레드에서 돌아요. 컴파일 작업은 큐에 들어가고, 컴파일러 스레드가 큐에서 메서드를 꺼내 컴파일을 진행해요.
스레드 수는 CPU 코어 수에 따라 자동으로 정해져요. 보통 C1 컴파일러 스레드와 C2 컴파일러 스레드가 약 1:2 비율로 만들어지고, 합쳐서 총 (코어수 * log2(코어수))/8 같은 휴리스틱으로 결정돼요. -XX:CICompilerCount로 수동 조정도 가능하지만, 대부분은 기본값이 충분해요.
컴파일 큐가 길어지면 메서드가 적절한 시점에 컴파일되지 못해 성능이 잠시 떨어질 수 있어요. 큐 상태는 jcmd <pid> Compiler.queue로 확인할 수 있어요.
Tiered Compilation — 5단계 계층
C1과 C2를 어떻게 함께 쓸지가 문제예요. 자바 7부터 도입된 Tiered Compilation 이 그 답이에요. 인터프리터부터 C2까지를 5단계로 나누고, 메서드가 얼마나 뜨거운지에 따라 단계를 올려가요.
| 레벨 | 실행 주체 | 프로파일링 |
|---|---|---|
| 0 | Interpreter | 카운터 기반 |
| 1 | C1 | 프로파일링 없음 |
| 2 | C1 | 제한적 (invocation + backedge counter) |
| 3 | C1 | 전체 프로파일링 |
| 4 | C2 | 프로파일 사용, 최적화 |
흐름은 이렇게 진행돼요.
flowchart LR
L0[Level 0<br/>Interpreter] -->|hot| L3[Level 3<br/>C1 with full profiling]
L3 -->|enough profile| L4[Level 4<br/>C2 optimized]
L0 -->|C2 queue full| L2[Level 2<br/>C1 limited profiling]
L2 -->|profile ready| L3
L0 -->|C2 unlikely| L1[Level 1<br/>C1 no profiling]
L4 -->|assumption violated| L0
특이한 건 레벨 0에서 바로 4로 가지 않는다는 점이에요. 메서드는 보통 인터프리터(0)에서 시작해 C1으로 프로파일링을 받으면서(3) 정보를 모으고, 충분히 모이면 C2(4)로 다시 컴파일돼요. C1으로 한 번 들렀다 가는 이유는 두 가지예요. 하나는 C2 컴파일이 끝날 때까지 인터프리터로만 도는 것보다 C1으로 컴파일된 코드로 도는 게 빠르고, 다른 하나는 그 동안 C1이 프로파일을 모아주기 때문이에요.
레벨 1과 2는 보조 경로예요. C2 큐가 꽉 차서 4단계까지 못 갈 것 같으면 프로파일을 덜 쌓고 빠르게 컴파일(레벨 2)하고, 메서드가 사소해서 C2로 갈 필요가 없어 보이면 프로파일 없이 C1으로 단순 컴파일(레벨 1)해요.
임계값 설정
각 레벨로 올라가는 임계값은 -XX:Tier3InvocationThreshold, -XX:Tier4InvocationThreshold 같은 플래그로 조정할 수 있어요. 기본값은 레벨 3 호출 임계값이 200, 레벨 4가 5000 정도예요. 호출 카운터뿐 아니라 루프 백엣지 카운터도 함께 보고 있어서, 호출은 적지만 루프가 많이 도는 메서드도 단계가 올라가요.
전체 동작을 끄고 켜는 플래그도 있어요.
# 티어드 컴파일 비활성화 (예전 -client/-server 동작에 가까움)
java -XX:-TieredCompilation -jar app.jar
# C1까지만 사용, C2 비활성화 (시작 빠르게)
java -XX:TieredStopAtLevel=1 -jar app.jar
# 컴파일 임계값 조정 (티어드 비활성화 시에만 의미 있음)
java -XX:-TieredCompilation -XX:CompileThreshold=10000 -jar app.jar
-XX:TieredStopAtLevel=1은 컨테이너에서 단명하는 자바 프로세스(CLI 도구, 빌드 스크립트 등)에서 자주 쓰여요. C2 컴파일이 끝날 즈음에는 프로세스가 이미 끝나 있을 수도 있거든요.
메서드 하나의 일생
호출 카운터, 컴파일 큐, 코드 캐시가 얽혀 있는 구조라 글만 읽으면 추상적으로 느껴질 수 있어요. 메서드 하나의 관점에서 일생을 따라가볼게요.
- 메서드가 처음 호출되면 인터프리터가 실행해요. 동시에 호출 카운터를 1 증가시켜요.
- 호출이 누적되어 카운터가 임계값(Tier3InvocationThreshold)을 넘으면 C1 컴파일 작업이 큐에 들어가요. 큐는 별도 컴파일러 스레드가 처리해요.
- C1 컴파일러 스레드가 메서드를 가져가서 IR로 변환하고 최적화한 뒤 네이티브 코드를 만들어요. 이 결과는 nmethod 라는 객체로 감싸져서 코드 캐시에 저장돼요.
- 다음 호출부터는 nmethod로 점프해서 네이티브 코드가 직접 실행돼요. C1이 만든 코드에는 호출/분기 카운터를 갱신하는 짧은 코드가 함께 들어 있어요.
- C1으로 도는 동안 카운터가 다시 Tier4 임계값을 넘으면 C2 컴파일 작업이 큐에 들어가요.
- C2 컴파일러 스레드가 그 메서드를 가져가서 더 공격적으로 최적화하고, 새 nmethod를 코드 캐시에 추가해요.
- 다음 호출부터는 C2 버전이 실행되고, 옛 C1 nmethod는 더 이상 진입되지 않아요. 일정 시간이 지나면 청소 작업이 회수해가요.
이 과정 전체에서 인터프리터는 멈추지 않고, 컴파일러 스레드는 별도로 돌아요. 그래서 컴파일 비용이 메인 실행을 막지 않아요.
실제 PrintCompilation 한 줄 한 줄 따라가기
이 흐름을 -XX:+PrintCompilation 출력으로 다시 보면 이런 식이에요. 임의의 메서드 Main::work가 호출되는 상황을 가정해볼게요.
312 1 3 Main::work (45 bytes)
315 2 3 Main::helper (12 bytes)
480 3 4 Main::helper (12 bytes)
612 4 4 Main::work (45 bytes)
615 1 3 Main::work (45 bytes) made not entrant
해석해보면 이래요.
- 312ms 시점에
work가 충분히 호출되어 C1 레벨 3 (full profiling)으로 컴파일됐어요. 이제부터 이 메서드 호출은 C1 nmethod로 점프해 들어가요. 그 안에서 MDO를 갱신하는 코드가 함께 돌아요. - 315ms에
helper도 같은 식으로 레벨 3 컴파일됐어요. - 480ms 시점,
helper에 충분한 프로파일이 쌓여 C2 레벨 4로 재컴파일됐어요. 이제helper호출은 더 최적화된 nmethod로 가요. - 612ms에
work도 레벨 4로 재컴파일됐어요. 동일한 메서드의 새 nmethod가 코드 캐시에 들어가고, 다음 호출부터 그쪽으로 점프해요. - 615ms에 옛 레벨 3
worknmethod가 "made not entrant"로 표시됐어요. 더는 새 호출이 들어오지 않는다는 뜻이에요. 이미 실행 중인 프레임은 끝까지 그대로 돌고, 일정 시간이 지나면 청소돼요.
이 다섯 줄만 봐도 메서드 하나가 어떻게 0 → 3 → 4로 단계를 올라가고 옛 버전이 폐기되는지 흐름이 보여요.
OSR — 실행 중에 메서드를 갈아끼우기
여기까지의 시나리오는 "메서드가 충분히 호출돼서 다음 호출부터 컴파일된 코드를 쓴다" 였어요. 그런데 메서드 한 번이 너무 길게 도는 경우가 있어요. 가장 흔한 예는 긴 루프 예요.
public static long sumPrimes(long n) {
long sum = 0;
for (long i = 2; i <= n; i++) {
if (isPrime(i)) sum += i;
}
return sum;
}
n이 1억쯤 되면 이 메서드는 한 번 호출되고 한참을 안 돌아와요. 호출 카운터는 1인데 루프 내부는 1억 번 도는 거예요. 이 메서드를 "다음 호출 때 컴파일된 코드로" 바꿔봐야 그 다음 호출이 영영 안 올 수도 있어요.
이 경우를 위해 HotSpot은 OSR(On-Stack Replacement) 라는 기법을 써요. 메서드가 실행되는 도중에 그 스택 프레임을 컴파일된 코드의 프레임으로 갈아끼우는 거예요.
동작은 이렇게 흘러가요.
- 인터프리터는 메서드 호출 카운터뿐 아니라 루프 백엣지 카운터도 따로 들고 있어요. 루프가 한 바퀴 돌 때마다 카운터를 증가시켜요.
- 백엣지 카운터가 임계값(
-XX:OnStackReplacePercentage로 조정)을 넘으면, 그 시점의 바이트코드 인덱스(BCI)를 진입점으로 삼아 OSR 컴파일 작업을 큐에 넣어요. - 컴파일러는 이 메서드를 일반 메서드와는 다르게 컴파일해요. 진입점이 메서드의 시작이 아니라 그 루프 안의 특정 BCI거든요. 이렇게 만들어진 nmethod를 OSR nmethod 라고 불러요.
- 인터프리터는 다음 루프 백엣지에서 OSR nmethod로 점프해 들어가요. 그 시점의 지역 변수와 스택 상태가 그대로 OSR nmethod의 프레임으로 옮겨져요.
- 그 이후의 반복은 모두 컴파일된 코드에서 일어나요.
flowchart TB
Start[Method called once] --> Loop[Interpreter runs the loop]
Loop --> Counter[Backedge counter increments]
Counter -->|threshold reached| Queue[OSR compile request<br/>with osr_bci]
Queue --> Compile[Compiler emits OSR nmethod]
Compile --> Wait[Interpreter keeps looping]
Wait -->|next backedge| Jump[Jump into OSR nmethod]
Jump --> Compiled[Remaining iterations run native]
OSR nmethod는 일반 nmethod와 따로 관리돼요. 같은 메서드에 대해 OSR 버전과 일반 버전이 동시에 코드 캐시에 존재할 수 있고, 같은 메서드의 다른 루프(다른 BCI)에 대해 여러 OSR nmethod가 있을 수도 있어요.
C2 내부 — Sea of Nodes와 핵심 최적화
C2가 진짜 강력해지는 지점이 이 부분이에요. Sea of Nodes 위에서 어떤 최적화들이 일어나는지 굵직한 것만 살펴볼게요.
Sea of Nodes
전통적인 컴파일러 IR은 제어 흐름 그래프(CFG) 위에 명령어들이 순서대로 놓여 있어요. C2의 Sea of Nodes는 다르게 접근해요. 노드(연산)들이 서로 데이터 의존성과 제어 의존성으로 연결되어 있고, 노드의 절대적인 순서는 정해져 있지 않아요. "이 곱셈은 이 덧셈 결과를 받고, 이 분기 이전에 실행돼야 한다" 같은 부분 순서만 그래프에 남아 있어요.
이 표현이 좋은 이유는 노드를 자유롭게 옮길 수 있어서예요. 루프 안에서 매번 같은 값을 계산하고 있다면 그 노드를 루프 밖으로 들어내요(루프 불변식 코드 이동). 분기마다 같은 부분식이 있다면 공통 부분식 제거(CSE)로 묶어내요. 최종 코드 생성 단계에서야 노드들을 실제 명령어 순서로 배치해요.
메서드 인라이닝
C2의 가장 강력한 최적화는 인라이닝 이에요. 호출되는 메서드의 본문을 호출자 안으로 직접 펼쳐 넣어요. 인라이닝이 중요한 건 단순히 호출 비용을 줄이기 때문만이 아니에요. 인라이닝이 일어나면 더 큰 범위에서 추가 최적화가 가능해져요. 예를 들어 getter가 인라이닝되면 그 안의 필드 접근이 호출자의 다른 식과 같이 묶여 분석돼요.
C2는 인라이닝 결정을 위해 프로파일 을 사용해요. C1이 모아둔 호출 횟수, 어떤 구체 타입이 자주 호출됐는지, 어느 분기가 자주 타는지를 보고 인라이닝할지 말지를 결정해요.
가상 메서드의 경우엔 더 까다로워요. interface.process() 같은 호출은 어떤 구현이 올지 컴파일 시점에 모르기 때문에, 정직하게 가상 디스패치로 두면 인라이닝이 안 돼요. 그래서 C2는 타입 프로파일 을 활용해요. "지금까지 이 호출에 들어온 타입은 99% ImplA 였다"는 정보가 있으면, ImplA.process() 본문을 인라이닝하고 앞에 "정말 ImplA인지 확인"하는 가드를 추가해요. 가드가 깨지면 Deoptimization이 일어나요. 이 기법을 타입 가드 인라이닝 이라고 해요.
이스케이프 분석 (Escape Analysis)
지역 변수에만 쓰이고 메서드 밖으로 새지 않는 객체는 굳이 힙에 할당할 필요가 없어요. C2의 이스케이프 분석은 객체의 참조가 어디까지 새는지를 분석해서, "이 객체는 절대 메서드 밖으로 안 나가요"라는 결론이 나면 스칼라 치환 으로 객체를 분해해 각 필드를 별도 지역 변수처럼 다뤄요. 결과적으로 GC 부담이 줄어들고, 필드 접근이 그냥 레지스터 접근이 돼요.
new Point(x, y).distance(...) 같은 패턴이 대표적이에요. Point가 메서드 안에서만 살고 죽으면, C2는 실제로 객체를 만들지 않고 x, y를 그냥 지역 변수로 처리해버려요.
이스케이프 분석은 또 한 가지 부수 효과가 있어요. 객체가 새지 않으면 그 객체에 대한 동기화(synchronized)는 의미가 없어요. C2는 이 사실을 이용해 락 제거(lock elision) 를 적용해요. StringBuffer 같은 동기화 컬렉션이 지역 변수로만 쓰일 때, 그 모든 락 획득/해제 명령이 통째로 사라져요.
C2 파이프라인 한 눈에
C2 컴파일러는 안에서 여러 단계를 거쳐요. 굵직한 단계만 나열하면 이래요.
flowchart LR
Parse[Parse<br/>bytecode to Ideal IR] --> IdealOpt[Ideal-graph<br/>optimizations]
IdealOpt --> Loop[Loop opts<br/>unrolling, peeling]
Loop --> Macro[Macro expansion<br/>e.g., array alloc]
Macro --> Matcher[Matcher<br/>IR to machine IR]
Matcher --> RA[Register allocation<br/>Chaitin-Briggs]
RA --> Output[Output<br/>emit machine code]
- Parse: 바이트코드를 읽어 Sea of Nodes 형태의 Ideal 그래프를 만들어요. 이 단계에서 인라이닝 결정과 타입 가드 삽입이 같이 일어나요.
- Ideal-graph optimizations: 상수 폴딩, 죽은 코드 제거, CSE 같은 고수준 최적화가 노드 그래프 위에서 일어나요.
- Loop opts: 루프 언롤링, 루프 피링, 루프 불변식 코드 이동(LICM), 그리고 자동 벡터화 시도가 여기서 이뤄져요.
- Macro expansion:
new같은 매크로 노드를 실제 할당 코드(TLAB bump)로 풀어내요. 이 단계 이후엔 노드가 훨씬 저수준 명령에 가까워져요. - Matcher: 플랫폼별 명령어 패턴에 맞춰 노드를 머신 IR 명령으로 매칭해요. x86, AArch64 같은 아키텍처별 룰이 여기서 적용돼요.
- Register allocation: Chaitin-Briggs 방식의 그래프 컬러링으로 레지스터를 배정해요. 레지스터가 부족하면 스택으로 스필해요.
- Output: 최종 명령어를 코드 캐시 안의 nmethod 버퍼에 써내요. 이 시점에 안전점(safepoint) 폴링 코드와 deopt 진입 지점도 함께 박혀요.
이 모든 단계가 끝나야 비로소 C2 컴파일이 끝나요. 일반적인 메서드에서 C2 컴파일은 C1보다 5~10배 정도 시간이 더 걸려요. 그 비용을 감수할 만큼 결과 코드 성능이 좋아지기 때문에 핫한 메서드에 한해서만 적용하는 거예요.
Deoptimization — 가정이 깨지면
C2의 공격적인 최적화는 거의 모두 "프로파일에서 본 가정이 앞으로도 유지된다"는 전제 위에 서 있어요. 문제는 그 가정이 깨지는 순간이 올 수 있다는 거예요.
- 타입 가드 인라이닝을 했는데 새로운 구체 타입이 호출에 들어옴
- 한 번도 안 타본 분기가 갑자기 타짐 (uncommon trap)
- 인라이닝한 메서드의 클래스가 나중에 서브클래싱돼서 가정이 깨짐
이때 동작이 Deoptimization 이에요. C2가 만든 네이티브 코드를 폐기하고, 그 시점의 실행 상태를 인터프리터 프레임으로 재구성해서 인터프리터로 점프해요. 그 다음부터는 인터프리터 또는 다른 컴파일 단계의 코드가 실행돼요.
깨진 가정에 따라 해당 메서드의 카운터가 리셋되거나, 다음 번엔 그 분기/타입을 더 보수적으로 다루도록 표시되어 재컴파일돼요. 이 과정을 Uncommon Trap 이라고 부르고, C2 코드 안에는 가정마다 트랩 진입점이 심어져 있어요.
flowchart LR
Code[C2-compiled native code] -->|assumption holds| Continue[Continue running]
Code -->|assumption fails| Trap[Uncommon Trap]
Trap --> Reconstruct[Reconstruct interpreter frame<br/>from compiled frame]
Reconstruct --> Interp[Resume in interpreter]
Interp -->|hot again| Recompile[Recompile with weaker assumption]
Deoptimization은 비싸지만, 자주 일어나면 안 되는 안전장치예요. JIT가 "괜찮을 거야"라고 베팅한 가정이 가끔 틀려도 프로그램 의미는 정확히 유지되도록 만드는 핵심 메커니즘이에요.
Deopt의 두 가지 형태
Deoptimization은 어떤 시점에 일어나느냐에 따라 두 가지로 나뉘어요.
- Eager deoptimization: 외부 이벤트로 즉시 무효화돼야 하는 경우예요. 새 클래스가 로딩돼서 기존의 단일 구현 가정(CHA)이 깨지면, 그 가정을 깐 모든 nmethod를 즉시 무효 처리해요. 이미 실행 중인 프레임은 다음 안전점에서 인터프리터로 되돌아가요.
- Lazy deoptimization (uncommon trap): nmethod 실행 도중 깨진 가정에 도달했을 때 일어나요. 트랩 코드가 deopt 핸들러로 점프하고, 핸들러가 현재 프레임의 레지스터/스택 상태를 인터프리터 프레임으로 재구성해요.
재구성에 필요한 정보는 컴파일 시점에 함께 저장돼요. nmethod에는 "이 명령어 위치에서 deopt가 일어나면 이 지역 변수는 그 레지스터에, 이건 스택의 그 슬롯에 있다"는 매핑(Debug Info)이 빼곡히 박혀 있어요. 이게 있어야 컴파일된 코드가 자기 마음대로 변수를 레지스터로 옮겨도, 어느 시점에든 인터프리터 상태로 정확히 복원할 수 있어요.
Code Cache — JIT 코드가 사는 곳
JIT이 만든 네이티브 코드는 Code Cache 라는 별도의 메모리 영역에 저장돼요. 자바 힙과는 완전히 다른 영역이에요. 크기는 기본적으로 240MB 정도로 시작하고 -XX:ReservedCodeCacheSize로 조정할 수 있어요. 이 캐시가 가득 차면 컴파일러는 새 컴파일을 멈추고, 인터프리터로 실행되거나 사용 빈도가 낮은 nmethod부터 제거(sweep)해요.
자바 9부터는 JEP 197 Segmented Code Cache 가 적용되어, 캐시가 세 영역으로 나뉘어요.
- Non-method segment — JVM 자체의 코드(어댑터, 인터프리터 스텁 등). 절대 비워지지 않아요.
- Profiled segment — 짧은 수명을 가진 프로파일링된 코드(주로 C1 레벨 2·3 결과).
- Non-profiled segment — 긴 수명을 가진 완전 최적화된 코드(주로 C2 레벨 4 결과).
이렇게 나누면 짧은 수명 코드와 긴 수명 코드가 같은 영역에서 단편화를 만드는 일이 줄어요. 청소 작업의 효율도 올라가요.
JIT 동작 관찰하기
이 모든 게 실제로 어떻게 일어나는지 보고 싶으면, HotSpot이 제공하는 진단 플래그를 켜면 돼요.
-XX:+PrintCompilation
가장 가벼운 플래그예요. 컴파일된 메서드를 한 줄씩 찍어줘요.
java -XX:+PrintCompilation -jar app.jar
출력 한 줄을 보면 이런 식이에요.
123 1 3 java.lang.String::hashCode (55 bytes)
145 2 4 java.util.HashMap::get (23 bytes)
200 3 % 3 Main::sumPrimes @ 14 (78 bytes)
각 칼럼이 이런 의미예요.
- 첫 번째 숫자: VM 시작 후 경과 시간 (밀리초)
- 두 번째: 컴파일 ID
- 세 번째 플래그:
%는 OSR 컴파일,n은 native wrapper,s는 synchronized,!는 예외 핸들러 보유 등 - 네 번째 숫자: 컴파일 레벨 (3=C1 full profiling, 4=C2)
- 다섯 번째: 메서드 풀네임 (
@가 있으면 OSR 진입 BCI) - 마지막: 바이트코드 크기
%가 붙은 줄을 찾으면 OSR이 일어났다는 뜻이에요.
-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
C2가 어떤 호출을 인라이닝했고 어떤 건 거부했는지 트리 형태로 보여줘요. 어느 호출이 너무 큰지, 어느 부분이 megamorphic해서 인라이닝이 안 됐는지 디버깅할 때 유용해요.
-XX:+LogCompilation
훨씬 자세한 정보를 XML로 파일에 떨궈요. JITWatch 같은 도구로 시각화하면 컴파일 단계와 최적화 과정을 따라가볼 수 있어요.
JFR (Java Flight Recorder)
자바 11+에서는 JFR이 기본으로 들어 있어요. jdk.Compilation, jdk.Deoptimization 이벤트를 켜서 운영 환경에서도 컴파일/디옵트 활동을 가볍게 기록할 수 있어요. 시작 시 한꺼번에 켜는 게 부담스러우면 jcmd <pid> JFR.start로 운영 중에 잠깐만 켜고 끌 수도 있어요.
java -XX:StartFlightRecording=filename=app.jfr,settings=profile -jar app.jar
이 결과 파일을 JDK Mission Control로 열면 어떤 메서드가 자주 deopt되는지, 컴파일 시간이 얼마나 걸렸는지를 그래프로 볼 수 있어요. 운영 환경에서 "왜 처음 N분 동안만 성능이 떨어지지"를 분석할 때 가장 실용적인 도구예요.
컴파일 동작 직접 제어하기
대부분의 경우 JIT는 알아서 잘 동작하지만, 특정 메서드의 컴파일 동작을 강제로 바꾸고 싶을 때가 있어요. 마이크로 벤치마크에서 인라이닝을 막아 측정을 일관되게 하고 싶거나, 자꾸 deopt되는 메서드의 컴파일을 잠시 막아두고 싶거나 할 때예요.
이런 목적으로 HotSpot은 -XX:CompileCommand 플래그를 제공해요.
# 특정 메서드 인라이닝 강제
java -XX:CompileCommand="inline,com.example.Hot::method" -jar app.jar
# 특정 메서드 인라이닝 금지
java -XX:CompileCommand="dontinline,com.example.Other::method" -jar app.jar
# 특정 메서드 컴파일 자체를 금지 (인터프리터로만 실행)
java -XX:CompileCommand="exclude,com.example.Buggy::method" -jar app.jar
여러 지시를 쓸 땐 -XX:CompileCommandFile=hotspot_compile_commands로 파일을 지정할 수도 있어요. 운영 환경에서 막 쓸 도구는 아니지만, 컴파일 동작이 의심될 때 격리해서 확인하는 데 유용해요.
정리
JIT는 단일 컴포넌트가 아니라 인터프리터, C1, C2, OSR, Deoptimization이 얽힌 시스템이에요. 핵심을 요약하면 이래요.
- 인터프리터가 시작 시간을 짧게 유지하면서 프로파일을 수집해요.
- C1이 빨리 컴파일해서 인터프리터보다 나은 코드를 일찍 제공하고, 그 동안에도 프로파일이 계속 쌓여요.
- C2가 프로파일을 활용해 인라이닝, 이스케이프 분석 같은 공격적 최적화를 적용해 최종 성능을 끌어올려요.
- OSR이 긴 루프를 실행 도중에 갈아끼워 한 번도 다시 호출되지 않는 메서드도 최적화 혜택을 받게 해줘요.
- Deoptimization이 깨진 가정을 안전하게 처리해 정확성을 유지해요.
JIT를 직접 만질 일은 거의 없지만, 어떤 식으로 동작하는지 한 번 익혀두면 자바 애플리케이션의 성능을 디버깅할 때 "이 코드는 왜 느리지? 컴파일이 안 되고 있나? 인라이닝이 안 됐나? Deopt가 자주 일어나나?"를 자연스럽게 떠올릴 수 있어요. PrintCompilation 한 줄짜리 플래그로도 의외로 많은 게 보여요.
참고자료
- HotSpot Glossary of Terms — OpenJDK
- JEP 197: Segmented Code Cache
- How Tiered Compilation works in OpenJDK — Microsoft for Java Developers
- How the JIT compiler boosts Java performance in OpenJDK — Red Hat Developer
- What the JIT!? Anatomy of the OpenJDK HotSpot VM — InfoQ
- Introduction to HotSpot JVM C2 JIT Compiler — Emanuel's Blog
- Understanding the On Stack Replacement (OSR) optimisation in the HotSpot C1 compiler — JITWatch wiki
- The Sea of Nodes and the HotSpot JIT — Cliff Click
- HotSpot Escape Analysis and Scalar Replacement Status — OpenJDK
- Tiered Compilation in JVM — Baeldung

