JIT
contents
JIT(Just-In-Time) 컴파일러는 자바를 빠르게 만드는 "핵심 비결"입니다. 자바가 태생적으로는 인터프리터 언어임에도 불구하고, 성능 면에서 C++과 경쟁할 수 있는 이유가 바로 이 기술 덕분입니다.
다음은 JIT가 무엇인지, JVM 내부에서 어떻게 작동하는지, 그리고 어떤 구체적인 최적화를 수행하는지에 대한 상세 분석입니다.
1. 핵심 개념: "통역사 vs 원어민"
JIT를 이해하기 위해서는 자바가 코드를 실행하는 방식을 먼저 이해해야 합니다.
- 인터프리터 (통역사): 자바 애플리케이션을 시작하면, JVM은 바이트코드(
.class파일)를 한 줄씩 읽어서 CPU가 이해할 수 있는 기계어로 즉시 변환(통역)하여 실행합니다.- 장점: 시작 속도가 빠릅니다.
- 단점: 매우 느립니다. 똑같은 줄을 100만 번 실행하는 루프(Loop)라도 매번 다시 통역해야 하기 때문입니다.
- JIT 컴파일러 (암기왕): JIT 컴파일러는 애플리케이션 실행 과정을 지켜봅니다. 그러다 특정 코드 블록(메서드나 루프 등)이 빈번하게 사용된다는 것을 감지하면, 그 부분을 통째로 네이티브 기계어(Native Machine Code) 로 컴파일하여 메모리(Code Cache)에 저장해 둡니다.
- 결과: 다음에 해당 메서드가 호출되면, JVM은 인터프리터를 건너뛰고 미리 컴파일된 기계어를 직접 실행합니다 (원어민처럼 빠름).
2. JIT 워크플로우: 차가운 상태에서 뜨거운 상태로
JVM(특히 HotSpot JVM)은 "적응형 최적화(Adaptive Optimization)" 라는 프로세스를 사용합니다. 모든 코드를 다 컴파일하는 것이 아니라, 중요한 부분만 골라서 합니다.
- 인터프리터 단계 (Cold): 앱이 실행됩니다. JVM은 바이트코드를 인터프리팅합니다. 이 단계에서 성능은 상대적으로 느립니다.
- 프로파일링 (Warm-up): 코드가 실행되는 동안, JVM은 모든 메서드와 루프에 대해 카운터(Counter) 를 유지합니다. 다음을 측정합니다:
- 메서드가 몇 번 호출되었는가?
- 루프가 몇 번 반복되었는가?
- 감지 ("Hot Spot"): 카운터가 특정 임계치(예: 메서드 10,000번 호출)를 넘으면, JVM은 이 코드를 "핫스팟(Hot Spot)" 으로 식별합니다.
- 컴파일: JIT 컴파일러가 별도의 백그라운드 스레드에서 실행됩니다. 핫스팟이 된 바이트코드를 가져와서 공격적으로 최적화하고, 네이티브 기계어로 컴파일합니다.
- 네이티브 실행: JVM은 해당 메서드의 진입점을 새로운 네이티브 코드로 변경합니다. 이후의 호출은 최고의 속도로 실행됩니다.
3. 계층적 컴파일 (Tiered Compilation): C1과 C2
최신 JVM은 JIT 컴파일러를 하나만 가지고 있지 않고, 두 개를 가지고 있습니다. 이를 계층적 컴파일(Tiered Compilation) 이라 합니다.
- C1 컴파일러 (클라이언트 컴파일러):
- 목표: 빠른 컴파일 속도.
- 동작: 코드를 빠르게 최적화하지만, 깊고 복잡한 최적화는 하지 않습니다. 애플리케이션의 "초기 실행 속도"를 높이기 위해 코드를 빠르게 네이티브 수준으로 만듭니다.
- C2 컴파일러 (서버 컴파일러):
- 목표: 최대 성능 (Throughput).
- 동작: 컴파일하는 데 시간이 오래 걸리지만, 프로파일링 데이터를 기반으로 매우 공격적인 최적화를 수행합니다. 장기 실행되는 서버 애플리케이션을 위해 가장 빠른 코드를 만들어냅니다.
생명주기:
코드는 인터프리터에서 시작 $\rightarrow$ C1(빠른 조치)으로 승격 $\rightarrow$ C2(고도로 최적화)로 최종 승격됩니다.
4. JIT의 마법: 최적화 기법들
JIT가 정적 컴파일러(C++의 gcc 같은)보다 특정 상황에서 더 빠를 수 있는 이유가 여기에 있습니다. JIT는 코드가 실행되는 도중 에 컴파일하기 때문에, 코드가 실제로 어떻게 사용되는지 정확히 알고 있습니다.
A. 메서드 인라인화 (Method Inlining - 가장 중요)
JIT는 메서드 호출(Call) 부분을 메서드의 실제 본문(Body) 코드로 교체해 버립니다.
-
인라인 이전:
// 이 함수를 호출하려면 메모리 주소를 이동하고 스택 프레임을 생성하는 비용이 듭니다. int result = calculate(a, b); -
인라인 이후:
// JIT가 로직을 여기에 바로 붙여넣습니다. 점프도, 오버헤드도 없습니다. int result = a + b;중요한 이유: 메서드 호출 비용을 없애고, 추가적인 최적화를 가능하게 합니다.
B. 죽은 코드 제거 (Dead Code Elimination)
만약 어떤 코드가 계산한 값을 아무도 사용하지 않는다는 것을 JIT가 깨닫는다면, JIT는 네이티브 코드에서 그 부분을 그냥 삭제해 버립니다.
C. 루프 펼치기 (Loop Unrolling)
루프 조건 검사(i < 1000) 비용을 줄이기 위해, JIT는 루프를 "펼칩니다".
-
원본:
for (int i = 0; i < 4; i++) { doSomething(); } -
펼쳐진 코드:
doSomething(); doSomething(); doSomething(); doSomething();중요한 이유: CPU의 "분기 예측(Branch Prediction)" 실패 비용과 조건 검사 비용을 제거합니다.
D. 탈출 분석 (Escape Analysis)
JIT는 객체의 범위를 분석합니다. 만약 객체가 메서드 안에서 생성되었는데 절대 밖으로 탈출하지 않는다(static 필드에 할당되거나 반환되지 않음)는 것을 알게 되면, 두 가지 마법을 부립니다:
- 스택 할당 (Stack Allocation): 객체를 힙(Heap)이 아닌 스택(Stack)에 할당합니다. 메서드가 끝나면 메모리가 즉시 사라지므로 가비지 컬렉션(GC)이 필요 없습니다!
- 스칼라 교체 (Scalar Replacement): 객체를 분해하여 기본 변수(primitive)들로 쪼갠 뒤 CPU 레지스터를 직접 사용합니다.
5. 최적화 해제 (Deoptimization): 안전망
만약 JIT가 가정한 내용이 나중에 틀렸다는 것이 밝혀지면 어떻게 될까요?
- 상황: JIT는
Animal인터페이스의 구현체가Dog하나뿐이라고 가정하고, 메서드 호출을Dog.bark()로 하드코딩해버렸습니다. - 사건: 갑자기 런타임 중에
Animal을 구현한Cat클래스가 로딩되었습니다. - 대응: JIT는 최적화 해제(Deoptimization) 를 발동합니다. 컴파일된 네이티브 코드를 즉시 버리고, 다시 인터프리터 모드(레벨 0)로 돌아갑니다. 그리고 새로운 정보를 바탕으로 나중에 다시 컴파일을 시도합니다.
6. 요약: 개발자가 알아야 할 점
| 개념 | 자바 앱에 미치는 영향 |
|---|---|
| 워밍업 (Warm-up) | 자바 앱은 처음 켜졌을 때(Cold) 느립니다. 몇 분 지나면 JIT가 "핫스팟"을 최적화했기 때문에 빨라집니다. |
| 코드 캐시 (Code Cache) | JIT는 네이티브 코드를 '코드 캐시'라는 특별한 메모리 영역에 저장합니다. 이 공간이 꽉 차면 JIT가 멈추고 성능이 저하될 수 있습니다. |
| -XX 플래그 | 대규모 시스템 성능 튜닝 시, JIT 관련 설정을 조절할 수 있습니다 (예: -XX:ReservedCodeCacheSize). |
결론:
JIT는 동적이고 학습하는 컴파일러입니다. 실행 초기에 컴파일을 위해 CPU를 조금 더 쓰지만, 그 대가로 장기적으로는 엄청난 성능 향상을 가져다줍니다.
references