자바 가상 머신이 어떻게 코드를 실행하고 메모리를 관리하는지
JVM(Java Virtual Machine)은 자바 바이트코드를 실행하는 가상의 컴퓨터입니다. OS와 하드웨어 위에서 동작하며, 자바 코드가 어디서든 똑같이 실행되도록 하는 역할을 합니다.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ User.java │ ──► │ User.class │ ──► │ JVM │
│ (소스코드) │ javac │ (바이트코드) │ │ (해석/실행) │
└─────────────┘ └─────────────┘ └──────┬──────┘
│
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
Linux JVM Windows JVM Mac JVM
│ │ │
▼ ▼ ▼
리눅스 기계어 윈도우 기계어 맥 기계어
자바 소스코드는 javac가 바이트코드(.class)로 컴파일합니다. 이 바이트코드는 OS와 CPU에 독립적이며, JVM만 설치되어 있으면 어디서든 실행됩니다.
JVM은 크게 세 가지 핵심 구성 요소로 이루어져 있습니다.
.class 파일을 읽어 메모리에 올림클래스 로더는 ".class 파일을 JVM 메모리에 적재하는 담당자" 입니다. 단순히 파일을 읽는 게 아니라 세 단계를 거칩니다.
파일 시스템·네트워크·JAR 파일에서 .class 파일을 찾아 바이트 배열로 읽어 메모리에 올립니다.
바이트코드를 검증(Verify)하고, 클래스 변수의 메모리 공간을 준비(Prepare)하며, 참조된 다른 클래스를 해석(Resolve)합니다.
static 블록 실행, static 필드에 초기값을 넣습니다. 이 시점부터 클래스 사용 준비 완료.
JVM은 여러 종류의 클래스 로더를 계층적으로 운영합니다. 부모에게 먼저 "이 클래스 로드해줄래?" 라고 묻고, 부모가 못 찾으면 자기가 로드합니다. (위임 모델)
Bootstrap ClassLoader ← 최상위. java.lang.* 같은 JDK 핵심 클래스
│
Platform ClassLoader ← JDK 확장 모듈 (과거 Extension)
│
Application ClassLoader ← 우리가 짠 애플리케이션 클래스, CLASSPATH
│
Custom ClassLoader ← 사용자 정의 (Tomcat, Spring 등이 커스텀 로더 사용)
JVM이 실행 중 사용하는 메모리는 "모든 스레드가 공유하는 영역"과 "스레드마다 따로 가지는 영역"으로 나뉩니다.
객체와 배열이 저장되는 곳. new로 만든 모든 것이 여기에. GC의 주 대상. JVM에서 가장 큰 메모리 영역.
클래스 정의·메서드·상수가 저장됩니다. 객체의 "설계도" 보관소. JDK 8부터 네이티브 메모리로 이동.
메서드 호출 시 지역변수·매개변수·호출 정보를 담은 스택 프레임이 쌓이는 곳. 메서드가 끝나면 즉시 제거됨.
현재 실행 중인 바이트코드의 위치(주소)를 저장. 스레드마다 독립적.
자바가 아닌 C/C++로 작성된 네이티브 메서드 호출 시 사용하는 별도 스택.
JIT 컴파일러가 바이트코드를 네이티브 기계어로 변환한 결과를 캐시.
Heap은 그냥 하나의 덩어리가 아니라 "세대(Generation)" 라는 영역으로 나뉩니다.
| 영역 | 역할 |
|---|---|
| Eden | 새로 생성된 객체가 처음 놓이는 곳. 대부분 여기서 짧게 살다 사라짐. |
| Survivor 0/1 | Eden에서 살아남은 객체가 이동하는 대기실. S0 ↔ S1 왔다갔다. |
| Old (Tenured) | 여러 번 GC에서 살아남은 "장수 객체". 캐시, 싱글톤 등이 여기로. |
간단한 코드 하나가 JVM에서 실행되는 과정을 따라가봅시다.
public class Hello {
public static void main(String[] args) {
String msg = "Hello, JVM!";
System.out.println(msg);
}
}
javac Hello.java → Hello.class 바이트코드 생성
java Hello 실행 → JVM 프로세스 생성 → OS로부터 메모리 할당받음
ClassLoader가 Hello.class와 의존 클래스(String, System 등)를 Method Area에 로딩
JVM이 main 스레드를 만들고 전용 Stack과 PC Register를 할당
Stack에 main 메서드 프레임 push, PC Register가 첫 바이트코드 위치를 가리킴
"Hello, JVM!" 문자열이 Heap의 String Pool에 생성, 참조가 Stack의 msg 변수에 저장
Execution Engine이 한 명령씩 읽어 인터프리터로 실행. System.out.println 호출이 Native Method로 전달되어 화면에 출력
main 메서드 반환 → Stack 프레임 pop → 모든 사용자 스레드 종료 → JVM 종료 → OS가 메모리 회수
JVM은 "인터프리터 + JIT 컴파일러" 하이브리드 방식으로 동작합니다. 이게 JVM이 Python보다 훨씬 빠른 이유입니다.
| 방식 | 장점 | 단점 |
|---|---|---|
| 인터프리터 | 시작이 빠름 | 실행이 느림 (매번 해석) |
| AOT 컴파일 (C/C++) | 실행이 빠름 | 컴파일 시간 길고 플랫폼 종속 |
| JIT (JVM) | 빠른 시작 + 실행 속도 점점 상승 | 초기 메모리 사용 |
JVM은 실행 중에 "어떤 메서드가 자주 호출되는지" 를 모니터링합니다. 일정 횟수 이상 호출되면 "핫(Hot)"으로 판단하고, 그 메서드를 네이티브 기계어로 컴파일해 Code Cache에 저장합니다.
실행 초기 (느림)
├─ getUserName() 1번째 호출 → 인터프리터 실행 (100ms)
├─ getUserName() 2번째 호출 → 인터프리터 실행 (100ms)
├─ ... (계속 호출)
└─ getUserName() 10000번째 호출
↓
JIT가 "핫 메서드"로 판단 → 기계어로 컴파일 → Code Cache에 저장
↓
실행 중반 이후 (빠름)
├─ getUserName() 10001번째 호출 → 네이티브 실행 (2ms)
└─ getUserName() 10002번째 호출 → 네이티브 실행 (2ms)
자바의 가장 큰 특징 중 하나. 더 이상 사용되지 않는 객체를 JVM이 자동으로 정리합니다.
GC는 "GC Root" 에서 시작해 참조를 따라가며 도달 가능한 객체만 살리고, 나머지는 쓰레기로 판단해 제거합니다.
GC Root (Stack 변수, 정적 필드, JNI 참조 등)
│
├─► 객체 A ─► 객체 B ─► 객체 C ✅ 도달 가능 (살림)
│
└─► 객체 D ✅ 도달 가능 (살림)
객체 E ─► 객체 F ❌ 어디서도 참조 X (수거)
| 구분 | Minor GC | Major GC (Full GC) |
|---|---|---|
| 대상 | Young 영역 (Eden + Survivor) | Old 영역 (전체 Heap) |
| 속도 | 빠름 (수 ms) | 느림 (수백 ms~초) |
| 빈도 | 자주 발생 | 드물게 발생 |
| 영향 | 거의 없음 | 애플리케이션 일시 정지 (STW) |
| GC | 특징 | 적합한 환경 |
|---|---|---|
| Serial GC | 단일 스레드, 단순함 | 작은 앱, CLI |
| Parallel GC | 멀티스레드, 처리량 중심 | 배치 작업 |
| G1 GC | Heap을 region으로 분할, 예측 가능한 pause | 일반적인 서버 (JDK 9+ 기본) |
| ZGC | 10ms 미만 초저지연, 수 TB Heap 지원 | 대용량 실시간 서비스 |
| Shenandoah | 동시 압축, 저지연 | 응답 속도 중요한 서비스 |
도커 컨테이너에 자바 서비스를 띄울 때 꼭 알아야 할 설정들.
전체 프로세스 메모리 (RSS)
├─ Heap : -Xmx로 설정 (예: 1.5GB)
├─ Metaspace : 클래스 메타 (~200MB)
├─ Thread Stack : 스레드×1MB (~200MB)
├─ Code Cache : JIT 결과 (~150MB)
├─ Direct Buffer : NIO 버퍼 (~100MB)
└─ GC / 기타 : ~100MB
─────────
합계 ~2.2GB
-Xmx2g를 주면 Non-Heap 영역 때문에 실제 사용량이 2GB를 초과해 OOMKilled로 컨테이너가 강제 종료됩니다. Heap은 전체의 70~75% 정도로 잡아야 안전합니다.
java \
-XX:InitialRAMPercentage=50.0 \
-XX:MaxRAMPercentage=75.0 \
-XX:MaxMetaspaceSize=256m \
-XX:ReservedCodeCacheSize=128m \
-Xss512k \
-XX:+UseG1GC \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/heap-dump.hprof \
-jar app.jar
| 옵션 | 의미 |
|---|---|
MaxRAMPercentage=75 |
컨테이너 메모리의 75%를 Heap으로 (1.5GB) |
MaxMetaspaceSize=256m |
Metaspace 무한 증가 방지 |
Xss512k |
스레드 스택 1MB → 512KB로 절약 |
UseG1GC |
G1 가비지 컬렉터 사용 (낮은 pause) |
HeapDumpOnOutOfMemoryError |
OOM 발생 시 힙덤프 자동 저장 → 사후 분석 |
# 실시간 메모리 사용량
jstat -gc <pid> 1000
# 스레드 덤프 (데드락, 느린 요청 분석)
jstack <pid> > thread-dump.txt
# 힙 덤프 (메모리 누수 분석)
jmap -dump:live,format=b,file=heap.hprof <pid>
# 네이티브 메모리 추적 (Non-Heap 영역 상세)
jcmd <pid> VM.native_memory summary
# GC 로그 활성화 (기동 옵션)
-Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=10M
micrometer-registry-prometheus 의존성만 추가하면 Heap, GC, 스레드, Metaspace 등 거의 모든 지표가 자동 노출됩니다.
자바 바이트코드를 받아, 클래스 로더로 메모리에 올리고, 인터프리터+JIT로 실행하며, GC로 메모리를 자동 관리하는 가상 머신