JVM — Java 코드가 실행되기까지
Class Loader → Runtime Data Area → Execution Engine 전체 흐름을 코드와 함께 깊이 파헤친다.
Java 코드는 어떻게 실행될까?
main()을 실행하는 순간 JVM 안에서는 수십 가지 일이 동시에 벌어진다.
이 글에서는 Class Loader → Runtime Data Area → Execution Engine → GC로 이어지는 JVM 실행 흐름 전체를 코드와 함께 깊이 다룬다.
GC는 분량상 별도 글에서 다룬다. 이 글에서는 구조와 기본 동작만 짚는다.
Java 실행 흐름 개요
.java 파일
└─► javac (컴파일러)
└─► 플랫폼 독립적인 .class (바이트코드)
└─► JVM
├─ Class Loader → .class를 메모리에 적재
├─ Runtime Data Area → 메모리 영역 구성
└─ Execution Engine → 바이트코드 → 기계어 → 실행
↑
GC가 Heap 관리
바이트코드는 OS에 종속되지 않는다. OS마다 구현된 JVM이 이를 받아서 해당 플랫폼의 기계어로 변환하며 실행한다. "Write Once, Run Anywhere"가 가능한 이유다.
예시 코드
아래 코드를 기준으로 JVM의 각 영역이 어떻게 동작하는지 설명한다.
public interface Animal {
String getName();
}
public class Dog implements Animal {
public static int count = 2; // static → Method Area
private String name; // instance 필드 → Heap
public Dog(String name) { this.name = name; }
@Override
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
public class Main {
public static void main(String[] args) {
int x = 7; // Stack: primitive 직접 저장
int[] numbers = {1, 2, 3}; // Stack: 참조값(0x4000) → Heap: int[]
Animal animal = new Dog("coco"); // Stack: 참조값(0x1000) → Heap: Dog①
Dog d = new Dog("changed"); // Stack: 참조값(0x2000) → Heap: Dog②
Thread thread2 = new Thread(() -> {
Dog sharedDog = (Dog) animal; // Dog①을 두 스레드가 공유 → Race Condition
int localVar = 3; // Thread 2의 Stack에만 존재
});
thread2.start();
}
}
이 코드를 실행하면 메모리는 아래처럼 구성된다.

1 Class Loader
.class 파일을 JVM 메모리에 올리는 역할을 한다. 세 단계로 동작한다.
1-1. Loading
.class 파일을 읽어 JVM 메모리에 적재한다.
1-2. Linking
Verification
바이트코드가 JVM 명세에 맞는지 검증한다. 손상된 클래스 파일이나 명세 위반 코드를 이 단계에서 차단한다.
Preparationstatic 변수에 타입별 기본값을 할당한다. (int → 0, boolean → false, 참조형 → null)
Resolution
클래스 파일 안의 심볼릭 참조(#1, #2 …)를 실제 메모리 주소로 치환한다. 이 심볼릭 참조들은 Method Area의 Runtime Constant Pool에 저장되어 있다가 이 단계에서 실제 참조로 교체된다.
Runtime Constant Pool이란?
클래스 파일 안에는"coco"같은 상수, 메서드 시그니처, 필드 이름 등이#1,#3같은 심볼릭 인덱스로 저장되어 있다. Class Loader가 클래스를 메모리에 올리면 이 정보들이 Method Area의 Runtime Constant Pool에 적재되고, Resolution 단계에서 실제 메모리 주소로 교체된다.
1-3. Initialization
static 변수에 코드에서 정의한 실제 값을 할당하고, static 블록을 실행한다.
public static int count = 2;
// Preparation: count = 0 (기본값)
// Initialization: count = 2 (실제 값)
Lazy Loading: Class Loader는 필요한 시점에만 클래스를 로드한다. 처음 실행 시 모든 클래스를 올리지 않는다. 이 덕분에 애플리케이션 시작 비용이 줄어든다.
Class Loader 계층 구조
Bootstrap Class Loader (C++로 구현, JVM 내장)
└─ java.lang.String, Object 등 핵심 클래스
Platform Class Loader (Java 9 이전: Extension Class Loader)
└─ Java 확장 라이브러리 (java.sql, java.xml 등)
Application Class Loader
└─ 내가 만든 클래스 + kafka, redis 등 외부 라이브러리
Java 9 변경사항: Java 9에서 모듈 시스템(JPMS)이 도입되면서Extension ClassLoader가Platform ClassLoader로 이름이 바뀌고 역할도 재정의됐다. Java 8 이하 자료를 볼 때 참고하자.
부모 위임 모델 (Parent Delegation Model)
클래스 로드 요청이 오면 자식 ClassLoader가 직접 처리하기 전에 부모에게 먼저 위임한다. 부모가 처리하지 못할 때만 자식이 처리한다.
클래스 로드 요청 (Dog.class)
→ Application ClassLoader: "내가 먼저 로드할게"
→ Platform ClassLoader에 위임
→ Bootstrap ClassLoader에 위임
→ "Dog는 모름" → 반환
→ "Dog는 모름" → 반환
→ Application ClassLoader가 직접 로드
이 구조 덕분에 java.lang.String처럼 핵심 클래스를 외부에서 악의적으로 덮어쓰는 것을 방지할 수 있다.
2 Runtime Data Area
JVM이 OS로부터 할당받아 사용하는 메모리 공간이다. 스레드 공유 여부에 따라 두 가지로 나뉜다.
스레드 비공유 영역 (Thread-private)
각 스레드가 독립적으로 보유하는 영역이다.
PC Register
현재 스레드가 실행 중인 바이트코드의 위치(주소)를 저장한다. 스레드 스케줄링으로 CPU를 뺏겼다가 다시 받았을 때 어디서부터 재개할지 추적하는 역할이다. Native Method 실행 중에는 undefined 상태가 된다.
JVM Stack
메서드 호출마다 Stack Frame이 하나씩 쌓이고, 메서드가 종료되면 pop된다. GC 없이 자동 해제된다.
Stack Frame 내부 구조:
- Local Variable Array: 지역 변수, 파라미터
- Operand Stack: 연산 중간 값 (바이트코드 명령어들이 이 스택을 읽고 씀)
- Frame Data: 메서드 반환 주소, 상수 풀 참조
main() Stack Frame
├─ x = 7 (int, primitive → 값 직접 저장)
├─ numbers = 0x4000 (int[], 참조값만 저장 → 실제 배열은 Heap)
├─ animal = 0x1000 (Dog①의 Heap 주소)
└─ d = 0x2000 (Dog②의 Heap 주소)
StackOverflowError: 재귀 호출이 끝없이 쌓이면 Stack 공간이 초과된다. -Xss 옵션으로 스택 크기를 조절할 수 있다.
Native Method Stack
C/C++로 작성된 Native Method 실행 전용 스택이다. JNI(Java Native Interface)와 함께 동작한다.
스레드 공유 영역 (Thread-shared)
모든 스레드가 접근 가능한 영역이다.
Heap
new 키워드로 생성된 객체 인스턴스가 저장된다. 모든 스레드가 공유하기 때문에 멀티스레드 환경에서 Race Condition이 발생할 수 있다. GC의 주요 대상이다.
Heap
├── Young Generation
│ ├── Eden ← new 로 생성된 객체가 처음 올라오는 곳
│ └── Survivor (S0, S1)
└── Old Generation ← 오래 살아남은 객체가 이동하는 곳
String Constant Pool
Heap 안에 존재하는 문자열 리터럴 전용 공간이다.
String a = "hello"; // Pool에 "hello" 생성 후 참조
String b = "hello"; // Pool에 이미 있으므로 재사용 → a == b: true
String c = new String("hello"); // new 키워드 → Pool 아닌 Heap에 별도 객체 생성
// a == c: false, a.equals(c): true
왜 Java 7부터 Pool이 Heap으로 이동했나?
이전까지는 Pool이 PermGen(Permanent Generation) 영역에 있었다. PermGen은 크기가 고정되어 있어 문자열이 많이 쌓이면OutOfMemoryError가 발생하는 문제가 있었다. Java 7부터 Pool을 Heap으로 옮겨 GC 대상이 되도록 해 이 문제를 해결했다.
intern() 메서드를 사용하면 Heap에 생성된 String 객체를 Pool로 이동시키거나, 이미 Pool에 동일한 값이 있으면 그것을 재사용할 수 있다.
Method Area
클래스 자체의 메타데이터가 올라오는 곳이다.
저장되는 정보:
- 클래스 이름, 부모 클래스, 인터페이스 정보
static변수 (예:Dog.count = 2)- 메서드 바이트코드
- Runtime Constant Pool (심볼릭 참조 → Resolution 후 실제 주소로 교체)
Java 8: PermGen → Metaspace
Java 8 이전에는 Method Area가 Heap 내부의 PermGen으로 구현되어 있었다. 크기가 고정되어 있어 클래스를 많이 로드하면java.lang.OutOfMemoryError: PermGen space가 발생했다. Java 8부터 Metaspace로 전환됐다. Metaspace는 JVM Heap이 아닌 Native Memory(OS가 관리하는 메모리)를 직접 사용하기 때문에 크기가 동적으로 조절된다.-XX:MaxMetaspaceSize로 상한을 설정할 수 있다.
3 Execution Engine
Class Loader가 올리고, Runtime Data Area가 저장하면, Execution Engine이 실행한다. 세 가지 컴포넌트로 구성된다.
Interpreter
바이트코드를 한 줄씩 읽어 기계어로 변환하며 실행한다. 구현이 단순하고 즉시 실행이 가능하다.
단점: 같은 코드를 반복 실행해도 매번 새로 해석한다. 자주 호출되는 코드에서는 병목이 생긴다.
JIT Compiler (Just-In-Time)
Interpreter의 반복 해석 문제를 보완하기 위해 도입됐다. JVM이 실행 중에 반복 호출되는 코드(핫스팟)를 감지하면, 해당 바이트코드를 통째로 네이티브 코드로 컴파일해 캐싱한다. 이후 동일한 코드 실행 시 컴파일 없이 캐시된 네이티브 코드를 바로 실행한다.
처음 실행 → Interpreter가 한 줄씩 해석
반복 감지 → JIT가 네이티브 코드로 컴파일 + 캐싱
이후 실행 → 캐싱된 네이티브 코드 바로 실행 (빠름)
왜 처음부터 JIT를 안 쓰나? 한 번만 실행되는 코드까지 전부 컴파일하면 컴파일 비용 자체가 오히려 더 크다. Interpreter로 시작해서 자주 쓰이는 코드만 JIT가 가져가는 구조가 효율적이다.
Tiered Compilation — C1 / C2
Java 7부터 기본값. JIT를 두 단계로 나눠 점진적으로 최적화를 적용한다.
호출 횟수 컴파일러
0회 → Interpreter
~2,000회 → C1 (가벼운 최적화)
~10,000회 → C2 (공격적인 최적화)
위 수치는 JVM 기본값이다. -XX:CompileThreshold 옵션으로 직접 조절할 수 있다. JVM 버전이나 환경에 따라 실제 임계값은 다를 수 있다.
C1 (Client Compiler)
빠르게 컴파일하고 가벼운 최적화를 수행한다. 인라이닝, 기본 최적화가 주다. 목적은 컴파일 지연 최소화다.
C2 (Server Compiler)
컴파일이 느리지만 공격적인 최적화를 수행한다. 루프 최적화, 탈출 분석(Escape Analysis), 인라이닝 등을 적극 적용한다. 목적은 장기 실행 성능 극대화다.
C2 주요 최적화
① 인라이닝 (Inlining)
메서드 호출을 없애고 호출부에 코드를 직접 삽입한다.
// 최적화 전
int result = add(a, b);
int add(int x, int y) { return x + y; }
// C2 인라이닝 후
int result = a + b; // Stack Frame 생성/반환 오버헤드 제거
메서드 호출은 Stack Frame 생성 → 인자 복사 → 반환 주소 저장 등의 비용이 있다. 짧은 메서드가 루프 안에서 반복 호출될수록 인라이닝 효과가 크다.
② 루프 최적화 (Loop Optimization)
반복문 내부의 불필요한 연산을 제거하거나 재구성한다. 대표적인 예가 루프 불변 코드 이동 (Loop Invariant Code Motion)이다.
// 최적화 전: list.size()를 매 반복마다 호출
for (int i = 0; i < list.size(); i++) {
process(list.get(i));
}
// C2 최적화 후: 루프 불변 값을 밖으로 꺼냄
int size = list.size();
for (int i = 0; i < size; i++) {
process(list.get(i));
}
이 외에도 루프 언롤링(Loop Unrolling)(반복 횟수를 줄이고 본문을 펼침), 벡터화(SIMD 명령어 활용) 등도 수행한다.
③ 탈출 분석 (Escape Analysis)
객체가 생성된 메서드 밖으로 "탈출"하는지 분석한다. 탈출하지 않는 객체는 Heap 대신 Stack에 할당한다.
// 탈출하는 경우 → Heap 할당
Dog createDog() {
Dog d = new Dog("coco");
return d; // 메서드 밖으로 나감 → Heap
}
// 탈출하지 않는 경우 → Stack 할당 가능
void process() {
Dog d = new Dog("coco");
System.out.println(d.getName());
// d가 이 메서드 안에서만 쓰임 → C2가 Stack에 할당
} // 메서드 종료 시 자동 해제 → GC 부담 없음
Heap 할당은 GC 대상이 되어 Stop-the-World를 유발할 수 있다. Stack 할당은 메서드 종료 시 자동 해제되어 GC를 완전히 우회한다.
④ 락 제거 (Lock Elision)
탈출 분석의 연장선이다. 객체가 단일 스레드 안에서만 사용된다고 분석되면, 해당 객체의 synchronized 블록 자체를 제거한다.
void method() {
StringBuffer sb = new StringBuffer(); // synchronized 메서드를 가짐
sb.append("hello");
sb.append(" world");
System.out.println(sb.toString());
// sb가 이 메서드 밖으로 나가지 않음
// → C2가 synchronized 자체를 제거
}
StringBuffer는 메서드마다 synchronized가 걸려 있지만, 로컬에서만 쓰이는 경우 락 비용 없이 실행된다.
OSR (On-Stack Replacement)
JIT 설명에서 빠지기 쉬운 개념이다. 일반적으로 JIT는 메서드 단위로 컴파일하는데, 긴 루프가 있으면 문제가 생긴다. 루프가 끝나야 다음 메서드 호출이 발생하고, 그때서야 JIT가 적용되기 때문이다.
OSR은 이미 실행 중인 루프를 중간에 JIT 컴파일된 코드로 교체하는 기법이다. Interpreter로 루프를 돌다가 핫스팟으로 감지되는 순간, 루프가 끝날 때까지 기다리지 않고 즉시 최적화된 네이티브 코드로 전환한다.
[루프 실행 중, Interpreter]
i=0 → i=1 → ... → i=4999 ← 핫스팟 감지
← JIT 컴파일 완료
i=5000 → 이 시점부터 네이티브 코드로 교체(OSR)
i=5001 → i=5002 → ... [이후 JIT 속도로 실행]
Deoptimization
C2가 공격적 최적화를 수행했는데 이후에 그 가정이 틀렸음이 확인되면, JVM은 최적화를 취소하고 Interpreter로 되돌아간다.
void process() {
Dog d = new Dog("coco");
// C2 판단: "d는 탈출하지 않는다" → Stack 할당으로 최적화
// 이후 리플렉션 등으로 d가 외부에 노출되는 경우가 생기면?
// → Deoptimization: 최적화 취소 → Interpreter로 복귀
}
Deoptimization은 JVM이 안전하게 동작을 보장하기 위한 메커니즘이다. -XX:+PrintCompilation 옵션을 켜면 JIT 컴파일과 Deoptimization 과정을 로그로 확인할 수 있다.
4 GC (Garbage Collector)
Heap에서 더 이상 참조되지 않는 객체를 자동으로 제거한다. Java는 개발자가 직접 메모리를 해제하지 않아도 된다.
1. 객체 생성 → Eden에 할당
2. Eden이 꽉 참 → Minor GC 발생
3. 살아남은 객체 → Survivor로 이동 (age 증가)
4. age 임계값 초과 → Old Generation으로 이동 (Promotion)
5. Old Gen이 꽉 참 → Major GC (Full GC) 발생
GC 알고리즘(G1, ZGC 등), Stop-the-World, Card Table, Write Barrier 등 세부 동작은 다음 글에서 다룬다.
정리
| 컴포넌트 | 역할 |
|---|---|
| Class Loader | .class 파일을 Loading → Linking → Initialization 순으로 메모리에 적재 |
| Runtime Data Area | Thread-private(PC, Stack, Native Stack) + Thread-shared(Heap, String Pool, Method Area) |
| Execution Engine | Interpreter로 시작, 핫스팟 감지 시 JIT(C1→C2)로 최적화. OSR, Deopt, Escape Analysis 등 수행 |
| GC | Heap의 unreachable 객체를 자동 제거 |
JVM은 단순한 실행 환경이 아니다. Class Loader가 보안을 담당하고, Runtime Data Area가 메모리를 구조화하고, Execution Engine이 런타임에 코드를 분석해 최적화하는, 정교하게 설계된 시스템이다.
'Java' 카테고리의 다른 글
| GC 동작 방식 및 원리 (1) (1) | 2026.06.02 |
|---|---|
| Json과 Object의 차이, Java에서 Json객체 사용법 (1) | 2024.12.09 |