[JVM] 실행 모델(ClassLoader 와 JIT)

개요

자바 코드를 작성하면 javac 가 바이트코드(.class)를 만들고, JVM은 그 바이트코드를 받아서 실행한다. 클래스 파일을 메모리에 올리는 클래스 로더(ClassLoader), 바이트코드를 한 줄씩 해석하는 인터프리터(Interpreter), 자주 쓰이는 코드를 네이티브로 컴파일하는 JIT(Just-In-Time) 컴파일러, 그리고 실행 중 클래스를 동적으로 끌어오는 리플렉션까지 모두 실행 모델의 일부다.

왜 첫 요청은 느리고 한참 부하를 받은 뒤에야 빨라지는지(워밍업), 왜 Spring 같은 프레임워크가 Metaspace를 많이 쓰는지, 왜 GraalVM 네이티브 이미지로 빌드하면 시작은 빠른데 정점 성능이 떨어지는지(AOT 와 JIT)를 알아본다.

전체 조감도

컴파일(소스에서 바이트코드까지)

javac 는 자바 코드를 JVM이 이해할 수 있는 중간 언어인 바이트코드로 변환하는 프론트엔드 컴파일러다. 이 단계는 코드 최적화보다 문법적 정합성과 타입 검사에 집중한다.

javac 의 단계

  1. 어휘 분석: 소스 코드의 문자 스트림을 토큰(키워드, 변수명, 리터럴, 연산자)으로 분해한다.
    int x = 10; // [int][x][=][10][;]
  2. 구문 분석: 토큰들로부터 추상 구문 트리(AST)를 구성하고, 문법 규칙에 맞는지 검사한다.
  3. 심벌 테이블 채우기: key가 변수, value가 메모리 주소인 형태로 심벌 정보를 모은다.
  4. 애너테이션 처리: 컴파일러는 애너테이션이 AST를 변경할 수 있게 허용한다(Lombok, MapStruct 등). 변경이 발생하면 1번으로 되돌아가 AST가 안정될 때까지 반복한다.
  5. 의미 분석: 변수 선언 여부, 타입 일치, 데이터와 제어 흐름의 논리적 정합성을 검사한다.
  6. 편의 문법 제거: 제네릭, 가변 길이 매개변수, 오토박싱과 언박싱 등은 런타임이 직접 지원하지 않는다. 컴파일 단계에서 더 기본 문법으로 변환한다.
  7. 바이트코드 생성: 최종적으로 .class 파일로 저장한다.

바이트코드 모양 보기

public int add(int a, int b) {
    return a + b;
}

javap -c MyClass.class 로 풀어 보면 이렇게 나온다.

public int add(int, int);
  Code:
     0: iload_1   // 로컬 변수 a를 피연산자 스택에 push
     1: iload_2   // 로컬 변수 b를 피연산자 스택에 push
     2: iadd      // 스택 상위 2개를 꺼내 더하고 결과를 push
     3: ireturn   // 스택 상위 값을 반환

바이트코드는 OS, CPU 에 종속되지 않는다. JVM만 깔려 있다면 어느 머신에서든 같은 .class로 같은 결과를 얻을 수 있다. (Write Once, Run Anywhere)

클래스 로더(바이트코드를 JVM에 로드)

클래스 로더는 .class 파일을 JVM에 로드해 런타임 데이터 영역에 배치한다. 핵심 특성은 동적 로딩이다.

왜 동적인가

자바는 인터페이스, 다형성, 리플렉션을 제공한다. 컴파일 시점에 어떤 구현체가 실제로 실행될지 알 수 없는 경우가 많다. 그래서 클래스 로더는 시작할 때 모든 클래스를 한꺼번에 로드하지 않고, 필요한 시점에 필요한 클래스만 로드한다.

// 컴파일 시점에는 어떤 구현체가 와도 됨
PaymentProcessor processor = paymentProcessorFactory.create(kind);
 
// 런타임에 클래스 이름으로 클래스를 가져옴
Class<?> clazz = Class.forName("com.example.PayPalProcessor");
PaymentProcessor processor = (PaymentProcessor) clazz
    .getDeclaredConstructor().newInstance();

클래스 로더의 3계층 구조

클래스 로더역할
Bootstrap ClassLoaderJVM 핵심 클래스(java.lang.*, java.util.* 등). 네이티브 코드로 구현
Platform ClassLoader플랫폼 모듈(JCE, JAXP 등). Java 8의 Extension ClassLoader 가 이름을 바꾼 것
System(Application) ClassLoader애플리케이션 classpath의 클래스

부모 위임 모델(Parent Delegation Model)

클래스 로더는 클래스 로드 요청을 받으면, 자기가 로드하기 전에 먼저 부모 로더에게 위임한다.

이 위임 덕분에 java.lang.String 은 항상 Bootstrap이 로드한다. 사용자가 같은 이름의 String 클래스를 만들어도 위에서 가로채진다. 클래스 식별의 일관성과 보안을 보장하는 메커니즘이다.

부모 위임 모델을 깨는 케이스

부모 위임은 절대적인 룰이 아니다. JNDI, JDBC, OSGi, 톰캣 같은 환경에서는 다른 방식으로 클래스를 로딩할 수 있다. 톰캣의 경우 웹 애플리케이션마다 다른 클래스 로더를 두고 자기가 먼저 클래스를 찾는다. 같은 라이브러리의 다른 버전을 웹 애플리케이션별로 격리하기 위해서다.

로딩 단계(Loading, Linking, Initialization)

클래스 로더는 다음 세 단계를 거쳐 클래스를 JVM에 올린다.

1) Loading

.class 파일을 읽어 메서드 영역(Metaspace)에 저장한다. 저장되는 정보는 다음과 같다.

  • 클래스와 부모 클래스의 정보
  • 인터페이스, Enum, 어노테이션 정보
  • 필드, 메서드 시그니처

2) Linking

로딩된 정보가 JVM 명세에 맞는지 검사하고, 메모리 배치를 준비한다. 다시 세 단계로 나뉜다.

  • Verify: 바이트코드가 JVM 명세를 위반하지 않는지 검사. 스택 오버플로우, 잘못된 타입 변환 등을 잡아낸다.
  • Prepare: 정적 필드를 기본값(0, null, false 등)으로 초기화한 메모리를 할당한다.
  • Resolve: 심벌 참조(예: java/lang/String 같은 클래스 이름)를 직접 참조(메모리 주소)로 변환한다.

3) Initialization

static 블록을 실행하고, 정적 필드를 사용자가 명시한 값으로 초기화한다. Prepare가 기본값이라면, Initialization은 명시값이다.

public class Config {
    public static int retry = 3;          // Prepare: 0, Initialization: 3
    public static String mode;             // Prepare: null, Initialization: null
 
    static {
        // Initialization 단계에서 실행
        System.out.println("Config class loaded");
    }
}

런타임 데이터 영역

클래스 로딩이 끝나면, 그 정보는 JVM의 메모리 영역들에 분산된다.

분류영역저장되는 것
공유메서드 영역(Metaspace)클래스 메타데이터, 정적 필드, 런타임 상수 풀, JIT 코드 캐시
공유객체 인스턴스
스레드별VM Stack스택 프레임(지역 변수, 피연산자 스택)
스레드별PC Register다음 실행할 바이트코드 주소
스레드별Native Method StackJNI 호출용 스택

Java 8 부터 클래스 메타데이터를 관리하던 힙의 PermGen 이 제거되고, 네이티브 메모리의 Metaspace 로 대체되었다. 그래서 클래스를 많이 로드해도 힙은 영향받지 않지만, 대신 OS 메모리를 잠식할 수 있어 -XX:MaxMetaspaceSize 가 중요해졌다.

스레드별 영역은 동기화 없이 빠르게 접근할 수 있다. 공유 영역은 여러 스레드가 동시에 읽고 쓸 수 있어서 동시성 처리가 필요하다.

실행 엔진(인터프리터와 JIT 의 협업)

런타임 데이터 영역에 배치된 바이트코드를 실제로 돌리는 주체가 실행 엔진이다. 핫스팟 JVM의 실행 엔진은 인터프리터와 JIT 컴파일러를 혼합해서 사용한다.

인터프리터

인터프리터는 바이트코드를 한 줄씩 읽어 기계어로 번역하며 실행한다. 시작은 빠르지만, 같은 코드가 반복 실행되어도 매번 해석을 하고, 비효율적이다. 이 한계 때문에 JIT가 필요해졌다.

JIT 컴파일러(런타임에 네이티브로 바꾸기)

JIT는 자주 호출되는 코드(핫코드, hot code)를 백그라운드에서 네이티브 코드(cpu가 바로 읽을 수 있는)로 컴파일하고, 다음 실행 때 컴파일된 결과를 사용한다.

여기서 ‘컴파일한다’는 것은 소스 코드를 다시 읽는 게 아니다. 이미 클래스 로더가 메서드 영역에 올려 둔 바이트코드를, 지금 이 JVM이 돌고 있는 CPU의 네이티브 기계어로 번역하는 것이다. 대상은 전체 코드가 아니라 카운터로 골라낸 핫 메서드(또는 핫 반복문) 하나 단위다. 번역 결과인 네이티브 코드는 Code Cache에 저장해 두고, 다음에 그 메서드가 불리면 인터프리터를 거치지 않고 캐시된 기계어를 바로 실행한다.

C1 과 C2는 ‘바이트코드 → 네이티브 기계어’라는 목적은 같고, 어떤 과정으로 변환하느냐만 다르다. C1은 인라이닝, 상수 전파처럼 최적화만 빠르게 적용하고, C2는 프로파일 데이터를 근거로 탈출 분석,루프 언롤링 같은 무거운 최적화 적용해 기계어를 만든다.

JIT가 정적 컴파일(AOT)보다 좋은 점은 자주 쓰이는 코드의 빈도수를 안다는 점이다. AOT는 미리 컴파일하기 때문에 어떤 코드가 자주 쓰이는지 모르고 모든 코드를 같은 수준으로 다룬다. JIT는 런타임 통계를 기반으로 핫코드에만 집중해서 최적화한다.

핫코드 판별(카운터)

JIT 컴파일러는 두 가지 카운터로 핫코드를 판별한다.

메서드 호출 카운터

메서드가 얼마나 호출됐는가를 기록한다. -XX:CompileThreshold 로 임계치를 지정할 수 있고, 임계치를 넘으면 JIT가 백그라운드에서 비동기로 컴파일을 수행한다.

백 에지 카운터(Back Edge Counter)

백 에지란 반복문 끝에서 시작 지점으로 돌아가는 분기다. 이 분기가 임계치 이상 반복되면 해당 반복문은 핫코드로 간주된다.

for (int i = 0; i < 1_000_000; i++) {
    // 이 반복문 자체가 백 에지 카운터에 의해 핫코드로 식별됨
    process(items[i]);
}

순환문 코드는 메서드에 포함되어 있어서, 스택 프레임에서 실행 중인 이 메서드를 도중에 변환해야 한다. 이를 OSR(On-Stack Replacement, 온스택 치환) 컴파일이라고 한다. OSR 덕분에 인터프리터로 한 줄씩 반복하지 않고, 실행 도중 네이티브 코드로 갈아끼울 수 있다.

C1 컴파일러(빠른 응답)

C1 은 원래 데스크톱, GUI처럼 빠른 시작이 중요한 클라이언트 환경을 위해 만들어진 컴파일러라 흔히 클라이언트 컴파일러(Client Compiler)라고 부른다. C1, C2의 숫자는 최적화 계층(Compiler 1·2)을 가리키며, C1은 무거운 분석을 생략하고 컴파일 자체를 빠르게 끝내는 데 초점을 둔다.

C1은 빠른 컴파일 속도가 강점이지만, 코드 최적화는 C2보다 얕다. 인터프리터와 달리 네이티브 코드로 컴파일을 수행하며, C2가 나중에 쓸 프로파일 데이터도 함께 수집한다. 즉 인터프리터보다는 빠르고 C2 보다는 최적화가 얕은 중간 계층 역할을 한다.

// 1. 메서드 인라이닝 (Inlining)
//    메서드 호출 오버헤드 제거
int result = square(x);
private int square(int n) { return n * n; }
// 최적화 후
int result = x * x;
 
// 2. Dead Code Elimination
if (false) {
    heavyOperation();   // 실행 불가 코드 제거
}
 
// 3. Constant Propagation
int x = 10;
int y = x + 5;          // int y = 15;

C2 컴파일러(깊은 최적화)

C2는 오래 살아 있는 서버 애플리케이션을 위해 만들어져 흔히 서버 컴파일러(Server Compiler)라고 부른다. 시작이 조금 느려도 오래 돌면서 최고 속도에 도달하는 워크로드를 겨냥한다.

C2는 컴파일 속도는 느리지만, 정점 성능 면에서 가장 좋다. C1에서 수집한 프로파일 데이터를 이용해 핫코드를 깊게 최적화한다.

// 1. 탈출 분석 (Escape Analysis)
//    객체가 메서드 밖으로 탈출하지 않으면 힙 대신 스택에 할당
public int computeArea() {
    Point p = new Point(3, 4);   // p는 메서드 밖으로 탈출 X
    return p.x * p.y;
}
// new Point() 힙 할당 제거, GC 부담 감소
 
// 2. 루프 언롤링 (Loop Unrolling)
for (int i = 0; i < 4; i++) {
    data[i] = 0;
}
// data[0]=0; data[1]=0; data[2]=0; data[3]=0;
// 분기 횟수를 줄여 CPU 파이프라인 효율 향상
 
// 3. 추측적 최적화 (Speculative Optimization)
//    프로파일 기반으로 아마도 이럴 것이라고 가정하고 최적화
shape.draw();   // 항상 Circle.draw() 였다면
// if (shape is Circle) Circle.draw();
// else deoptimize;
 
// 4. 강도 감소 (Strength Reduction)
// 곱셈을 비트 시프트로 대체
// x * 2 → x << 1
// x * 8 → x << 3

계층형 컴파일(Tiered Compilation)

C1은 빠른 응답, C2는 깊은 최적화가 강점이다. 두 장점을 모두 살리기 위해 등장한 것이 계층형 컴파일이다. 인터프리터부터 C2까지 5단계를 두고, 코드 실행 빈도에 따라 점점 더 깊은 최적화를 적용한다.

계층동작
Level 0인터프리터 레벨에서 순수 해석
Level 1C1 컴파일 (간단한 메서드, 모니터링 없음)
Level 2C1 과 기본 프로파일링 (메서드 호출과 반환 횟수)
Level 3C1 과 전체 프로파일링 (모든 성능 모니터링)
Level 4C2 컴파일과 프로파일 기반 깊은 최적화

대부분의 코드는 인터프리터, Level 3, Level 4 흐름을 탄다. 어떤 메서드는 Level1에서 멈출 수도 있다. JVM이 통계를 보고 판단한다.

Code Cache

컴파일된 네이티브 코드는 Code Cache에 저장된다. Java 8 이후 Code Cache는 JVM 내 별도 네이티브 메모리 영역에 위치한다(메서드 영역과 분리). Code Cache가 가득 차면 JIT 컴파일이 중단되므로 필요 시 -XX:ReservedCodeCacheSize 로 크기를 조절한다.

역최적화(Deoptimization)

JIT는 프로파일 데이터를 기반으로 낙관적 최적화를 한다. 예를 들어 이 가상 메서드 호출은 항상 Circle.draw() 였다는 가정 아래 가상 호출을 제거한다.

런타임에 다른 타입(Rectangle)이 들어오면 가정이 깨진다. JVM은 컴파일 결과를 폐기하고 인터프리터 모드로 되돌아가 다시 계층형 컴파일을 수행한다. 이걸 역최적화라고 한다. 이런 동적 적응이 JIT 가 AOT보다 정점 성능에서 유리한 이유다.

AOT(JIT 의 대안)

AOT(Ahead-Of-Time)는 런타임이 아니라 빌드 시점에 네이티브 코드를 만들어 둔다. GraalVM Native Image가 대표적이다.

여기서 네이티브 코드의 범위는 도달 가능한(reachable) 코드 전체다. Native Image는 빌드 때 정적 분석(closed-world, 도달성 분석)으로 실행 중 쓰일 수 있는 코드를 추려 네이티브로 컴파일해 하나의 실행 파일로 묶는다. 애플리케이션 코드 + 실제로 쓰는 라이브러리·JDK 클래스 + 최소 런타임(GC 등)이 포함되고, 어디서도 호출되지 않는 코드는 이미지에서 빠진다. JIT가 런타임에 핫 메서드만 골라 컴파일하는 것과 달리, AOT는 빌드 시점에 쓸 만한 코드를 미리 다 컴파일한다는 점이 핵심 차이다. 리플렉션, 동적 로딩처럼 정적 분석이 못 잡는 부분은 미리 선언(reachability metadata)해 줘야 하며, 이것이 Spring이 별도 처리를 두는 이유다.

항목JITAOT
컴파일 시점런타임 (백그라운드)빌드/배포 시점
시작 속도느림 (워밍업 필요)빠름 (즉시 네이티브 실행)
정점 성능높음 (프로파일 기반 최적화)보통 (런타임 정보 없음)
메모리높음 (Code Cache 등)낮음
클래스로더와 리플렉션자유로움제약 많음
대표 사례HotSpot JVMGraalVM Native Image

AOT는 워밍업 단점이 없어 서버리스, 짧은 수명의 CLI, 컨테이너 콜드 스타트가 중요한 워크로드에 적합하다. 다만 동적 클래스 로딩과 리플렉션 사용이 제한적이라 Spring Boot같은 프레임워크는 별도 처리(Spring AOT, GraalVM Reachability Metadata)가 필요하다.

네이티브 코드와 바이트코드

JIT, AOT 모두 결과물이 네이티브 코드인데, 이건 바이트코드와 다른 층이다. 바이트코드(.class)는 JVM이라는 가상 머신용 중간 명령어(iload·iadd·invokevirtual 등)로 JVM 만 있으면 어느 플랫폼에서든 똑같이 돈다. 네이티브 코드는 실제 CPU(x86-64·ARM64 등)가 바로 실행하는 기계어로, 플랫폼에 종속되는 대신 JVM 없이 하드웨어가 직접 실행한다.

구분바이트코드 (.class)네이티브 코드 (기계어)
대상JVM(가상 머신)실제 CPU
실행JVM 이 해석 / JIT 필요하드웨어가 직접 실행
이식성플랫폼 독립플랫폼 종속

변환 사슬로 보면 소스(.java) → (javac) → 바이트코드(.class) → (JIT 또는 AOT) → 네이티브 기계어다. javac 가 만드는 것은 바이트코드이고, JIT, AOT 는 그 바이트코드를 한 단계 더 내려 네이티브 기계어로 번역한다.

코드 예시

1. JIT 동작을 눈으로 보기

public class JitDemo {
    public static int compute(int n) {
        int sum = 0;
        for (int i = 0; i < n; i++) {
            sum += i * i;
        }
        return sum;
    }
 
    public static void main(String[] args) {
        long t0 = System.nanoTime();
        // 첫 호출. 인터프리터 또는 Level 1/3 으로 실행
        compute(100_000);
        long t1 = System.nanoTime();
 
        // 충분히 반복하면 JIT 가 Level 4 로 컴파일
        for (int i = 0; i < 10_000; i++) {
            compute(100_000);
        }
 
        long t2 = System.nanoTime();
        compute(100_000);
        long t3 = System.nanoTime();
 
        System.out.printf("첫 호출: %d ns%n", t1 - t0);
        System.out.printf("워밍업 후: %d ns%n", t3 - t2);
    }
}

실행 옵션에 -XX:+PrintCompilation 을 주면 JIT 가 어떤 메서드를 어느 계층으로 컴파일했는지 로그가 찍힌다.

java -XX:+PrintCompilation JitDemo
# 출력 예시
# 123  4   3   JitDemo::compute (24 bytes)
# 124  5   4   JitDemo::compute (24 bytes)

숫자는 컴파일 시점(ms), 컴파일 ID, 계층(3=C1+프로파일, 4=C2), 메서드 시그니처 순이다.

2. JVM 옵션으로 실행 모델 제어

# 실행 옵션 예시. JVM 시작 시 지정
java-opts:
  # JIT 임계치 (인터프리터에서 컴파일로 넘어가는 호출 횟수)
  - "-XX:CompileThreshold=1500"
  # 계층형 컴파일 비활성화 (C2 만 사용)
  - "-XX:-TieredCompilation"
  # 코드 캐시 크기
  - "-XX:ReservedCodeCacheSize=256m"
  # JIT 컴파일 로그
  - "-XX:+PrintCompilation"
  # 인라이닝 결정 로그 (디버그용)
  - "-XX:+UnlockDiagnosticVMOptions"
  - "-XX:+PrintInlining"

3. 클래스 로더 직접 사용

// 동적 클래스 로딩 예시. 플러그인 시스템 구현
public class PluginLoader {
    private final URLClassLoader classLoader;
 
    public PluginLoader(Path pluginJar) throws Exception {
        this.classLoader = new URLClassLoader(
            new URL[] { pluginJar.toUri().toURL() },
            getClass().getClassLoader()   // 부모 로더 지정
        );
    }
 
    public Plugin loadPlugin(String className) throws Exception {
        Class<?> clazz = classLoader.loadClass(className);
        return (Plugin) clazz.getDeclaredConstructor().newInstance();
    }
 
    // 플러그인 언로드 (Metaspace 해제를 위해)
    public void close() throws IOException {
        classLoader.close();
    }
}

플러그인 클래스 로더는 자체 인스턴스를 가진다. 모든 플러그인 인스턴스와 클래스 로더 자체의 참조가 끊기면, GC가 클래스로더를 회수하고 Metaspace의 클래스 정보도 함께 해제된다. 반대로 클래스 로더 누수가 발생하면 Metaspace OOM의 원인이 된다.

4. 모니터링

# JIT 컴파일 통계
jcmd <pid> Compiler.codecache
 
# 클래스 로딩 상태
jcmd <pid> VM.classloader_stats
jcmd <pid> VM.class_hierarchy
 
# Code Cache 분리 상태 (JDK 9+)
# non-nmethods: 인터프리터, JNI 어댑터
# profiled nmethods: C1 코드
# non-profiled nmethods: C2 코드
jcmd <pid> Compiler.codelist

운영에서 Metaspace OOM 이 발생하면 가장 먼저 VM.classloader_stats 로 클래스 로더 수와 로드된 클래스 수를 본다. 비정상적으로 많으면 클래스로더 누수를 의심한다.

JIT 효과와 워밍업

JIT 컴파일은 워밍업(Warm-Up) 시간을 만든다. 첫 요청은 인터프리터로 처리되어 느리고, 부하가 쌓이며 JIT가 코드를 네이티브로 바꾸면 점점 빨라진다. 이 특성은 운영에 두 가지 영향을 준다.

1. 배포 직후 응답 지연 스파이크

새 인스턴스가 트래픽을 받기 시작하면 첫 수십 초에서 수 분 동안 응답이 느리다. 로드 밸런서가 새 인스턴스에 트래픽을 점진적으로 보내는 Slow Start 또는 헬스체크가 통과한 뒤에도 일정 시간 트래픽을 적게 보내는 Warm-Up Pool 패턴이 도움이 된다.

2. 짧은 수명의 워크로드는 JIT 효과를 못 본다

람다 함수, CLI 도구처럼 짧게 실행되는 워크로드는 JIT가 충분히 컴파일하기 전에 끝난다. 이 경우 AOT(GraalVM Native Image)가 더 적합하다.