JVM이 new ArrayList()를 처음 실행할 때 일어나는 일 — 클래스 로딩과 JIT 컴파일

Java 애플리케이션을 시작하면, 모든 클래스를 한 번에 로드하지 않는다 — 처음 참조되는 순간에 로드한다(lazy loading). 그리고 실행 초기에는 인터프리터로 바이트코드를 한 줄씩 해석하지만, 특정 메서드가 충분히 "hot"해지면 JIT 컴파일러가 네이티브 기계어로 변환한다.

이 두 단계 — 클래스 로딩JIT 컴파일 — 가 JVM의 성능과 유연성을 동시에 만드는 핵심 메커니즘이다. (JVM Specification §5 - Loading) 이 글은 클래스 로딩의 계층 구조, 바이트코드 검증, JIT 컴파일(C1/C2), 그리고 프로파일링의 작동 원리를 풀어간다.

클래스 로딩 — lazy, 계층적, 위임

flowchart TD
    APP["Application ClassLoader<br/>classpath (애플리케이션 코드)"]
    APP -->|"위임"| EXT["Platform ClassLoader<br/>(Java 11+, 구 Extension)"]
    EXT -->|"위임"| BOOT["Bootstrap ClassLoader<br/>java.base 등 코어 모듈"]
ClassLoader 로드 대상 비고
Bootstrap java.base(java.lang, java.util) 등 코어 네이티브 C++ 구현, Java 객체가 아님
Platform (구 Extension) java.sql, java.xml 등 플랫폼 모듈 Java 9+ 이름 변경
Application classpath/module path (사용자 코드 + 라이브러리) ClassLoader.getSystemClassLoader()

부모 위임(Parent Delegation) 모델

클래스 로딩 요청이 들어오면, ClassLoader는 먼저 부모에게 위임한다. 부모가 로드할 수 없으면 자신이 로드한다.

// Java 25 — ClassLoader 확인
ClassLoader loader = String.class.getClassLoader();
System.out.println(loader);   // null (Bootstrap은 Java 객체가 아님)

ClassLoader appLoader = MyClass.class.getClassLoader();
System.out.println(appLoader);   // jdk.internal.loader.ClassLoaders$AppClassLoader@...
System.out.println(appLoader.getParent());   // Platform ClassLoader

부모 위임은 보안과 일관성을 보장한다 — java.lang.String을 사용자가 다시 정의해도 Bootstrap이 먼저 로드하므로 항상 JDK의 String이 사용된다. 커스텀 ClassLoader가 이 모델을 깨면 보안 위험이 발생한다.

클래스 로딩 과정

  1. Loading: .class 파일(바이트코드)을 읽어 메모리에 로드
  2. Linking:
    • Verification: 바이트코드가 JVM 명세를 준수하는지 검증 (타입 안전성, 스택 일관성)
    • Preparation: static 필드에 기본값 할당 (0, null, false)
    • Resolution: 상수 풀의 심볼릭 참조를 직접 참조로 변환 (lazy 가능)
  3. Initialization: static 초기화 블록(static {}) 실행, static 필드에 실제 값 할당

클래스는 첫 번째 활성 사용(active use) 시점에 초기화된다: new, static 필드 접근, static 메서드 호출, Class.forName(). 단, final static 상수(컴파일 타임 상수)는 초기화 없이 인라인된다.

바이트코드 검증 — 안전성의 첫 번째 관문

JVM은 로드된 바이트코드를 실행하기 전에 검증(verifier)한다: (JVM Spec §4.10)

  • 스택 오버플로우/언더플로우 없는지
  • 명령어의 피연산자 타입이 올바른지
  • private/protected 접근을 위반하지 않는지
  • return이 모든 경로에서 보장되는지

검증 덕분에 JVM은 신뢰할 수 없는 바이트코드(네트워크에서 다운로드한 applet 등 — 레거시)를 안전하게 실행할 수 있다. 잘못된 바이트코드는 VerifyError로 거부된다.

// Java 25 — 검증 실패 예 (개념적)
// 바이트코드를 조작하여 타입 안전성을 위반하면
// java.lang.VerifyError 발생

JIT 컴파일 — Hot Spot에서 네이티브 코드로

JVM은 실행 시작 시 인터프리터로 모든 바이트코드를 해석 실행한다. 메서드가 충분히 자주 실행되면 JIT(Just-In-Time) 컴파일러가 네이티브 기계어로 변환한다.

flowchart LR
    BYTE["바이트코드<br/>(인터프리터 실행)"] -->|"호출 수 증가<br/>(hot spot)"| PROF["프로파일링<br/>(C1 컴파일러)"]
    PROF -->|"더 자주 실행"| C2["C2 컴파일러<br/>(aggressive 최적화)"]
    C2 --> NATIVE["네이티브 코드<br/>(직접 실행)"]

C1 (Client) vs C2 (Server) 컴파일러

특징 C1 C2
목표 빠른 컴파일 극한 최적화
최적화 수준 기본 aggressive (인라이닝, 루프 언롤, escape analysis)
시작 속도 빠름 느림 (워밍업 필요)
적합 단기 실행, CLI 장기 실행 서버

Java 8부터 Tiered Compilation이 기본 — 처음엔 C1으로 빠르게 컴파일하고, 코드가 더 hot 해지면 C2로 재컴파일. 두 컴파일러의 장점을 결합한다.

JIT가 수행하는 최적화

최적화 설명
인라이닝(Inlining) 자주 호출되는 작은 메서드를 호출 지점에 직접 삽입 (메서드 호출 오버헤드 제거)
루프 언롤(Loop unrolling) 루프 반복을 펼쳐 분기 비용 감소
Escape analysis 객체가 메서드 밖으로 나가지 않으면 스택 할당 (GC 부하 감소)
dead code elimination 실행되지 않는 코드 제거
devirtualization virtual 메서드 호출을 직접 호출로 변환 (인터페이스가 하나의 구현체만 가질 때)

JIT 컴파일 관찰

# Java 25 — JIT 컴파일 로그
java -XX:+PrintCompilation MyApp
# 출력 예:
#   120  56 % b   MyCode::hotMethod @ 5 (32 bytes)
#   121  56       MyCode::hotMethod @ -1 (32 bytes)
# JIT 비활성화 (인터프리터 전용 — 디버깅 용도)
java -Xint MyApp    # 매우 느림

# C2만 비활성화 (C1만 사용)
java -XX:TieredStopAtLevel=1 MyApp

AOT Method Profiling (Java 25, JEP-515) — 웜업 없는 JIT

Java 25의 Project Leyden 결실: 빌드 시 프로파일링 데이터를 AOT 캐시에 저장하여, 애플리케이션 시작 즉시 JIT 컴파일이 가능하다. (JEP-515)

flowchart LR
    subgraph before["Java 24 이전 (런타임 웜업)"]
        B1["앱 시작<br/>인터프리터 실행"] --> B2["프로파일 수집<br/>(수천 호출)"] --> B3["JIT 컴파일<br/>(C1 → C2)"] --> B4["피크 성능"]
    end
    subgraph after["Java 25 AOT Profiling"]
        A1["트레이닝 실행<br/>(빌드 시)"] --> A2["프로파일 캐시<br/>(AOT)"]
        A2 --> A3["앱 시작<br/>캐시에서 즉시 JIT"] --> A4["피크 성능<br/>(웜업 없음)"]
    end
항목 기존 JIT AOT Method Profiling (Java 25)
웜업 시간 수초 ~ 수분 거의 없음
프로파일 수집 런타임 빌드 시 (트레이닝 실행)
시작 성능 인터프리터 → 점진적 JIT 시작부터 최적화된 코드
코드 변경 불필요 불필요 (애플리케이션 코드 수정 없음)

AOT Method Profiling은 JEP-483(Ahead-of-Time Class Loading & Linking, Java 24)에 이은 Project Leyden의 두 번째 단계다. (JEP-514) (AOT Command-Line Ergonomics)와 함께 사용하면 AOT 캐시 생성이 간소화된다. 서버리스, CLI 도구, 마이크로서비스에서 시작 시간이 획기적으로 단축된다.

-server vs -client — 역사적 맥락

Java 8 이전에는 -server (C2)와 -client (C1)가 별도였다. Java 8부터 Tiered Compilation이 기본이 되면서 두 모드가 통합됐다. Java 25에서는 더 이상 구분이 의미 없다 — 항상 C1 + C2 혼합 모드로 동작한다.

AOT 컴파일 — GraalVM

JIT가 런타임에 컴파일하는 반면, AOT(Ahead-Of-Time) 컴파일은 빌드 시점에 네이티브 바이너리를 생성한다:

# GraalVM native-image (외부 도구)
native-image -jar myapp.jar myapp
./myapp   # JVM 없이 실행, 시작 시간 ~50ms (vs JVM ~500ms)
항목 JIT (HotSpot) AOT (GraalVM native-image)
시작 시간 ~ 500ms ** ~ 50ms **
메모리 보통 적음
피크 성능 높음 (동적 최적화) 보통 (정적 최적화)
리플렉션 전체 지원 제한적 (설정 필요)
용도 장기 실행 서버 CLI, 서버리스, 컨테이너

GraalVM native-image는 Spring Boot AOT(Spring Boot 3.3+, GraalVM)와 결합하여 빠른 시작과 적은 메모리를 제공한다 — 서버리스/마이크로서비스 환경에서 유리. 단, 리플렉션 동적 프록시 등 런타임 최적화가 제한되므로, 장기 실행 서버에서는 여전히 JIT가 유리하다.

커스텀 ClassLoader — 언제 필요한가

대부분의 애플리케이션은 Application ClassLoader로 충분하다. 커스텀 ClassLoader가 필요한 시나리오:

시나리오 ClassLoader 용도
플러그인 시스템 런타임에 JAR 로드/언로드
핫 디플로이 서버 재시작 없이 클래스 교체 (Tomcat, JBoss)
클래스 격리 같은 라이브러리의 여러 버전 공존 (OSGi)
암호화된 클래스 복호화 후 메모리에서 로드
네트워크 로딩 원격 서버에서 바이트코드 다운로드 (applet - 레거시)
// Java 25 — 간단한 커스텀 ClassLoader
import java.nio.file.*;

class FileClassLoader extends ClassLoader {
    private final Path classDir;

    public FileClassLoader(Path classDir, ClassLoader parent) {
        super(parent);
        this.classDir = classDir;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            String fileName = name.replace('.', '/') + ".class";
            Path classFile = classDir.resolve(fileName);
            byte[] bytes = Files.readAllBytes(classFile);
            return defineClass(name, bytes, 0, bytes.length);
        } catch (Exception e) {
            throw new ClassNotFoundException(name, e);
        }
    }
}

// 사용
ClassLoader loader = new FileClassLoader(
    Path.of("/tmp/classes"), ClassLoadingDemo.class.getClassLoader());
Class<?> clazz = Class.forName("com.example.Dynamic", true, loader);
Object instance = clazz.getDeclaredConstructor().newInstance();

Tomcat의 WebappClassLoader, Spring Boot의 LaunchedURLClassLoader 등이 커스텀 ClassLoader의 실제 사례다. 각 웹 애플리케이션이 독립적인 클래스 공간을 갖도록 격리한다.

JIT 최적화 — 디옵티마이제이션(Deoptimization)

JIT가 수행한 최적화는 조건이 변경되면 취소될 수 있다:

// Java 25 — deoptimization 예
interface Animal { String sound(); }
class Dog implements Animal { public String sound() { return "멍멍"; } }
class Cat implements Animal { public String sound() { return "야옹"; } }

// 초기에는 Dog만 로드됨 → JIT가 sound()를 Dog.sound()로 인라이닝
Animal animal = new Dog();
for (int i = 0; i < 100000; i++) {
    animal.sound();   // JIT: Dog.sound()로 인라인 최적화
}

// Cat이 로드되면, JIT의 가정(Dog만 존재)이 깨짐 → deoptimization
animal = new Cat();
animal.sound();   // 인라인된 코드 폐기 → virtual dispatch로 회귀

JIT는 가정을 기반으로 최적화한다 ("이 인터페이스의 구현체는 하나다"). 가정이 깨지면(새 구현체 등장), 해당 컴파일된 코드를 폐기하고 인터프리터로 되돌린다(not-made/not-entrant). 이 과정이 deoptimization이다.

deoptimization은 성능에 일시적 영향을 준다 — 최적화된 코드가 폐기되고 재컴파일이 필요하다. 클래스 로딩 패턴이 실행 중에 자주 변하면(deoptimization 빈번), JIT 최적화의 이점이 줄어든다. 장기 실행 서버에서는 초기에 모든 클래스가 로드되어 안정화되므로, 이 문제가 덜하다.

JIT 컴파일 임계값 — 언제 컴파일이 시작되는가

JIT는 메서드의 호출 횟수가 임계값에 도달하면 컴파일을 시작한다. (HotSpot Compiler 가이드)

옵션 기본값 의미
-XX:CompileThreshold=N 10000 메서드 호출 수 임계값 (Tiered 비활성 시)
-XX:Tier4InvocationThreshold=N 5000 C2 컴파일 임계값 (Tiered 모드)
-XX:Tier3InvocationThreshold=N 200 C1 컴파일 임계값
# JIT 컴파일을 더 일찍 시작 (빠른 웜업, 더 많은 CPU 사용)
java -XX:Tier4InvocationThreshold=1000 MyApp

# JIT 컴파일 비활성화 (인터프리터 전용, 디버깅)
java -Xint MyApp

# C1만 사용 (C2 비활성화)
java -XX:TieredStopAtLevel=1 MyApp

JMH(Java Microbenchmark Harness)로 벤치마크를 수행할 때, JIT 웜업 효과를 반드시 고려해야 한다 — 첫 몇 초는 인터프리터 실행으로 느리고, JIT 컴파일 후에 피크 성능이 나온다. JMH는 기본적으로 충분한 웜업 반복을 수행한다.

실습 — 클래스 로딩과 JIT 관찰

// Java 25 — ClassLoadingDemo.java
public class ClassLoadingDemo {
    static class Lazy {
        static {
            System.out.println("Lazy 클래스 초기화됨!");
        }
        static int value = 42;
    }

    public static void main(String[] args) {
        System.out.println("main 시작");
        System.out.println("Lazy 참조 전");

        // 이 줄에서 Lazy 클래스가 로드 + 초기화됨
        System.out.println("Lazy.value = " + Lazy.value);

        System.out.println("Lazy 참조 후");
    }
}
java ClassLoadingDemo.java
main 시작
Lazy 참조 전
Lazy 클래스 초기화됨!
Lazy.value = 42
Lazy 참조 후

확인할 것: Lazy 클래스는 Lazy.value에 접근하는 순간에 초기화된다 — "Lazy 참조 전" 출력 후에 static {} 블록이 실행된다. lazy loading의 직접적 증거.

요약 — 이 글의 결론

  • 클래스 로딩은 lazy + 계층적 + 부모 위임이다. 클래스는 처음 참조될 때 로드되고, ClassLoader는 부모에게 먼저 위임한다. 이 모델이 보안과 일관성을 보장한다.
  • 바이트코드 검증이 JVM 안전성의 기반이다. 로드된 바이트코드는 타입 안전성, 스택 일관성, 접근 제어를 검증받는다 — javac가 통과한 코드만 실행된다.
  • JIT 컴파일은 hot spot을 네이티브 코드로 변환한다. C1으로 빠르게 시작하고, C2로 aggressive 최적화. 인라이닝, escape analysis, 루프 언롤이 핵심.
  • 인터프리터 + JIT의 혼합이 JVM 성능의 핵심이다. 시작 시 인터프리터로 즉시 실행, 웜업 후 JIT로 피크 성능. -Xint(인터프리터 전용)는 10 ~ 100배 느리다.
  • GraalVM AOT는 시작 시간과 메모리를 줄인다. 서버리스/컨테이너에 적합하지만, 동적 최적화(리플렉션, 동적 프록시)가 제한된다. 장기 실행 서버에서는 JIT가 여전히 유리.

생각해 볼 문제

  1. -XX:+PrintCompilation으로 hot 메서드가 JIT 컴파일되는 과정을 관찰해 보자. 어떤 메서드가 먼저 컴파일되는가?
  2. 커스텀 ClassLoader를 작성하여 부모 위임 모델을 깨면 어떤 문제가 발생하는가?
  3. JIT 인라이닝의 한계는 무엇인가? (메서드 크기 제한, -XX:MaxInlineSize)
  4. GraalVM native-image에서 Spring Boot 앱을 실행하면 시작 시간이 얼마나 줄어드는가? 리플렉션 제약은?
  5. Class.forName("com.example.MyClass")MyClass.class의 차이는? 클래스 로딩 시점이 다른가?
  6. -XX:+PrintCompilation 출력에서 % 기호가 붙은 항목은 무엇을 의미하는가? (OSR - On-Stack Replacement)

참고

'Develop Artifacts > Java' 카테고리의 다른 글

Java - 24. jvm gc  (0) 2026.07.12
Java - 23. modules  (0) 2026.07.12
Java - 22. networking  (0) 2026.07.12
Java - 21. jdbc  (0) 2026.07.12
Java - 20. virtual threads  (0) 2026.07.12