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가 이 모델을 깨면 보안 위험이 발생한다.
클래스 로딩 과정
- Loading:
.class파일(바이트코드)을 읽어 메모리에 로드 - Linking:
- Verification: 바이트코드가 JVM 명세를 준수하는지 검증 (타입 안전성, 스택 일관성)
- Preparation: static 필드에 기본값 할당 (
0,null,false) - Resolution: 상수 풀의 심볼릭 참조를 직접 참조로 변환 (lazy 가능)
- 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가 여전히 유리.
생각해 볼 문제
-XX:+PrintCompilation으로 hot 메서드가 JIT 컴파일되는 과정을 관찰해 보자. 어떤 메서드가 먼저 컴파일되는가?- 커스텀 ClassLoader를 작성하여 부모 위임 모델을 깨면 어떤 문제가 발생하는가?
- JIT 인라이닝의 한계는 무엇인가? (메서드 크기 제한,
-XX:MaxInlineSize) - GraalVM native-image에서 Spring Boot 앱을 실행하면 시작 시간이 얼마나 줄어드는가? 리플렉션 제약은?
Class.forName("com.example.MyClass")와MyClass.class의 차이는? 클래스 로딩 시점이 다른가?-XX:+PrintCompilation출력에서%기호가 붙은 항목은 무엇을 의미하는가? (OSR - On-Stack Replacement)
참고
- JVM Spec §5 - Loading, Linking, and Initializing - 접근 2026-07-10
- JVM Spec §4.10 - Verification - 접근 2026-07-10
- ClassLoader API - 접근 2026-07-10
- HotSpot JIT 튜닝 가이드 - 접근 2026-07-10
- GraalVM Native Image - 접근 2026-07-10
- JEP-515: Ahead-of-Time Method Profiling - 접근 2026-07-10
- JEP-519: Compact Object Headers - 접근 2026-07-10
'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 |
