Skip to main content

Command Palette

Search for a command to run...

JVM JIT 컴파일러 동작 원리 — C1, C2, Tiered Compilation, 그리고 Project Leyden

Updated
16 min read

이 글은 HotSpot JVM이 같은 자바 메서드를 인터프리트로 시작해서 C1, C2를 거치며 점점 더 빠른 네이티브 코드로 바꿔 가는 과정을 들여다봅니다. Tiered Compilation의 다섯 단계, OSR(On-Stack Replacement), 디옵티마이제이션, 그리고 JDK 24·25에서 들어온 Project Leyden의 AOT 캐시가 JIT 워밍업을 어떻게 학습 단계로 옮겨내는지를 같은 다이어그램 위에서 설명합니다. 자바 성능을 진단하거나 운영 중인 서비스의 워밍업 비용을 줄이려는 분에게 도움이 될 만한 글입니다.

왜 JIT가 필요한가

자바 컴파일러 javac는 소스 코드를 곧장 기계어로 바꾸지 않습니다. 대신 JVM이라는 가상 기계가 이해하는 바이트코드(.class)로 변환합니다. 그래서 같은 .class 파일이 x86, ARM, RISC-V 어디서든 동작합니다. 그런데 CPU는 바이트코드를 직접 실행하지 못합니다. 누군가는 그것을 그 CPU가 이해하는 명령어로 다시 번역해 줘야 합니다.

가장 단순한 방법은 인터프리터입니다. 바이트코드를 한 줄씩 읽어서 해석한 뒤 동등한 동작을 수행하는 방식입니다. 구현이 간단하고, 실행을 곧바로 시작할 수 있습니다. 단점은 명백합니다. 같은 메서드를 백만 번 호출하면 같은 해석 작업을 백만 번 반복합니다.

반대편에 있는 것이 AOT(Ahead-of-Time) 컴파일입니다. 실행 전에 모든 코드를 네이티브로 컴파일해 두는 방식이고, C/C++가 이렇게 동작합니다. 빠르지만, 런타임에야 알 수 있는 정보(어떤 분기가 자주 도는지, 어떤 타입이 실제로 들어오는지)를 활용할 수 없습니다.

JIT(Just-In-Time) 컴파일은 이 둘의 중간을 노립니다. 처음에는 인터프리터로 시작합니다. 같은 메서드가 자주 호출되는 것이 관측되면 그때 그 메서드만 네이티브로 컴파일합니다. 이때 인터프리터가 모은 프로파일 정보를 컴파일러가 입력으로 씁니다. "이 분기는 99% 참이다", "이 가상 메서드는 항상 같은 구현으로 디스패치됐다" 같은 정보가 그것입니다.

flowchart LR
    A[bytecode .class] --> B[Interpreter]
    B -->|hot method detected| C[JIT compile to native]
    C --> D[execute native code]
    D -->|assumption broken| E[Deoptimize back to interpreter]
    E --> B

JIT의 핵심 통찰은 두 가지입니다. 첫째, 모든 코드가 똑같이 중요하지 않다. 보통 전체 코드의 일부에서 대부분의 시간이 소비되므로, 그 일부만 잘 컴파일하면 충분합니다. 둘째, 프로파일이 곧 정보다. 인터프리트 단계에서 모은 실측치를 기반으로 더 공격적으로 최적화할 수 있습니다. AOT가 가지지 못한 카드를 JIT는 가지고 있습니다.


HotSpot의 두 컴파일러 — C1과 C2

OpenJDK HotSpot에는 두 개의 JIT 컴파일러가 있습니다.

  • C1 (Client Compiler): 빠르게 컴파일해서 인터프리트보다 훨씬 빠른 코드를 만들어 냅니다. 최적화 깊이가 얕은 대신 컴파일 시간이 짧습니다.
  • C2 (Server Compiler): 천천히, 깊게 최적화한 코드를 만들어 냅니다. 인라이닝, 이스케이프 분석, 루프 언롤링, 벡터화, 타입 가설 기반 추측 같은 공격적인 최적화를 모두 시도합니다. 대신 컴파일 자체에 시간이 걸리고 메모리도 더 씁니다.

이 둘은 역사적 구분에서 옵니다. 옛날에는 클라이언트 데스크톱 자바 애플리케이션은 빠른 시작이 중요해서 C1을, 서버 자바 애플리케이션은 오랫동안 돌면서 처리량이 중요해서 C2를 썼습니다. JDK 8 이전까지는 -client/-server 플래그로 둘 중 하나만 켜고 끄는 식이었습니다.

JDK 8부터는 둘 다 함께 쓰는 것이 기본이 되었습니다. 같은 메서드를 처음에는 C1로 빠르게 컴파일해서 인터프리트의 느린 시작을 피하고, 충분히 자주 호출되는 것이 확인되면 C2로 다시 컴파일해서 장기 처리량을 끌어올립니다. 이 모델을 Tiered Compilation이라 부릅니다.


Tiered Compilation의 다섯 단계

Tiered Compilation은 컴파일 상태를 다섯 단계(level 0~4)로 나눕니다.

Level 이름 컴파일러 프로파일 수집 코드 품질
0 Interpreter 한다 가장 느림
1 C1 simple C1 안 한다 보통
2 C1 limited profile C1 일부(invocation/backedge counter만) 보통
3 C1 full profile C1 한다(전체) 보통
4 C2 full optimization C2 안 한다 가장 빠름

핵심은 "어떤 컴파일 상태가 동시에 프로파일도 수집할지"의 결정입니다. C2는 프로파일 데이터를 입력으로 쓰는 컴파일러이므로, C2가 컴파일하기 전에 누군가 프로파일을 모아 두어야 합니다. 그 책임을 인터프리터(level 0) 혹은 C1 full-profile 모드(level 3)가 집니다.

flowchart LR
    L0[Level 0 Interpreter] -->|hot, profiling done| L3[Level 3 C1 full profile]
    L3 -->|enough samples, C2 queue free| L4[Level 4 C2]
    L0 -->|trivial method| L1[Level 1 C1 simple]
    L0 -->|C2 queue saturated| L2[Level 2 C1 limited]
    L2 -->|C2 queue free| L3

가장 흔한 경로는 0 -> 3 -> 4 입니다. 인터프리터가 프로파일을 모으는 사이에 C1이 따라잡지 못할 정도로 호출이 폭주하면, C1이 직접 프로파일까지 수집하는 level 3 코드를 만듭니다. 충분히 따끈해지면 C2가 그 프로파일을 입력 삼아 level 4 코드를 발행합니다.

특수한 경로도 있습니다.

  • 0 -> 1: 작고 단순해서 C2까지 갈 가치가 없는 메서드(e.g. getter)는 level 1로 끝냅니다. 프로파일도 모으지 않습니다.
  • 0 -> 2 -> 3 -> 4: C2 컴파일 큐가 가득 찼을 때 임시로 거치는 경로입니다. C1 limited는 빨리 컴파일되지만 프로파일이 부족하므로, 큐가 풀리면 다시 level 3로 올라가 프로파일을 채웁니다.

이 구조의 의도는 분명합니다. 빠른 워밍업과 깊은 최적화를 동시에 잡는 것. Tier 1만 있던 시절에는 빠른 시작이 가능하지만 장기 성능이 부족했고, Tier 4만 있던 시절에는 시작이 답답할 정도로 느렸습니다. Tiered Compilation은 이 두 끝을 한 그래프 위에서 잇습니다.


컴파일은 언제 시작되는가 — 카운터와 임계치

JVM은 모든 메서드에 두 종류의 카운터를 답니다.

  • Invocation counter: 메서드가 호출된 횟수
  • Backedge counter: 메서드 안의 루프에서 뒤로 점프한 횟수(루프 회전 수)

이 카운터가 임계치를 넘으면 그 메서드를 컴파일 큐에 집어넣습니다. Tiered가 꺼져 있는 비-tiered 정책에서는 단순히 inv_count > CompileThreshold이면 컴파일하고, inv_count + bb_count > BackEdgeThreshold이면 OSR을 시도합니다. 기본 CompileThreshold는 10,000입니다.

Tiered가 켜진 기본값에서는 단순한 임계치 비교가 아니라 두 카운터를 종합한 더 동적인 정책을 씁니다. 큐가 비어 있고 카운터가 빠르게 늘어나면 더 일찍 승급시키고, 큐가 밀려 있으면 한 단계 낮은 티어에 잠시 머무르게 합니다. 그래서 운영 중에 같은 코드라도 부하 패턴에 따라 컴파일 결정 시점이 흔들릴 수 있습니다.

이 동작을 직접 확인하려면 -XX:+PrintCompilation 플래그를 쓰면 됩니다. 출력은 다음과 비슷합니다.

   89    1     n 0       java.lang.invoke.MethodHandle::linkToStatic (native)
  142    2       3       java.lang.String::hashCode (55 bytes)
  142    3       3       java.lang.String::charAt (29 bytes)
  ...
  912   42       4       org.example.Hot::process (185 bytes)
  912   38       3       org.example.Hot::process (185 bytes)   made not entrant

세 번째 컬럼이 컴파일 레벨입니다. 3은 C1 full profile, 4는 C2 full optimization입니다. 마지막 줄의 made not entrant는 같은 메서드의 이전 level 3 버전이 폐기되었음을 뜻합니다. 같은 메서드의 새 버전이 나왔으니 더 이상 진입을 허용하지 않겠다는 표식이고, 이미 그 코드를 실행 중인 스레드는 끝까지 마치고 다음 호출부터는 새 버전을 씁니다.


OSR — 루프 한가운데에서 갈아타기

main 메서드 안에서 큰 루프 한 번으로 작업을 끝내는 프로그램을 생각해 봅니다. 일반적인 컴파일 정책은 메서드 호출 횟수를 보지만, 이 메서드는 평생 한 번만 호출됩니다. 인터프리트로 시작해서 인터프리트로 끝나면 컴파일 효과를 전혀 누리지 못합니다.

이를 구하기 위한 메커니즘이 **OSR(On-Stack Replacement)**입니다. 컴파일러는 메서드 안의 루프 진입점에 대해 별도의 컴파일을 수행합니다. 그리고 인터프리터가 그 루프를 충분히 돌렸다고 판단되는 순간, 루프 회전 도중에 인터프리터에서 실행 중이던 스택 프레임을 컴파일된 네이티브 프레임으로 통째로 갈아치웁니다. 같은 메서드의 같은 호출이지만, 다음 회전부터는 컴파일된 코드 위에서 굴러갑니다.

flowchart TD
    A[Method invoked] --> B[Interpreter executes loop]
    B --> C{Backedge counter > threshold?}
    C -- no --> B
    C -- yes --> D[Compile OSR version of loop]
    D --> E[Build new stack frame in compiled form]
    E --> F[Migrate locals from interpreter frame]
    F --> G[Resume execution at compiled loop entry]

OSR로 만들어진 컴파일 결과물은 그 진입점 전용입니다. 일반적인 메서드 진입(첫 줄부터 실행)에는 쓰이지 않습니다. 실제로 PrintCompilation 출력에서 OSR로 만들어진 코드는 끝에 % (N) 표기가 붙어 구분됩니다. N은 OSR 진입 바이트코드 인덱스입니다.

OSR의 존재 덕분에 마이크로벤치마크가 끔찍하게 잘못된 결과를 내기도 합니다. 측정 루프 안에 OSR이 발생하면, 측정 시작 이전과 이후의 코드가 다른 수준으로 최적화된 채 같은 결과 안에 섞여 들어옵니다. JMH 같은 도구가 별도의 워밍업 페이즈를 강제하는 이유가 여기 있습니다.


디옵티마이제이션 — "내가 틀렸어"

C2가 만들어 내는 코드는 자주 **추측(speculation)**에 기댑니다. 프로파일을 보고 "이 호출 사이트는 항상 ArrayList로만 호출됐으니, 그냥 ArrayList.get을 인라이닝해 버리자" 같은 결정을 내립니다. 가상 호출의 비용을 한 번에 없애 버리는 강력한 최적화입니다.

문제는 그 가설이 틀릴 수 있다는 점입니다. 운영 중 어느 순간 누군가가 LinkedList를 인자로 넘기면, 인라이닝된 ArrayList.get 코드는 잘못된 동작을 합니다. 이를 막기 위해 컴파일된 코드 안에는 uncommon trap이라 부르는 가드가 박혀 있습니다. 가드가 발동하면 다음 동작이 일어납니다.

  1. 현재 컴파일된 프레임의 실행을 중단합니다.
  2. 인터프리터가 같은 위치에서 이어 실행할 수 있도록 스택 프레임과 지역 변수를 재구성합니다(OSR의 정반대 방향).
  3. 그 메서드의 컴파일 결과를 폐기(made not entrant)하고, 인터프리터로 돌려보냅니다.
  4. 인터프리터가 다시 충분히 돌리면 새 프로파일로 재컴파일합니다.

이를 디옵티마이제이션(Deoptimization) 이라 부릅니다. C2의 공격적 최적화가 가능한 근본 이유는 디옵티마이제이션이라는 안전망이 있기 때문입니다. 잘못 추측해도 동작 자체는 깨지지 않으므로, 컴파일러는 마음 놓고 추측할 수 있습니다.

flowchart TD
    A[C2 compiled code running] --> B{Speculative guard hit?}
    B -- no --> A
    B -- yes --> C[Reconstruct interpreter frame and locals]
    C --> D[Mark compiled code not entrant]
    D --> E[Resume in interpreter]
    E --> F[Recompile with updated profile]

운영에서 디옵티마이제이션이 잦다는 것은 보통 좋은 신호가 아닙니다. 같은 코드를 끊임없이 컴파일했다 폐기하는 사이클을 도는 것이고, 이는 CPU와 코드 캐시를 모두 낭비합니다. -XX:+PrintCompilation -XX:+PrintInlining 혹은 JFR 이벤트 jdk.Deoptimization로 추적할 수 있습니다.


인라이닝 — 모든 최적화의 어머니

C2가 코드 품질을 올리는 데 가장 크게 기여하는 단일 최적화는 인라이닝입니다. 호출 사이트의 메서드 호출을 호출되는 메서드의 본문으로 통째로 대체하는 것입니다. 단순한 호출 비용을 없애는 것 이상의 효과가 있습니다. 인라이닝된 본문이 호출자의 컨텍스트와 합쳐지면서, 죽은 코드 제거, 상수 전파, 이스케이프 분석, 루프 변환 같은 후속 최적화가 모두 더 멀리 뻗어 갈 수 있게 됩니다.

가상 호출(인터페이스, 비-final 메서드)도 프로파일이 "여기서는 항상 같은 구현이 호출된다"고 알려 주면 **단형 인라이닝(monomorphic inlining)**으로 인라이닝됩니다. 두 종류의 구현이 섞여 들어오면 **이형 인라이닝(bimorphic inlining)**으로 두 분기를 모두 인라이닝하고 type guard를 답니다. 셋 이상이면 인라이닝을 포기하고 가상 호출로 남깁니다.

인라이닝의 한계는 두 가지 측면에서 옵니다.

  • 메서드 크기: -XX:MaxInlineSize (기본 35바이트), -XX:FreqInlineSize (기본 325바이트). 일반 메서드는 35바이트 이하만, 호출 빈도가 매우 높은 hot 메서드는 325바이트 이하까지 인라이닝합니다. 메서드를 너무 크게 짜면 인라이닝이 가로막히고, 그 결과 다른 최적화도 함께 막힙니다.
  • 호출 깊이: 인라이닝이 너무 깊어지면 컴파일 시간과 코드 캐시 비용이 폭증합니다. C2는 일정 깊이에서 자르고 호출로 남깁니다.

-XX:+PrintInlining 플래그로 어떤 호출이 인라이닝됐고 어떤 호출이 거부됐는지를 볼 수 있습니다. 거부된 이유에는 too big, not inlineable, disallowed by CompilerOracle, callee is too large 같은 문구가 붙습니다.

다음 코드를 봅니다.

public abstract class Shape {
    public abstract double area();
}

public final class Circle extends Shape {
    private final double r;
    public Circle(double r) { this.r = r; }
    @Override public double area() { return Math.PI * r * r; }
}

public final class Square extends Shape {
    private final double s;
    public Square(double s) { this.s = s; }
    @Override public double area() { return s * s; }
}

public double sumAreas(List<Shape> shapes) {
    double sum = 0;
    for (Shape sh : shapes) {
        sum += sh.area();
    }
    return sum;
}

shapes 리스트가 사실상 항상 Circle만 들어오는 환경이라면, C2는 sh.area() 호출을 단형으로 인라이닝하고 그 안의 Math.PI * r * r까지 풀어 헤쳐 루프를 단순한 산술 연산 시퀀스로 만들 수 있습니다. CircleSquare가 섞여 들어오면 이형 인라이닝으로 두 분기를 모두 인라이닝하되 type guard가 추가됩니다. 셋 이상의 구현이 들어오면 가상 호출로 남고, 그 안에서의 추가 최적화(상수 전파, 죽은 코드 제거)는 차단됩니다. 같은 코드인데 호출 분포가 바뀌면 성능이 달라지는 이유입니다.


C2의 내부 표현 — Sea of Nodes

C2가 깊은 최적화를 할 수 있는 이유 중 하나는 그 내부 표현(IR)에 있습니다. 전통적인 컴파일러는 코드를 제어 흐름 그래프(CFG) 위의 기본 블록으로 표현합니다. 그러면 데이터 의존성과 제어 의존성이 분리되어 있어, 어떤 명령이 어떤 명령에 의존하는지를 따로 따로 추적해야 합니다.

C2는 그 대신 Sea of Nodes라는 IR을 씁니다. 모든 연산을 하나의 노드로 만들고, 노드 사이의 엣지가 데이터 의존성과 제어 의존성을 함께 표현합니다. 노드들은 처음에는 어디에도 "위치"가 없습니다. 코드 생성 직전에야 컴파일러가 각 노드를 어떤 기본 블록에 넣을지 결정합니다.

이 표현의 효과는 강력합니다. 데이터 의존성만 만족시키면 노드를 자유롭게 옮겨 다닐 수 있으므로, 루프 불변 코드 이동(loop-invariant code motion), 공통 부분식 제거(CSE), 죽은 코드 제거가 같은 변환의 다른 측면처럼 자연스럽게 일어납니다. 이스케이프 분석으로 "이 객체는 메서드 밖으로 새지 않는다"고 판정되면, 그 객체의 할당 자체를 빼고 필드들을 레지스터에 직접 두는 스칼라 치환(scalar replacement)도 가능해집니다.

대신 디버깅이 어렵습니다. 컴파일된 코드의 어떤 명령이 원래 어느 줄에서 왔는지 역추적하려면 디옵티마이제이션 시점에 사용할 별도의 메타데이터를 잔뜩 들고 다녀야 합니다. C2의 컴파일 시간이 C1보다 한참 긴 이유에는 이 IR 구축과 변환 비용도 포함됩니다.


디옵티마이제이션을 직접 보는 코드

다음 예제는 디옵티마이제이션이 어떻게 발동되는지를 직접 관찰할 수 있게 합니다.

public class DeoptDemo {
    interface Op { int apply(int x); }

    static int run(Op op, int n) {
        int sum = 0;
        for (int i = 0; i < n; i++) sum += op.apply(i);
        return sum;
    }

    public static void main(String[] args) {
        Op square = x -> x * x;
        Op cube   = x -> x * x * x;

        // 첫 단계: square만 1억 번 -> C2가 square로 단형 인라이닝
        for (int k = 0; k < 5; k++) run(square, 100_000_000);

        // 두 번째 단계: 갑자기 cube를 넣음 -> 가설 깨짐 -> deopt
        run(cube, 100_000_000);
    }
}

-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+TraceDeoptimization을 붙여 실행하면, 두 번째 단계에서 DeoptDemo::runmade not entrant로 표시되고 디옵티마이제이션 이벤트가 출력됩니다. 그 다음 호출에서 인터프리터가 새 프로파일을 모으고, 충분히 돌면 양쪽 람다를 모두 고려한 이형 인라이닝 버전이 다시 만들어집니다.

이 패턴이 운영에서 자주 발생하면, 같은 호출 사이트에서 실제로 보는 구현체를 줄이는 방향(영역별 분리, 명시적 분기)으로 리팩터링하는 것이 대안입니다.


JIT 진단 도구

JIT 동작을 들여다보는 손에 익은 도구를 몇 가지 정리합니다.

  • -XX:+PrintCompilation: 어떤 메서드가 어떤 레벨로 컴파일됐는지를 한 줄씩 출력합니다. 가장 가벼운 진단이고 운영에 켜 두어도 부담이 적습니다.
  • -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining: 인라이닝 결정을 트리 형태로 출력합니다. hot path가 왜 인라이닝되지 않는지 진단할 때 첫 번째로 켭니다.
  • -XX:+LogCompilation -XX:LogFile=hotspot.log: 컴파일 이벤트 전체를 XML 로그로 떨어뜨립니다. JITWatch 같은 도구로 시각화해서 본격 분석에 씁니다.
  • jcmd <pid> Compiler.codecache: 코드 캐시 사용량 스냅샷. 실시간 모니터링에 적합합니다.
  • jcmd <pid> Compiler.queue: 컴파일 큐의 현재 상태. 큐가 늘 길게 쌓여 있다면 워밍업이 끝나지 않았거나 코드 캐시가 부족한 상황을 의심합니다.
  • JFR(Java Flight Recorder): jdk.Compilation, jdk.Deoptimization, jdk.CodeCacheStatistics 등의 이벤트를 운영 부하에 영향을 거의 주지 않으면서 수집합니다. JDK Mission Control에서 시각화합니다.
  • async-profiler: 컴파일 단위가 아니라 실제 CPU 시간 분포로 hot path를 보는 데 가장 강력한 도구입니다. JIT 관점의 진단(JFR/JITWatch)과 함께 쓰면 양쪽 시야가 맞습니다.

코드 캐시 — JIT가 만든 것을 어디에 두는가

JIT가 만들어 낸 네이티브 코드는 Code Cache라는 별도의 메모리 영역에 들어갑니다. 힙도 아니고 메타스페이스도 아닙니다. 기본 크기는 보통 240MB이고, -XX:ReservedCodeCacheSize로 조정합니다.

JDK 9부터 코드 캐시는 세 영역으로 나뉩니다(JEP 197).

  • Non-method: VM 내부 코드(어댑터, 인터프리터)
  • Profiled: 짧은 수명의 코드 — 주로 C1 컴파일 결과
  • Non-profiled: 긴 수명의 코드 — 주로 C2 컴파일 결과

이렇게 나누는 이유는 단편화 때문입니다. C1 코드는 자주 폐기되고 새로 만들어지는데, 그것이 C2 코드와 같은 영역에 섞여 있으면 시간이 지나면서 빈 구멍투성이가 되어 큰 C2 메서드를 둘 자리가 사라집니다. 영역을 나누면 C2 코드는 C2 코드끼리만 인접하게 배치됩니다.

코드 캐시가 가득 차면 JIT가 멈춥니다. 그러면 새 메서드를 컴파일할 수도, 기존 메서드를 더 높은 티어로 올릴 수도 없게 되어 처리량이 점진적으로 떨어집니다. 다음 경고가 로그에 보이면 곧바로 의심해야 합니다.

CodeCache: size=245760Kb used=245741Kb max_used=245760Kb free=18Kb
 bounds [0x...]
 ...
CodeCache is full. Compiler has been disabled.

대형 모놀리식 애플리케이션이나 동적으로 클래스를 많이 만들어 내는 프레임워크(과거의 Groovy/JRuby, JIT가 무거운 일부 ORM)에서는 기본 240MB로 부족할 수 있습니다. -XX:ReservedCodeCacheSize=512m 정도로 늘리는 것이 일반적인 처방입니다.


Project Leyden — JIT 워밍업을 학습 단계로 옮기다

자바 애플리케이션의 고질적인 단점은 차가운 시작입니다. JVM이 막 부팅된 직후에는 모든 코드가 인터프리터에서 굴러갑니다. 클래스를 로드하고, 메타데이터를 만들고, 첫 호출들에서 프로파일을 쌓는 동안 처리량이 정상치의 1/10 수준입니다. 컨테이너 환경에서 자주 재시작되는 서비스는 이 워밍업 비용을 매번 다시 치릅니다.

이 문제를 해결하기 위해 OpenJDK는 여러 시도를 해 왔습니다.

  • JEP 295 (JDK 9, 2017): 실험적 AOT 컴파일러 jaotc를 들여놨습니다. Graal 기반이었습니다.
  • JEP 410 (JDK 17, 2021): 사용자가 거의 없다는 이유로 jaotc를 다시 제거했습니다.
  • AppCDS: 클래스 데이터 공유. 클래스 파일을 미리 파싱한 결과를 아카이브로 저장해 두고 재사용합니다. 이건 살아남았습니다.

새 흐름은 Project Leyden이라는 이름으로 진행됩니다. 핵심 발상은 단순합니다. 런타임의 일을 학습 단계(training run)로 옮기자. 한 번 평소처럼 애플리케이션을 돌리면서 결과물을 캐시에 저장하고, 다음 실행부터는 그 캐시를 입력으로 받아 시작하는 방식입니다.

flowchart LR
    subgraph Training[Training run]
        T1[Run app with AOTMode=record] --> T2[Collect: loaded classes, linked state, hot method profiles]
        T2 --> T3[Write AOT cache file]
    end
    subgraph Production[Production run]
        P1[Start JVM with AOTCache=app.aot] --> P2[Skip class load and link work]
        P2 --> P3[Feed hot profiles directly into JIT]
        P3 --> P4[Native code generated earlier]
    end
    Training -.->|cache file| Production

이 흐름을 구체화한 JEP들은 다음과 같습니다.

  • JEP 483 (JDK 24, 2025): AOT 클래스 로딩과 링킹. AppCDS가 했던 "파싱된 클래스 캐시" 위에 "로드되고 링크된 상태"까지 캐시합니다. Spring PetClinic 기준 시작 시간이 4.486초 -> 2.604초로 약 42% 줄었다고 보고됐습니다.
  • JEP 514 (JDK 25, 2025): AOT 명령행 단순화. JDK 24에서는 record/assemble/run 세 단계였던 것을 -XX:AOTCacheOutput=app.aot 한 플래그로 record와 assemble을 합쳐, train과 deploy 두 단계로 줄였습니다.
  • JEP 515 (JDK 25, 2025): AOT 메서드 프로파일. 학습 단계에서 모은 hot 메서드 프로파일을 캐시에 함께 담아, 운영 단계의 JIT가 부팅 직후부터 그것을 입력으로 컴파일을 시작합니다. 인터프리터에서 프로파일을 다시 쌓는 시간을 건너뜁니다.

Leyden은 JIT를 대체하지 않습니다. GraalVM Native Image처럼 모든 코드를 통째로 AOT 컴파일하는 길이 아니라, JIT의 입력을 미리 만들어 두는 길입니다. 그래서 동적으로 클래스를 만들어 내는 프레임워크나 리플렉션, agent와도 같은 모델로 동작합니다. 단점은 학습 단계가 필요하다는 점이고, 학습 시 본 동작 시나리오가 운영의 그것과 비슷해야 효과가 큽니다.


운영에서 자주 부딪히는 함정 다섯 가지

1. 워밍업 없이 한 측정은 거짓말이다

자바 마이크로벤치마크에서 가장 흔한 실수는 인터프리트 -> C1 -> C2 -> 디옵티마이제이션 -> 재컴파일까지 끝나기 전에 측정값을 받는 것입니다. 측정 결과는 인터프리트 시간, C1 시간, C2 시간이 뒤섞인 평균이 됩니다. JMH는 별도의 워밍업 반복(@Warmup)을 강제해서 이를 막습니다. 손으로 측정할 때도 처음 수천 회는 버리는 습관이 필요합니다.

2. final 빼고 너무 큰 메서드 쓰기

인라이닝 임계치(MaxInlineSize, FreqInlineSize)를 넘어서는 메서드는 인라이닝되지 않습니다. 인라이닝되지 않으면 후속 최적화도 따라서 차단됩니다. 핵심 hot path 메서드가 수백 줄로 부풀어 있으면, 그것을 잘게 쪼개는 것만으로도 성능이 의미 있게 좋아질 수 있습니다.

3. 다형성 폭발

같은 호출 사이트에 셋 이상의 구현이 들어오면 인라이닝이 포기됩니다. 추상화를 위해 인터페이스 뒤에 무언가를 가두는 것은 좋지만, 핵심 hot path에서 한 호출 사이트에 너무 많은 구현이 도달하면 C2가 손을 놓습니다. 같은 추상의 사용을 영역별로 분리해 두면 각 영역에서는 단형 호출로 보여서 인라이닝이 살아납니다.

4. 코드 캐시 부족

대형 자바 애플리케이션에서 처리량이 시간이 지나며 미묘하게 떨어지면 코드 캐시를 의심합니다. JFR 이벤트 jdk.CodeCacheStatisticsjcmd <pid> Compiler.codecache로 사용량을 확인하고, 80%를 꾸준히 넘으면 -XX:ReservedCodeCacheSize를 늘립니다.

5. Tiered를 끄려는 충동

JDK 8 시절의 옛 글에서 "C2만 쓰는 게 더 빠르다"는 조언을 보고 -XX:-TieredCompilation을 넣는 경우가 있습니다. 현대의 워크로드와 JVM에서는 거의 항상 Tiered가 이깁니다. C2만 쓰면 워밍업 동안 인터프리터에서 굴러야 하는데, 그 시간이 보통 Tiered의 추가 오버헤드보다 훨씬 큽니다. 측정 없이 끄지 말아야 합니다.


마무리

JVM JIT는 단순한 "바이트코드를 기계어로 번역하는 도구"가 아닙니다. 인터프리터로 시작해서, 프로파일을 모으고, 그 프로파일을 입력 삼아 점점 더 깊은 최적화를 시도하고, 가설이 깨지면 안전하게 후퇴하는 자기 적응형 시스템입니다. C1과 C2의 협업으로 빠른 워밍업과 깊은 최적화를 동시에 잡고, OSR로 단발성 메서드도 컴파일의 혜택을 받게 하고, 디옵티마이제이션으로 공격적 추측을 안전망 위에 올립니다.

Project Leyden은 그 다음 한 걸음을 그립니다. 워밍업 자체를 학습 단계로 옮겨, 운영 단계에서는 시작부터 거의 정상 처리량으로 굴러가게 만드는 방향입니다. JIT를 버리는 게 아니라 JIT의 입력을 미리 만들어 둡니다. 동적인 자바의 매력을 잃지 않으면서 차가운 시작을 데우는 길입니다.

자바를 운영해 본 사람이라면 다음 두 가지만 기억해도 충분합니다. 측정은 충분히 워밍업한 뒤에 한다는 것, 그리고 hot path는 작고 단형으로 유지한다는 것. 이 두 가지가 JIT의 동작 모델과 가장 잘 맞습니다.


참고자료

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ