JVM은 메모리를 어떻게 나누는가
Heap의 세대별 구조부터 TLAB, 스택 프레임, Metaspace, Runtime Constant Pool, 객체 레이아웃, Off-Heap까지 JVM 메모리 모델 전체를 하나의 설계 철학으로 추적한다.
- 01 JVM 클래스로더는 어떻게 JVM을 지탱하는가
- 02 JVM은 메모리를 어떻게 나누는가
- 03 JVM 바이트코드는 어떻게 플랫폼을 초월하는가
- 04 JVM은 바이트코드를 어떻게 실행하는가
- 05 JVM GC는 어떻게 살아있는 객체를 판단하는가
- 06 Java Memory Model은 무엇을 추상화하는가
- 07 JVM 동기화는 어떻게 작동하는가
- 08 JVM 튜닝의 철학 — 덜 건드릴수록 더 잘 돌아간다
- 09 JVM은 객체를 어떻게 들여다보는가
JVM의 메모리 구조는 영역마다 이름이 다르고 플래그도 제각각이다. 그런데 각 영역을 따로 외우려 하면 놓치는 게 있다 — 이 모든 구조는 단 하나의 원칙에서 파생된다. “언제 죽는지 안다면, 언제 정리할지도 안다.” 왜 JVM은 7개의 메모리 영역을 만들었는가?
세대별 힙 — “대부분의 객체는 젊어서 죽는다”
전통적인 단일 메모리 풀 설계의 문제는 GC 비용이 힙 크기에 선형 비례한다는 것이다. 4GB 힙이면 모든 GC가 4GB를 스캔한다. JVM은 이 문제를 Weak Generational Hypothesis로 돌파한다: 생성된 객체의 90% 이상이 첫 GC에서 사라진다. 그러므로 젊은 영역만 자주 정리하면 된다.
결과가 Young/Old 분리 구조다.
┌──────────────────────────────────────────────────────────┐
│ Heap │
├───────────────────────────────┬──────────────────────────┤
│ Young Generation │ Old Generation │
├───────┬───────────┬───────────┤ │
│ Eden │ Survivor0 │ Survivor1 │ (Full GC 대상) │
│ (80%) │ (10%) │ (10%) │ │
└───────┴───────────┴───────────┴──────────────────────────┘
객체는 Eden에서 태어나고, Young GC 때마다 살아남으면 Survivor를 교대로 오가며 Age가 오른다. Age가 임계값(기본 15)에 달하거나 Survivor 공간이 부족하면 Old로 승격된다. Survivor가 두 개인 이유는 Copy 알고리즘 때문이다 — 한쪽이 항상 비어 있어야 살아있는 객체를 복사할 공간이 생긴다. 실질적으로 사용 가능한 Young 공간은 Eden + Survivor 1개 = 90%다.
Young GC는 수 밀리초, Full GC는 수백 밀리초 이상이다. 빈도 차이가 크기 때문에 Young GC 효율이 전체 처리량을 결정한다.
Java 9+ 기본 GC인 G1은 물리적 Young/Old 경계를 없앤다. 전체 힙을 1MB~32MB 크기의 Region으로 쪼개고, 각 Region이 동적으로 Eden/Survivor/Old 역할을 맡는다. -XX:MaxGCPauseMillis로 STW 목표를 설정할 수 있다 — 영역 단위로 GC하기 때문에 전체 힙을 한 번에 정리할 필요가 없다.
TLAB — 할당 경로에서 락을 없애다
멀티스레드 환경에서 모든 스레드가 Eden에 동시에 할당하면 동기화 문제가 생긴다. Eden 전체에 락을 걸면 해결되지만, 10배 이상 느려진다. JVM의 답은 **Thread-Local Allocation Buffer(TLAB)**다 — 스레드마다 Eden의 작은 구역을 독점적으로 예약해준다.
TLAB 내 할당은 포인터 하나를 이동하는 것뿐이다(Bump-the-pointer). 락이 없다. 대략 세 개의 CPU 명령으로 끝난다.
Thread 1의 TLAB (예: 128KB):
┌──────────────────────────────┐
│ [ObjA][ObjB] │ ← top 포인터
│ end →│
└──────────────────────────────┘
new MyObject() → top += size, 완료.
TLAB가 가득 차면 새 TLAB를 Eden에서 받아야 하는데, 이때만 락이 필요하다(Slow Path). 전체 할당의 99%는 Lock 없이 처리된다.
객체가 TLAB보다 크면 TLAB를 우회해 Eden 공유 영역에 직접 할당한다. 락이 필요하고 경우에 따라 Old에 직행하기도 한다. 대용량 객체를 자주 생성하면 느려지는 이유가 여기 있다.
스택 프레임 — 메서드 실행의 스냅샷
힙이 객체 수명을 관리한다면, 스택은 메서드 실행 상태를 관리한다. 스레드마다 독립적인 JVM 스택이 있고, 메서드 호출마다 Frame이 Push된다.
하나의 Frame 안에는 세 가지가 들어간다. Local Variable Array(LVA) — 매개변수와 지역 변수. 인스턴스 메서드는 인덱스 0이 this다. Operand Stack — 바이트코드 실행의 작업 공간. iadd는 두 값을 pop해서 더한 결과를 push한다. Frame Data — Return Address와 Exception Handler 정보.
StackOverflowError는 Frame이 누적되어 스택 크기(-Xss, 기본 1MB)를 초과할 때 발생한다. Frame 1개가 100바이트라면 1MB 스택에서 재귀 깊이는 약 10,000이다. Java 21의 Virtual Thread는 초기 스택을 수 KB만 쓰고 동적으로 확장해서 100만 개의 동시 Virtual Thread를 수 GB로 감당한다.
Metaspace — PermGen의 실패에서 배운 것
클래스 메타데이터(구조, 바이트코드, Static 변수 참조, Runtime Constant Pool)는 인스턴스와 수명이 다르다. Java 7까지는 이걸 PermGen이라는 고정 크기 Heap 영역에 넣었다. Tomcat 웹앱을 100번 재배포하면 ClassLoader 누수가 쌓여 PermGen이 가득 차고 서버를 재시작해야 했다.
Java 8이 이 구조를 해체했다. Metaspace는 Heap 밖 네이티브 메모리를 사용한다. 기본적으로 크기 제한이 없으므로 OOM 빈도가 줄었고, Heap과 분리되어 Full GC 때 Metaspace를 스캔하지 않아도 된다.
그러나 컨테이너 환경에서 -XX:MaxMetaspaceSize를 설정하지 않으면 네이티브 메모리를 무제한 소비하다가 컨테이너 OOM Killer에 걸릴 수 있다. 총 메모리 예산을 반드시 계산해야 한다.
컨테이너 4GB 예시:
Heap : 2.5GB (-Xms2.5g -Xmx2.5g)
Metaspace : 256MB (-XX:MaxMetaspaceSize=256m)
Thread Stack : 200MB (200 threads × 1MB)
Code Cache + 기타 : ~500MB
─────────────────────────────
합계 ≈ 3.5GB (500MB 여유)
객체 레이아웃과 String Pool
객체가 Heap에 올라갈 때 실제 크기는 필드 합보다 크다. Object Header가 12바이트(압축 포인터 활성화 시)를 먼저 차지한다. Mark Word 8바이트에 Hash Code, GC Age(0~15), Lock 상태가 비트 필드로 압축되어 들어간다. Class Pointer 4바이트가 Klass 메타데이터를 가리킨다. 이후 Instance Data가 오는데, JVM은 선언 순서 무시하고 long/double → int/float → short/char → byte/boolean → reference 순으로 재배치해 패딩을 최소화한다. 마지막으로 8바이트 정렬을 위한 Padding이 붙는다.
빈 클래스(new Object())도 최소 16바이트다. 힙 크기가 32GB 미만이면 Compressed Oops가 활성화되어 참조 필드가 4바이트로 줄어든다. 32GB를 넘는 순간 8바이트로 늘어나 전체 메모리 사용량이 약 30% 증가한다 — 힙을 32GB 미만으로 유지해야 하는 핵심 이유다.
Runtime Constant Pool은 각 클래스가 독립적으로 가지는 영역으로, 클래스 파일의 심볼릭 참조를 런타임 직접 참조로 Resolution한다. 첫 호출 시 Resolution이 일어나고 이후는 직접 참조를 쓴다 — JVM “Warm-up”의 정체다. 문자열 리터럴은 Java 7부터 Heap의 String Pool로 이동했다. GC로 관리되므로 사용하지 않는 리터럴도 수거될 수 있다.
Off-Heap — GC 바깥에서 다루어야 할 것들
대용량 파일을 읽어 네트워크로 보낼 때 Heap Buffer를 쓰면 커널 버퍼 → Heap 복사 → 소켓 버퍼 복사, 총 두 번의 복사가 일어난다. ByteBuffer.allocateDirect()는 네이티브 메모리에 직접 버퍼를 잡아 한 번의 DMA로 끝낸다. 대용량 I/O에서 2~3배 빠른 이유다.
DirectByteBuffer 객체 자체는 Heap에 있어 GC 대상이지만, 객체 크기가 작아서 GC 트리거가 늦다. GC가 늦어지면 네이티브 메모리가 누수처럼 계속 증가한다. Netty의 PooledByteBufAllocator처럼 버퍼를 풀링해 재사용하거나, 사용 후 명시적으로 Cleaner를 호출해야 한다.
-XX:MaxDirectMemorySize를 설정하지 않으면 기본값이 -Xmx와 같아서 Heap + Direct가 서버 물리 메모리를 초과할 수 있다. 컨테이너 환경에서는 반드시 명시한다.
정리
- 세대별 힙은 “대부분의 객체는 젊어서 죽는다”는 관찰에서 나왔다. Young GC가 빠른 이유는 Copy 알고리즘과 작은 대상 범위 때문이다.
- TLAB는 Eden 할당에서 락을 제거한다. 99%의 할당은 포인터 이동 하나로 끝난다.
- Metaspace는 PermGen의 고정 크기 실패를 학습한 결과다. 컨테이너에서는 반드시
MaxMetaspaceSize를 설정한다. - 객체 헤더 12바이트에 Hash, Age, Lock 상태가 압축 인코딩된다. 32GB 힙 경계가 Compressed Oops를 끄는 임계값이다.
- Off-Heap은 GC 바깥의 메모리다. 빠르지만 수명 관리는 전적으로 개발자 책임이다.
다음 글에서는 이 메모리 구조 위에서 GC가 어떻게 동작하는지 — Mark, Sweep, Compact의 세 단계와 각 GC