자바가 인터프리터 언어라서 느리다는 말을 아직도 들을 때가 있는데, 실제로는 C/C++에 근접한 성능을 내기도 합니다. 어떻게 가능할까요?

JIT(Just-In-Time) 컴파일러 는 바이트코드를 실행하면서 자주 사용되는 코드를 네이티브 기계어로 변환하는 JVM 내부 컴포넌트입니다. 핵심은 런타임 프로파일링 정보를 활용 한다는 점인데, 실제 실행 패턴을 보고 최적화하기 때문에 AOT 컴파일러보다 오히려 더 공격적인 최적화가 가능합니다.

Tiered Compilation — C1과 C2의 협업

Java 8부터 기본으로 활성화된 Tiered Compilation은 두 JIT 컴파일러를 단계적으로 사용합니다.

5단계 레벨

레벨설명
0인터프리터 (프로파일링 데이터 수집)
1C1 — 최소 최적화, 프로파일링 없음
2C1 — 제한적 프로파일링
3C1 — 전체 프로파일링 (가장 흔한 중간 단계)
4C2 — 최대 최적화 (핫 메서드의 최종 형태)
PLAINTEXT
인터프리터 (Level 0)
    ↓ 실행 횟수 임계값 도달
C1 컴파일 (Level 3, 프로파일링 수집)
    ↓ 핫 메서드로 판정
C2 컴파일 (Level 4, 공격적 최적화)

이 단계적 접근이 필요한 이유는 C2의 공격적 최적화에 시간이 걸리기 때문입니다. 애플리케이션 시작 직후에는 C1이 빠르게 네이티브 코드를 생성하고, 충분한 프로파일링 데이터가 쌓이면 C2가 최적 코드를 만듭니다.

JIT 컴파일 로그 확인

다음 옵션으로 JIT의 동작을 직접 관찰할 수 있습니다.

BASH
java -XX:+PrintCompilation -jar myapp.jar

출력 예시와 각 컬럼의 의미입니다.

PLAINTEXT
  123   3       3   java.lang.String::hashCode (55 bytes)
  145   4       4   java.lang.String::hashCode (55 bytes)
  146   3       3   java.lang.String::hashCode (55 bytes)   made not entrant
컬럼의미
123타임스탬프(ms)
3컴파일 ID
3 or 4컴파일 레벨 (C1=1~3, C2=4)
made not entrant이전 컴파일 결과가 무효화됨 (C2가 더 나은 코드를 생성)

인라이닝 — 다른 모든 최적화의 전제 조건

인라이닝 은 메서드 호출을 호출 대상 메서드의 본문으로 대체하는 최적화입니다.

JAVA
// 최적화 전
public int calculate(int x) {
    return square(x) + 1;
}
private int square(int x) { return x * x; }

// 인라이닝 후 (JIT이 자동으로)
public int calculate(int x) {
    return x * x + 1;  // 메서드 호출 오버헤드 제거
}

단순히 호출 오버헤드를 줄이는 것이 전부가 아닙니다. 인라이닝이 되어야 JIT이 메서드 경계를 넘어서 코드를 분석할 수 있기 때문에, 상수 전파, 데드 코드 제거, 이스케이프 분석 같은 후속 최적화가 가능해집니다. 인라이닝이 실패하면 이 모든 최적화가 막힙니다.

BASH
-XX:MaxInlineSize=35       # 인라이닝할 메서드의 최대 바이트코드 크기
-XX:FreqInlineSize=325     # 자주 호출되는 메서드의 인라이닝 크기 제한
-XX:MaxInlineLevel=9       # 인라이닝 깊이 제한

메서드가 MaxInlineSize를 초과하면 인라이닝 대상에서 제외됩니다. 그래서 작은 메서드를 선호하는 것이 JIT 최적화에도 유리합니다.

이스케이프 분석 — 힙 할당을 없앨 수 있는 최적화

** 이스케이프 분석(Escape Analysis)**은 객체가 메서드나 스레드 밖으로 "탈출"하는지 분석하여, 탈출하지 않는 객체의 힙 할당을 제거하는 최적화입니다. 힙 할당이 줄어들면 GC 부담도 함께 줄어듭니다.

JIT은 객체의 탈출 수준을 세 단계로 분류합니다.

JAVA
// NoEscape — 메서드 내에서만 사용 → 스택 할당 또는 스칼라 치환 가능
public int sumPoints() {
    Point p = new Point(3, 4);
    return p.x + p.y;
}

// ArgEscape — 인자로 전달되지만 스레드를 벗어나지 않음
public void process() {
    List<String> list = new ArrayList<>();
    doSomething(list);
}

// GlobalEscape — 필드에 저장되거나 다른 스레드에서 접근 가능 → 최적화 불가
public Object escaped;
public void leak() {
    this.escaped = new Object();
}

NoEscape 객체에 적용되는 최적화

NoEscape로 판정된 객체에는 두 가지 핵심 최적화가 적용됩니다.

** 스칼라 치환 **: 객체 자체를 없애고 필드를 지역 변수로 분해합니다.

JAVA
// 원래 코드
public int distance() {
    Point p = new Point(3, 4);
    return (int) Math.sqrt(p.x * p.x + p.y * p.y);
}

// 스칼라 치환 후 — Point 객체가 사라짐
public int distance() {
    int p_x = 3;
    int p_y = 4;
    return (int) Math.sqrt(p_x * p_x + p_y * p_y);
}

락 제거(Lock Elision): 객체가 탈출하지 않으면 synchronized 블록의 락을 제거합니다. 예를 들어 StringBuffer를 메서드 안에서만 사용하면, 내부의 synchronized가 불필요하므로 JIT이 제거합니다.

JAVA
public String concat(String a, String b) {
    StringBuffer sb = new StringBuffer(); // 이 메서드 밖으로 나가지 않음
    sb.append(a);
    sb.append(b);
    return sb.toString();
    // → JIT이 StringBuffer의 synchronized를 제거
}

OSR (On-Stack Replacement)

긴 루프가 실행 중일 때, 루프 도중에 인터프리터에서 컴파일된 코드로 전환하는 기법입니다.

JAVA
public long sumToN(long n) {
    long sum = 0;
    for (long i = 0; i < n; i++) { // 이 루프가 충분히 반복되면
        sum += i;                   // 루프 중간에 컴파일된 코드로 전환
    }
    return sum;
}

-XX:+PrintCompilation 로그에서 % 표시가 OSR 컴파일을 나타냅니다.

PLAINTEXT
234   5 %     4   MyApp::sumToN @ 8 (25 bytes)
                ^  OSR         ^ 바이트코드 오프셋

추측적 최적화와 역최적화

JIT은 프로파일링 데이터를 기반으로 ** 추측 **합니다. 예를 들어 아래 코드에서 shape이 지금까지 항상 Circle이었다면, JIT은 Circle.draw()를 인라이닝합니다.

JAVA
public void process(Shape shape) {
    shape.draw(); // 프로파일링 결과: 항상 Circle.draw()가 호출됨
}

하지만 이 가정이 깨지면 어떻게 될까요? 런타임에 Rectangle이 전달되면 다음 과정이 발생합니다.

  1. 가드(guard) 조건 실패 감지 — "shape이 Circle이 아니다"
  2. ** 역최적화(deoptimization)** — 컴파일된 코드 무효화
  3. 인터프리터 모드로 복귀
  4. 새로운 프로파일링 데이터 수집
  5. 다시 컴파일 (이번에는 다형적 호출로)

역최적화는 성능에 일시적인 충격을 줍니다. 워밍업 직후 트래픽 패턴이 급변하는 서비스에서는 이 충격이 레이턴시 스파이크로 나타날 수 있습니다. -XX:+TraceDeoptimization -XX:+UnlockDiagnosticVMOptions으로 역최적화 빈도를 모니터링할 수 있습니다.

Graal JIT — Java로 작성된 JIT 컴파일러

C2의 한계

C2는 C++로 작성된 약 15만 줄의 코드베이스입니다. 새로운 최적화를 추가하기 어렵고 유지보수가 복잡합니다.

Graal의 장점

BASH
# Graal JIT 사용 (GraalVM 또는 JVMCI 지원 JDK)
-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler
  • **Java로 작성 **: 디버깅, 테스트, 확장이 용이
  • ** 부분 이스케이프 분석(Partial Escape Analysis)**: 일부 경로에서만 탈출하는 객체도 최적화
JAVA
public Point transform(boolean flag) {
    Point p = new Point(1, 2); // 항상 할당?

    if (flag) {
        return p;      // 이 경로에서는 탈출
    }
    return new Point(p.x + 1, p.y + 1); // 이 경로에서는 탈출하지 않음
}

C2의 이스케이프 분석은 p가 탈출할 수 있으므로 항상 힙에 할당합니다. Graal의 부분 이스케이프 분석은 flag == false인 경로에서 p의 힙 할당을 제거할 수 있습니다.

GraalVM Native Image

AOT 컴파일로 JVM 없이 실행 가능한 네이티브 바이너리를 생성합니다.

BASH
native-image -jar myapp.jar
./myapp  # JVM 없이 직접 실행, 밀리초 단위 시작

장점:

  • 즉시 시작 (수 밀리초)
  • 낮은 메모리 사용량
  • 작은 바이너리 크기

단점:

  • JIT 최적화 없음 (피크 성능은 JIT이 유리할 수 있음)
  • 리플렉션, 동적 프록시 등에 제약

주의할 점

Megamorphic 호출이 인라이닝을 죽인다

JIT은 호출 대상이 하나(monomorphic) 또는 둘(bimorphic)일 때 인라이닝을 적용합니다. 구현체가 세 개 이상이면 megamorphic으로 판정되어 인라이닝을 포기합니다.

JAVA
// megamorphic — JIT이 인라이닝 포기
List<Shape> shapes = List.of(new Circle(), new Rectangle(), new Triangle());
for (Shape s : shapes) {
    s.draw(); // 세 가지 타입 → 인라이닝 불가
}

인라이닝이 안 되면 후속 최적화(이스케이프 분석, 상수 전파)도 모두 차단됩니다. 성능이 중요한 핫 루프에서 인터페이스 호출의 구현체 수를 최소화하는 것이 효과적인 이유입니다.

워밍업 없이 부하를 받으면 레이턴시 스파이크

서비스 시작 직후에는 대부분의 코드가 인터프리터 모드로 실행됩니다. C2가 핫 메서드를 최적화하는 데 수초~수십 초가 걸리므로, 워밍업 없이 프로덕션 트래픽을 받으면 초기 응답 시간이 정상의 10배 이상일 수 있습니다. 배포 시 그레이스풀 워밍업(점진적 트래픽 증가)을 적용하는 것이 중요합니다.

예외를 제어 흐름에 사용하면 최적화 방해

try-catch를 루프의 흐름 제어에 사용하면 JIT이 해당 코드 경로의 최적화를 보수적으로 처리합니다. 예외는 예외적 상황에서만 사용해야 합니다.

정리

항목설명
Tiered CompilationC1(빠른 컴파일)으로 워밍업, C2(깊은 최적화)로 피크 성능 달성
인라이닝다른 최적화의 전제 조건. 메서드가 작을수록 인라이닝 확률 증가
이스케이프 분석탈출하지 않는 객체의 힙 할당 제거 + 불필요한 락 제거
추측적 최적화프로파일링 기반 추측. 가정이 깨지면 역최적화로 일시적 성능 하락
Graal JITJava로 작성. 부분 이스케이프 분석 등 C2보다 발전된 최적화
GraalVM Native ImageAOT 컴파일로 밀리초 시작. 피크 성능은 JIT이 유리할 수 있음
댓글 로딩 중...