JVM은 바이트코드를 어떻게 실행하는가
Interpreter의 즉시 실행부터 JIT 계층화 컴파일, OSR, Deoptimization, Intrinsics까지 — HotSpot이 성능을 끌어올리는 원리를 추적한다.
- 01 JVM 클래스로더는 어떻게 JVM을 지탱하는가
- 02 JVM은 메모리를 어떻게 나누는가
- 03 JVM 바이트코드는 어떻게 플랫폼을 초월하는가
- 04 JVM은 바이트코드를 어떻게 실행하는가
- 05 JVM GC는 어떻게 살아있는 객체를 판단하는가
- 06 Java Memory Model은 무엇을 추상화하는가
- 07 JVM 동기화는 어떻게 작동하는가
- 08 JVM 튜닝의 철학 — 덜 건드릴수록 더 잘 돌아간다
- 09 JVM은 객체를 어떻게 들여다보는가
java MyApp을 실행하는 순간, JVM은 바이트코드를 즉시 CPU에서 돌릴 수 없다는 사실을 안다. 그러나 사용자는 기다리지 않는다. HotSpot은 이 모순을 어떻게 해결하는가?
출발점: Interpreter가 먼저 뛴다
JVM이 켜지면 모든 코드는 Template Interpreter로 시작한다. 이 Interpreter는 흔히 상상하는 “한 줄씩 파싱” 방식이 아니다. JVM 시작 시 각 바이트코드 opcode마다 대응하는 기계어 템플릿을 미리 생성해두고, 실행 시에는 opcode를 읽어 Dispatch Table에서 주소를 조회한 뒤 직접 점프한다.
opcode = *pc;
addr = dispatchTable[opcode]; // O(1) 조회
goto addr; // 직접 점프, switch-case 없음
Switch-case보다 2~3배 빠른 이유는 조건 분기가 없기 때문이다. CPU는 dispatchTable[opcode]라는 예측 가능한 패턴만 따라간다. 그럼에도 Interpreter는 명령어당 약 50ns — JIT 대비 25배 느리다. 이것이 출발선일 뿐인 이유다.
계층 컴파일: C1이 먼저, C2가 마무리한다
HotSpot은 메서드 호출 횟수(Invocation Counter)와 루프 반복 횟수(Backedge Counter)를 추적한다. 임계값을 넘으면 백그라운드 컴파일러 스레드가 해당 메서드를 큐에 넣고 컴파일한다.
컴파일은 한 번에 끝나지 않는다. Tiered Compilation은 5단계로 나뉜다.
Level 0: Interpreter (50ns/명령어)
Level 1: C1 Simple — 빠른 컴파일, 기본 최적화
Level 2: C1 Limited Profiling
Level 3: C1 Full Profiling — C2를 위한 데이터 수집
Level 4: C2 Full Optimization (2ns/명령어)
일반 경로는 0 → 3 → 4다. C1(Level 3)이 프로파일링 데이터를 쌓는 동안 사용자는 이미 5~10배 빨라진 응답을 받는다. C2(Level 4)가 완료되면 최대 25배 향상이 이루어진다. Tiered를 끄고 C2만 쓰면 Level 3의 중간 가속 없이 Interpreter가 훨씬 오래 돈다 — 대부분의 경우 Tiered ON이 유리하다.
메모리 제약 환경에서는 -XX:TieredStopAtLevel=1로 C2를 비활성화할 수 있다. C1만 사용하면 컴파일러 메모리를 아끼고 빠른 Warm-up을 유지하지만, 최종 성능은 C2 대비 약 50% 수준에 머문다. 배치 작업이나 512MB 이하 컨테이너에 적합하다.
OSR: 실행 중인 루프를 바꿔치기한다
문제가 하나 있다. 메서드가 딱 한 번만 호출되는데 내부 루프가 100만 번 돈다면? Invocation Counter는 1에 머물러 JIT가 트리거되지 않는다.
**On-Stack Replacement(OSR)**는 이 문제를 해결한다. Backedge Counter가 루프 반복마다 올라가다가 임계값(기본 약 14,000)을 넘으면, 백그라운드에서 루프 본문만 JIT 컴파일하고 실행 중인 Stack Frame 자체를 JIT 버전으로 교체한다. i=14,000이던 지역 변수 값까지 그대로 복사해 루프 도중부터 JIT 코드가 이어받는다.
-XX:+PrintCompilation 출력에서 % 표시가 보이면 OSR이 발생한 것이다.
120 1 % 3 OSRDemo::longLoop @ 15 (60 bytes)
↑ OSR, @ 15 = backedge 바이트코드 위치
최적화의 뿌리: Inlining과 Escape Analysis
C2가 하는 최적화 중 가장 중요한 것은 Inlining이다. 메서드 호출을 본문으로 대체하면, 호출 오버헤드가 사라질 뿐 아니라 Constant Folding 같은 후속 최적화가 연쇄적으로 일어난다.
int result = sum(10, 20); // Inlining →
int result = 10 + 20; // Constant Folding →
int result = 30; // 기계어: mov eax, 30
Escape Analysis는 객체가 메서드 밖으로 탈출하지 않으면 Heap 할당 자체를 제거한다(Scalar Replacement). new Point(1, 2)가 int x = 1; int y = 2;로 분해되면 GC 압박도 사라진다. Loop Unrolling과 SIMD Vectorization은 루프를 펼치고 한 명령어로 여러 데이터를 동시에 처리한다.
Deoptimization: 가정이 틀렸을 때
C2는 프로파일링 데이터를 믿고 Speculative Optimization을 한다. “지금까지 shape는 항상 Circle이었다 → Virtual Call을 제거하고 Circle.area()를 직접 인라인하자.” 이 가정은 Square가 등장하는 순간 무너진다.
그 순간 Deoptimization이 발생한다. JIT Frame을 Interpreter Frame으로 복구하고, 기존 JIT 코드는 “made not entrant”(새 진입 금지)로 표시된다. 실행 중인 스레드는 완료까지 허용되고, 이후 재컴파일이 Circle + Square 모두를 고려해 다시 최적화한다.
다형성이 넓을수록 이 사이클이 자주 일어난다. Monomorphic(1개 타입)은 최적화가 유지되고, Megamorphic(4개 이상)은 JIT가 최적화를 포기하고 Virtual Call을 남겨둔다.
Intrinsics: CPU 명령어로 바꿔버린다
일부 메서드는 JIT 최적화의 한계를 넘는다. Math.sqrt()를 아무리 잘 컴파일해도 CPU의 sqrtsd 명령어 한 줄보다 빠를 수 없다. JVM Intrinsics는 특정 메서드를 바이트코드 단계에서 인식해 CPU 명령어로 직접 대체한다.
System.arraycopy는 rep movsq로, String.equals는 AVX2 SIMD 비교로, Integer.bitCount는 popcnt로 대체된다. 효과는 5~20배 성능 향상이다. Intrinsics는 JIT 컴파일 시에만 작동하므로 -Xint 모드에서는 일반 Java 구현이 호출된다.
정리
- JVM은 Interpreter로 즉시 실행을 시작하고, Tiered Compilation으로 점진적으로 최적화한다.
- 긴 루프는 OSR이 도중에 JIT 버전으로 교체한다 — Invocation Counter가 낮아도 문제없다.
- Inlining은 모든 고급 최적화의 게이트웨이다. 메서드를 작게 유지하면 JIT가 더 많이 볼 수 있다.
- Deoptimization은 버그가 아니라 설계다. 타입을 단순하게 유지할수록 Speculative Optimization이 오래 살아남는다.
Math.sqrt,System.arraycopy같은 표준 API는 직접 구현보다 항상 빠르다 — Intrinsics가 있기 때문이다.