Skip to content
Published on

JIT 컴파일 완전 가이드 2025: V8, JVM HotSpot, .NET, Tiered Compilation, 성능 분석

Authors

TL;DR

  • JIT = 인터프리터의 속도 + 컴파일러의 유연성
  • Tiered Compilation: 빠른 시작 + 점진적 최적화
  • V8 (Chrome, Node.js): Ignition (인터프리터) → Maglev → TurboFan
  • JVM HotSpot: 인터프리터 → C1 → C2 (또는 Graal)
  • .NET: ReadyToRun → Tier 0 → Tier 1
  • Inline Caching, Speculation, Deoptimization: JIT의 핵심 기법

1. 컴파일 모델

1.1 3가지 접근

1. 인터프리터:

  • 코드를 직접 실행
  • 빠른 시작
  • 느린 실행

2. AOT (Ahead-Of-Time):

  • 미리 기계어로 컴파일
  • 빠른 실행
  • 느린 시작 (컴파일 시간)
  • 동적 최적화 X

3. JIT (Just-In-Time):

  • 런타임에 컴파일
  • 둘의 장점 결합

1.2 비교

인터프리터AOTJIT
시작빠름느림빠름
실행느림빠름빠름 (warm-up 후)
최적화없음정적동적
메모리적음적음많음
Python (CPython)C, RustJava, JS, C#

1.3 JIT의 장점

1. 프로파일 기반 최적화:

  • 어떤 코드가 자주 실행되는지 측정
  • "hot" 코드만 최적화

2. 가정 기반 최적화:

  • "이 변수는 항상 정수다"라고 가정
  • 틀리면 deoptimize

3. 인라이닝:

  • 함수 호출을 본문으로 대체
  • 함수 호출 오버헤드 제거

4. 동적 정보 활용:

  • 런타임 타입 정보
  • 분기 예측 데이터

2. JIT가 작동하는 방식

2.1 기본 흐름

[Source Code]
Parse
[AST]
Compile to Bytecode
[Bytecode]
Execute (Interpreter)
[Profile (어떤 함수가 hot?)]
JIT Compile (hot 함수만)
[Native Code]
Execute (Fast)
[Deoptimize if assumption wrong]
[Bytecode (다시)]

2.2 Hot Spot 감지

Method-level:

  • 함수 호출 횟수 카운트
  • 임계값 (예: 10,000) 넘으면 컴파일

Loop-level:

  • 루프 반복 횟수 카운트
  • "OSR (On-Stack Replacement)"

2.3 OSR — On-Stack Replacement

void process() {
    for (int i = 0; i < 1_000_000_000; i++) {
        // 매우 hot
    }
}

문제: 함수가 한 번만 호출되어도 루프가 hot.

해결: OSR — 실행 중인 인터프리터를 컴파일된 코드로 교체.

[Interpreter at iteration 100,000]
JIT compile
[Native code]
OSR: 인터프리터 → 네이티브
[Iteration 100,001 in native code]

2.4 컴파일 임계값

시스템Method 임계값OSR 임계값
HotSpot C11,50013,500
HotSpot C210,00014,000
V8 Maglev~1,000
V8 TurboFan~10,000

너무 낮음: 컴파일 오버헤드. 너무 높음: 인터프리터에서 너무 오래.


3. V8 (Chrome, Node.js)

3.1 V8의 컴파일 파이프라인

[JavaScript]
Parse
[AST]
Bytecode
[Ignition (Interpreter)]
Profile
[Sparkplug (baseline JIT)]
Hot
[Maglev (mid-tier)]
Hotter
[TurboFan (top-tier optimizer)]

3.2 Ignition — Bytecode Interpreter

2016 도입.

역할:

  • AST를 bytecode로 컴파일
  • 빠른 시작 (메모리 효율)
  • 인라인 캐싱

왜 인터프리터?:

  • 모바일에서 메모리 절약
  • 시작 시간 단축

3.3 Sparkplug — Baseline JIT

2021 도입.

역할:

  • Bytecode를 즉시 기계어로 (최적화 X)
  • Ignition보다 5-15% 빠름
  • 매우 빠른 컴파일 (인터프리터 직접 변환)

3.4 Maglev — Mid-tier Optimizer

2023 도입.

역할:

  • TurboFan 전 단계
  • Sparkplug보다 빠름
  • TurboFan보다 컴파일 빠름
  • Tier 사이 균형

3.5 TurboFan — Top-tier Optimizer

2017 도입 (Crankshaft 대체).

역할:

  • 가장 최적화된 코드 생성
  • Sea-of-Nodes IR
  • 인라이닝, 분기 예측, escape analysis
  • SIMD 활용

컴파일 비용: 매우 큼 (~ms 단위), hot 함수만.

3.6 Inline Caching

JavaScript의 동적 디스패치 최적화.

function getX(obj) {
    return obj.x  // obj의 형태를 모름
}

// Hidden Class 학습
getX({x: 1, y: 2})  // Class A
getX({x: 3, y: 4})  // Class A (재사용)
getX({x: 5, y: 6, z: 7})  // Class B (다른 형태)

V8이 학습:

  • 첫 호출: monomorphic (Class A만)
  • 두 번째: 여전히 monomorphic
  • 세 번째: polymorphic (Class A, B)
  • 5+: megamorphic (느림)

Monomorphic이 가장 빠름. 같은 형태 객체를 일관되게 사용.

3.7 Hidden Classes

JavaScript는 동적 타입이지만, V8 내부에서는 숨은 클래스를 사용:

const obj1 = {}        // Class C0
obj1.x = 1             // Class C1 (x 필드 추가)
obj1.y = 2             // Class C2

const obj2 = {}        // Class C0
obj2.x = 1             // Class C1 (재사용!)
obj2.y = 2             // Class C2 (재사용!)

같은 순서로 필드 추가 → 같은 hidden class → 빠름.

다른 순서면:

const obj3 = {}
obj3.y = 1            // Class D1 (다른 경로)
obj3.x = 2            // Class D2
// obj1, obj2와 다른 클래스 → 느림

: 객체 생성 시 모든 필드를 한 번에:

function makePoint(x, y) {
    return { x, y }  // 모든 필드 같은 순서
}

4. JVM HotSpot

4.1 컴파일 파이프라인

[Java source]
    ↓ javac
[Bytecode (.class)]
JVM
[Interpreter]
Hot (1,500 호출)
[C1 (Client Compiler)]
Very Hot (10,000 호출)
[C2 (Server Compiler)]
    ↓ 또는
[Graal]

4.2 C1 (Client Compiler)

역할:

  • 빠른 컴파일
  • 보통의 최적화
  • 빠른 시작에 적합

사용: 짧은 프로그램, 데스크톱 앱.

4.3 C2 (Server Compiler)

역할:

  • 매우 공격적 최적화
  • 느린 컴파일
  • 긴 실행 프로그램에 적합

최적화:

  • 인라이닝
  • 루프 풀기 (loop unrolling)
  • Escape analysis
  • Branch prediction
  • Dead code elimination
  • Constant folding

4.4 Tiered Compilation (JDK 8+)

기본: 인터프리터 → C1 → C2.

장점:

  • 빠른 시작 (인터프리터 + C1)
  • 최종적으로 최고 성능 (C2)
# 활성화 (기본)
java -XX:+TieredCompilation MyApp

# C2만 (고성능)
java -XX:-TieredCompilation -server MyApp

4.5 Graal — 새로운 컴파일러

Oracle이 만든 차세대 JIT. C2 대체.

장점:

  • Java로 작성 (C2는 C++)
  • 더 나은 최적화 (특정 워크로드)
  • 다른 언어 지원 (GraalVM)
java -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler MyApp

4.6 Escape Analysis

public Point getPoint() {
    Point p = new Point(1, 2);  // 객체 생성
    return p.x + p.y;            // 객체가 함수 밖으로 안 나감
}

C2가 분석:

  • Point 객체가 함수 밖으로 안 나감
  • Heap에 할당 X, 스택에 할당 (매우 빠름)
  • 또는 scalar replacement (필드만 사용)

효과: GC 부담 감소, 캐시 친화적.

4.7 Inlining

int square(int x) { return x * x; }
int sumSquares(int n) {
    int sum = 0;
    for (int i = 0; i < n; i++)
        sum += square(i);
    return sum;
}

C2가 인라이닝:

int sumSquares(int n) {
    int sum = 0;
    for (int i = 0; i < n; i++)
        sum += i * i;  // square 인라이닝
    return sum;
}

효과:

  • 함수 호출 오버헤드 제거
  • 추가 최적화 가능 (loop fusion 등)

한계: 너무 큰 함수는 인라이닝 X (코드 크기 폭증 방지).


5. .NET CLR

5.1 컴파일 파이프라인

[C# source]
Roslyn
[CIL (Common Intermediate Language)]
CLR
[ReadyToRun (옵션, AOT)]
[Tier 0 (빠른 컴파일)]
Hot
[Tier 1 (최적화)]

5.2 ReadyToRun (R2R)

.NET Core 2.1+ AOT 컴파일.

역할:

  • 시작 시간 단축
  • IL을 미리 네이티브로
  • 런타임에 추가 JIT 가능
dotnet publish -c Release -r linux-x64 -p:PublishReadyToRun=true

효과:

  • 시작 시간 50%+ 단축
  • 메모리 절감

5.3 Tiered Compilation

.NET Core 3.0+ 기본.

Tier 0: 빠른 컴파일, 최적화 적음. Tier 1: 느린 컴파일, 공격적 최적화.

[MethodImpl(MethodImplOptions.AggressiveInlining)]
int Square(int x) => x * x;

5.4 RyuJIT

.NET Core의 기본 JIT.

개선 (.NET 8+):

  • ARM64 지원
  • 동적 PGO (Profile-Guided Optimization)
  • 더 빠른 컴파일

5.5 NativeAOT

.NET 7+: 완전 AOT.

dotnet publish -p:PublishAot=true

장점:

  • 매우 빠른 시작
  • 작은 바이너리
  • 컨테이너에 적합
  • JIT 없음 → 메모리 적음

단점:

  • 런타임 동적 최적화 X
  • Reflection 제한
  • 일부 라이브러리 호환 X

6. PGO — Profile-Guided Optimization

6.1 정적 PGO

  1. 빌드 1: 계측 코드 추가
  2. 실행: 프로파일 수집
  3. 빌드 2: 프로파일 사용해 최적화
# Clang
clang -fprofile-generate -o app app.c
./app  # profile.profdata 생성
clang -fprofile-use=profile.profdata -O2 -o app app.c

효과:

  • 5-30% 성능 향상
  • 분기 예측 개선

6.2 동적 PGO (.NET, JVM)

JIT가 런타임에 자동으로:

  • 어떤 분기가 자주 taken인가?
  • 어떤 함수가 hot인가?
  • 어떤 객체 형태가 자주 보이나?

→ 다음 컴파일에 활용.

장점: 사용자 패턴에 자동 적응.


7. Tracing JIT

7.1 Method JIT vs Tracing JIT

Method JIT (HotSpot, V8):

  • 함수 단위로 컴파일
  • Java, JavaScript

Tracing JIT (PyPy, LuaJIT):

  • 실행 경로(trace) 단위로 컴파일
  • "이 루프의 이 분기"
  • 매우 공격적 최적화

7.2 PyPy

Python 인터프리터 + tracing JIT.

장점:

  • CPython보다 5-10배 빠름
  • 100% 호환

단점:

  • 시작 느림
  • 메모리 많이 사용
  • C 확장 호환 문제

7.3 LuaJIT

Lua의 tracing JIT.

Mike Pall의 작품. 종종 C와 비슷한 성능.

사용:

  • WoW (Lua scripts)
  • nginx (Lua module)
  • OpenResty

7.4 Tracing의 어려움

  • Trace 폭증: 가능한 경로가 너무 많으면
  • Trace 무효화: 가정이 깨지면
  • 메모리: 많은 trace 저장

→ Method JIT가 더 일반적.


8. Speculation과 Deoptimization

8.1 Speculative Optimization

JIT는 가정을 합니다:

예시: V8

function add(a, b) {
    return a + b
}

// 1000번 호출, 항상 정수
add(1, 2)
add(3, 4)
// V8: "a, b는 항상 정수다" → 정수 덧셈으로 최적화

컴파일된 코드:

; 정수 덧셈 (매우 빠름)
add eax, ebx

가정 검증: 매번 타입 체크.

8.2 가정이 깨지면?

add("hello", "world")  // 문자열!

문제: 정수 덧셈 코드는 문자열 처리 못 함.

해결: Deoptimization.

[Native Code (정수 덧셈)]
Type check 실패
[Deopt to Bytecode]
Re-execute in interpreter
[String concatenation]

비용: deopt는 비쌈. 자주 발생하면 JIT가 포기.

8.3 Deopt 방지

일관된 타입 사용:

// 좋음 - 항상 정수
function add(a, b) {
    return a + b
}
add(1, 2)
add(3, 4)

// 나쁨 - 타입 혼용
function process(x) {
    if (typeof x === 'string') return x.toUpperCase()
    return x * 2
}

8.4 Megamorphic 함수

function getName(obj) {
    return obj.name
}

getName({name: "Alice"})
getName({name: "Bob", age: 30})
getName({name: "Charlie", age: 25, country: "KR"})
// 5+ 다른 hidden class → megamorphic

Megamorphic = 느림. V8의 inline cache가 polymorphic까지만 효율적.


9. 디버깅과 분석

9.1 V8

Node.js:

node --trace-opt --trace-deopt my-app.js

출력:

[marking 0x... for optimized recompilation]
[deoptimizing 0x... because of wrong map]

Chrome DevTools:

  • Performance 탭
  • "Bottom-Up" 분석
  • Optimized vs not optimized

9.2 JVM

JIT 컴파일 로그:

java -XX:+PrintCompilation MyApp

JITWatch: GUI 도구.

Async-profiler: 샘플링 프로파일러.

./profiler.sh -d 30 -f profile.html <pid>

9.3 .NET

dotnet-trace:

dotnet-trace collect --process-id <pid>

PerfView: Windows 도구.

BenchmarkDotNet: 마이크로벤치마크.

[Benchmark]
public int Sum() {
    int sum = 0;
    for (int i = 0; i < 1000; i++) sum += i;
    return sum;
}

10. 실전 — 빠른 코드 작성

10.1 V8 (JavaScript)

Do:

  • ✅ 일관된 객체 형태 (모든 필드 같은 순서)
  • ✅ 일관된 타입 (정수만, 문자열만)
  • ✅ Hot 함수를 작게 (인라이닝 가능)
  • ✅ Monomorphic 호출 사이트

Don't:

  • ❌ 객체에 동적으로 필드 추가
  • delete 키워드 (hidden class 무효화)
  • ❌ try/catch in hot paths (V8은 try 안의 코드 최적화 X — 옛 버전)
  • arguments 객체 (rest parameters 사용)

10.2 JVM

Do:

  • ✅ 작은 메서드 (인라이닝)
  • ✅ Final 필드/클래스 (devirtualization)
  • ✅ Primitive 타입 우선
  • ✅ Escape analysis 활용 (지역 객체)

Don't:

  • ❌ 거대 메서드 (인라이닝 안 됨)
  • ❌ 과도한 reflection (deoptimize)
  • ❌ JIT warm-up 무시 (벤치마크에 중요)

10.3 .NET

Do:

  • [MethodImpl(MethodImplOptions.AggressiveInlining)]
  • Span<T> 사용 (heap 회피)
  • ✅ struct 활용 (escape analysis)
  • ✅ NativeAOT for short-lived

Don't:

  • ❌ Boxing (object로 변환)
  • ❌ LINQ in hot paths
  • ❌ Reflection in hot paths

10.4 일반 원칙

  1. 측정 먼저 — 추측하지 말기
  2. 핫 루프에 집중 — Pareto 원칙
  3. JIT warm-up 고려 — 첫 1초는 느림
  4. 벤치마크는 microbench가 아닌 실제 부하

퀴즈

1. JIT가 인터프리터와 AOT의 장점을 어떻게 결합하나요?

: 인터프리터의 빠른 시작 + 컴파일러의 빠른 실행. 처음에는 인터프리터로 빠르게 시작 → 자주 실행되는 "hot" 코드를 프로파일링 → JIT가 그 부분만 기계어로 컴파일. AOT보다 우월한 점: (1) 런타임 정보 활용 (실제 사용 패턴), (2) Speculative optimization (가정 기반), (3) Deoptimization (가정 틀리면 인터프리터로 돌아감). 단점: 메모리 사용량 증가, warm-up 필요.

2. V8의 Hidden Class가 왜 중요한가요?

: JavaScript는 동적 타입이지만 V8 내부는 숨은 클래스로 객체 형태를 추적합니다. 같은 순서로 필드 추가 → 같은 hidden class → V8이 효율적으로 코드 생성. 다른 순서 → 다른 hidden class → 느림. : 객체 생성 시 모든 필드를 한 번에 ({ x, y }), delete 사용 X, 일관된 형태 유지. Monomorphic > Polymorphic > Megamorphic — 같은 형태로 호출하면 100배+ 빠를 수 있음.

3. JVM HotSpot의 C1과 C2의 차이는?

: C1 (Client Compiler): 빠른 컴파일, 보통의 최적화. 짧은 프로그램이나 데스크톱 앱에 적합. C2 (Server Compiler): 느린 컴파일, 공격적 최적화 (인라이닝, escape analysis, loop unrolling). 긴 실행 프로그램에 적합. Tiered Compilation (JDK 8+ 기본): 인터프리터 → C1 → C2 — 빠른 시작 + 최종 고성능. Graal은 C2를 대체할 차세대 컴파일러 (Java로 작성).

4. Speculative Optimization과 Deoptimization은?

: Speculation: JIT가 가정을 함 (예: "a, b는 항상 정수"). 가정 기반으로 매우 빠른 코드 생성. Deoptimization: 가정이 깨지면 인터프리터로 돌아감. 예: add(1, 2) 1000번 호출 → 정수 덧셈으로 최적화 → add("hello", "world") 호출 → 가정 깨짐 → deopt → 인터프리터에서 문자열 처리. deopt는 비쌈. 자주 발생하면 JIT가 그 함수를 포기. 일관된 타입 사용이 핵심.

5. NativeAOT는 언제 사용하나요?

: NativeAOT 적합: (1) 컨테이너/Lambda — 빠른 시작 시간 필수, (2) CLI 도구 — 짧은 실행, (3) 임베디드 — 작은 바이너리, (4) JIT 메모리 부담 회피. 장점: 매우 빠른 시작, 작은 바이너리, JIT 없어 메모리 적음. 단점: 런타임 동적 최적화 없음 (긴 실행 프로그램은 JIT가 더 빠를 수 있음), Reflection 제한, 일부 라이브러리 호환 안 됨. .NET 7+, GraalVM Native Image가 대표적. 장기 실행 서버는 JIT, 단기 실행은 AOT.


참고 자료