✍️ 필사 모드: JVM GC 완전 가이드 2025: G1, ZGC, Shenandoah, Generational ZGC — Stop-the-World부터 Sub-millisecond Pause까지
한국어들어가며: "STW, 평균 2초"의 악몽
Java 서버의 흔한 비극
2010년대 초, 많은 Java 백엔드 엔지니어들이 공유한 악몽이 있다:
[14:32:15] GC pause: 2,847 ms (Full GC)
[14:32:18] Web requests timed out: 1,432
[14:32:19] Load balancer marked instance unhealthy
[14:32:20] Alert: Server "app-17" DOWN
2초 넘는 Stop-the-World (STW) pause 동안 모든 애플리케이션 스레드가 완전히 멈춘다. 들어오는 요청은 쌓이고, 타임아웃이 발생하고, 로드 밸런서는 서버를 죽은 걸로 판단한다.
"왜 멈췄지?"라고 물으면 답은 하나: Garbage Collection.
그리고 2023년
이제 Java 21의 Generational ZGC는 이렇게 말한다:
[14:32:15.000] GC pause: 0.5 ms (Young)
[14:32:15.120] GC pause: 0.3 ms (Mixed)
[14:32:15.340] GC pause: 0.4 ms (Young)
수백 GB 힙에서도 대부분의 pause가 1ms 미만이다. 같은 10년 사이에 GC는 완전히 변했다.
이 글에서 다룰 것
이 글은 JVM GC의 진화를 따라간다:
- 고전 GC: Serial, Parallel, CMS — 기본 아이디어.
- G1: 2012년 기본 GC가 된 region 기반 수거.
- ZGC: 2018년 등장, colored pointer 기반.
- Shenandoah: Red Hat의 concurrent compaction.
- Generational ZGC: 2023년 Java 21, 결정판.
- 실전 튜닝: JVM 옵션과 측정 방법.
왜 중요한가: GC를 이해하면 "왜 내 Java 서버가 지금 느려?"를 진단할 수 있다. 더 나아가, 어떤 GC를 고르고 어떻게 튜닝할지 판단할 수 있다.
1. GC의 근본 원리
문제 정의
GC는 두 가지 질문에 답해야 한다:
- 어떤 객체가 살아있나? (Live identification)
- 죽은 객체의 메모리를 어떻게 회수할까? (Reclamation)
Reachability: 살아있음의 정의
Java에서 "살아있다"는 root로부터 도달 가능하다는 의미다:
GC Roots:
- 현재 실행 중인 스택 프레임의 지역 변수.
- 정적 필드.
- 활성 JNI 참조.
- synchronized된 객체.
객체 A가 roots에서 참조를 따라가다 만날 수 있으면 → live. 아니면 → garbage.
Mark-Sweep의 기본
가장 원시적인 GC 알고리즘 Mark-Sweep:
- Mark: roots부터 시작해 도달 가능한 모든 객체를 표시.
- Sweep: 표시되지 않은 객체들을 free list에 추가 (또는 회수).
Before:
[obj1* live][obj2 dead][obj3* live][obj4 dead][obj5* live]
After Sweep:
[obj1* ][ ][obj3* ][ ][obj5* ]
↑free ↑free
문제: external fragmentation. 회수된 공간이 조각나 큰 할당이 어려워진다.
Mark-Compact
Fragmentation 해결:
- Mark: 동일.
- Compact: 살아있는 객체를 메모리 앞쪽으로 이동해 연속화.
After Compact:
[obj1*][obj3*][obj5*][--------free--------]
비용: 객체 이동 시 포인터를 모두 업데이트해야 함. 느리다.
Copying GC
더 단순한 방식: 메모리를 두 공간(From/To)으로 나누고, GC 시 live 객체만 To로 복사:
From: [obj1*][obj2 ][obj3*][obj4 ][obj5*]
↓ 복사
To: [obj1*][obj3*][obj5*][----free-----]
From: 전체 비움
- 장점: fragmentation 없음, 단순함.
- 단점: 메모리의 절반만 사용 가능.
Generational Hypothesis
1984년 David Ungar가 관찰한 중요한 사실:
"대부분의 객체는 매우 짧게 살고, 오래 산 객체는 계속 오래 산다."
이를 Weak Generational Hypothesis라고 부른다. 이 통찰이 현대 GC의 근간이다.
GC 구조:
- Young generation (Eden + Survivor): 새 객체. 자주 수거.
- Old generation (Tenured): 여러 GC에서 살아남은 객체. 가끔 수거.
이 분리로 대부분의 수거를 작은 young 영역에서만 할 수 있다. 전체 힙을 스캔할 필요 없음.
Card Table: 세대 간 참조
문제: old 객체가 young 객체를 참조하는 경우 어떻게 알지?
해결: card table. 힙을 작은 "카드"(보통 512바이트)로 나누고, 카드별로 "이 카드에 young 참조가 있나?" 비트를 저장.
write barrier로 old → young 참조가 만들어질 때마다 카드를 dirty로 표시. Young GC 시엔 dirty card만 스캔.
Write Barrier / Read Barrier
GC가 애플리케이션과 함께 동작하려면 barrier가 필요하다:
- Write barrier: 객체의 참조 필드를 쓸 때 실행되는 훅.
- Read barrier: 객체의 참조 필드를 읽을 때 실행되는 훅.
Write barrier는 거의 모든 현대 GC가 쓴다. Read barrier는 최근의 concurrent GC(ZGC, Shenandoah)에서 사용.
2. 고전 GC: Serial, Parallel, CMS
Serial GC
가장 단순. 단일 스레드로 전체 힙을 수거.
- 알고리즘: young에 copying, old에 mark-compact.
- STW: 예. 전체 수거 동안 애플리케이션 멈춤.
- 용도: 작은 힙(수백 MB), 클라이언트 앱, 임베디드.
JVM 옵션:
-XX:+UseSerialGC
Parallel GC (Throughput GC)
Serial의 멀티스레드 버전. STW 시간을 줄이려 여러 스레드로 병렬화.
- 알고리즘: Serial과 동일, 단 병렬 실행.
- STW: 예, 그러나 짧아짐.
- 목표: throughput 최대화 (긴 pause는 허용).
- 용도: 배치 작업, 처리량 우선 워크로드.
-XX:+UseParallelGC
-XX:ParallelGCThreads=8
Java 8의 기본 GC였다. 지금도 배치 작업에는 좋은 선택.
CMS (Concurrent Mark-Sweep)
2002년 등장한 최초의 concurrent GC. Old generation 수거를 애플리케이션과 동시에 수행해 pause를 줄인다.
단계:
- Initial Mark (STW): roots 표시.
- Concurrent Mark: 애플리케이션과 병렬로 나머지 live 객체 표시.
- Remark (STW): concurrent mark 중 변경된 것 보완.
- Concurrent Sweep: live 아닌 객체 회수 (compact X).
장점: 매우 짧은 STW. 단점:
- Fragmentation (compact를 안 함) → 결국 Full GC 필요.
- Concurrent mode failure: 수거 속도보다 할당 속도가 빠르면 Full GC로 폴백, 그때 긴 pause 발생.
- 높은 CPU 오버헤드.
Java 9에서 deprecated, 14에서 제거되었다. 그러나 GC 역사에서 매우 중요한 단계.
3. G1 GC: Region 기반의 혁명
문제 의식
CMS는 old generation을 연속된 거대한 공간으로 다룬다. 이는:
- Mark 단계가 오래 걸림.
- Fragmentation이 불가피.
- 수거 타이밍 예측 어려움.
G1 (Garbage First)는 완전히 다른 접근을 한다.
Region 기반 힙
G1은 힙을 고정 크기 region(1MB ~ 32MB)으로 나눈다.
┌──┬──┬──┬──┬──┬──┬──┬──┐
│E │E │S │E │O │O │O │O │
├──┼──┼──┼──┼──┼──┼──┼──┤
│E │O │O │E │H │H │O │E │ H = Humongous
├──┼──┼──┼──┼──┼──┼──┼──┤
│O │O │S │E │O │O │E │E │
└──┴──┴──┴──┴──┴──┴──┴──┘
E = Eden, S = Survivor, O = Old, H = Humongous
각 region은 상황에 따라 young/old/humongous로 재할당된다. Humongous는 region 절반 이상인 큰 객체용.
"Garbage First"의 의미
이름의 유래: G1은 쓰레기가 가장 많은 region을 우선 수거한다.
각 region별로 live 객체 비율을 추적. GC 시:
- 가장 live가 적은 region들 선택 (쓰레기가 많음).
- 그 region들의 live 객체를 다른 region으로 복사.
- 원본 region을 통째로 비움.
결과: 예측 가능한 pause 시간 (설정한 목표 근사).
Pause Time Goal
G1의 주요 매력: 목표 pause 시간을 지정할 수 있다.
-XX:MaxGCPauseMillis=200
G1은 200ms를 넘지 않도록 region 선택한다. 완벽하진 않지만 평균적으로 맞춘다.
G1의 단계
Young Collection (STW):
- Eden과 Survivor에서 live 객체를 새 Survivor 또는 Old로 복사.
- 통과한 횟수가 임계치(MaxTenuringThreshold)를 넘으면 Old로 승격.
Mixed Collection (STW):
- Young + 일부 Old region 수거.
- Old region 선택은 "가장 live가 적은" 것부터.
Concurrent Marking (애플리케이션과 병렬):
- Initial Mark (STW, 보통 Young GC에 piggyback).
- Root region scan.
- Concurrent mark.
- Remark (STW).
- Cleanup (STW, 빈 region 회수).
Humongous Region
Region 크기의 절반 이상인 객체는 humongous region에 저장된다.
- 전용 region 하나 또는 여러 개 사용.
- Young GC에서 처리 안 됨 (바로 old로).
- 크기 때문에 복사 비용이 커서 특별 취급.
Humongous가 많으면 문제: 수거가 어렵고, fragmentation 발생 가능.
G1이 Java 9+ 기본 GC
Java 9부터 G1이 기본 GC가 되었다. Parallel GC에서 전환되었는데, 이유:
- 예측 가능한 pause: 대부분의 웹 서비스가 throughput보다 응답 시간 중요.
- 큰 힙 지원: 수십 GB까지 합리적으로 관리.
- 성숙도: 수년간의 개선으로 안정화.
G1의 한계
G1도 만능은 아니다:
- 수 GB 이상의 힙에서 pause 증가: region 수가 많아지면 mark 시간 증가.
- Fragmentation: 여전히 발생 가능 (humongous 등).
- 높은 CPU 오버헤드: Concurrent 작업 많음.
- 100 GB 이상: 만족스러운 pause 달성 어려움.
4. ZGC: Pause 시간 1ms 미만
ZGC의 약속
2018년 Oracle이 발표한 ZGC (Z Garbage Collector) 의 약속:
- TB 급 힙 지원.
- Pause < 10ms (실제론 < 1ms).
- 힙 크기에 무관한 pause 시간.
이게 사실일 수 있을까? 사실이다. 어떻게 달성했는지 들여다보자.
Colored Pointers
ZGC의 핵심: colored pointers (tagged pointers).
64비트 포인터의 상위 비트에 메타데이터를 저장:
64비트 포인터:
[18비트 unused][4비트 color][42비트 address]
Color bits:
- Marked0
- Marked1
- Remapped
- Finalizable
GC는 이 color 비트를 이용해 객체가 "어떤 상태"인지 즉시 알 수 있다. 객체 헤더에 접근할 필요 없음.
Load Barrier
ZGC는 load barrier (읽기 장벽)를 사용한다. 애플리케이션이 객체 참조를 읽을 때마다:
- 색을 확인.
- 색이 "현재 유효"하면 그대로 사용.
- 아니면 "현재 위치"로 업데이트하고 색 갱신.
이 barrier 덕분에 GC는 애플리케이션을 방해하지 않고 객체를 이동시킬 수 있다.
ZGC의 단계
1. Pause Mark Start (STW, < 1ms):
- Thread stack의 root만 빠르게 표시.
- 전체 힙이 아니라 root만 처리하므로 짧음.
2. Concurrent Mark:
- 애플리케이션과 병렬로 live 객체 표시.
- Load barrier로 동기화.
3. Pause Mark End (STW, < 1ms):
- Mark 완료 확인.
4. Concurrent Prepare for Relocation:
- 수거할 region(page) 결정.
- Relocation set 생성.
5. Pause Relocate Start (STW, < 1ms):
- Root들의 참조 업데이트.
6. Concurrent Relocate:
- 애플리케이션과 병렬로 객체를 새 위치로 복사.
- Load barrier가 나머지 참조를 lazy하게 업데이트.
핵심: 애플리케이션 스레드가 멈추는 시간(STW pause)은 root 수에만 비례한다. 힙 크기와 무관!
ZGC의 성능
실측:
- 4GB 힙: 평균 pause 0.5ms, 최대 1ms.
- 128GB 힙: 평균 pause 0.5ms, 최대 1ms. (동일!)
- 1TB 힙: 여전히 비슷.
처리량은 약 15% 감소 (load barrier 오버헤드). 메모리 오버헤드는 ~10% (forwarding table).
ZGC의 한계
초기 ZGC는 non-generational이었다. Young/Old 분리 없이 전체 힙을 동일하게 다룸. 이는:
- 대부분 객체가 금방 죽는 Generational Hypothesis를 활용 못 함.
- 전체 힙을 자주 스캔해야 함.
- 할당 속도가 매우 빠른 워크로드에서 CPU 오버헤드.
5. Shenandoah: Red Hat의 답
철학
Shenandoah는 Red Hat이 OpenJDK에 기여한 GC로, ZGC와 비슷한 목표를 갖는다:
- 초저지연.
- Concurrent compaction.
- 힙 크기 독립적 pause.
ZGC보다 먼저 시작되었고, 다른 접근을 쓴다.
Brooks Pointer
Shenandoah는 Brooks pointer (forwarding pointer) 를 사용:
- 모든 객체에 "현재 위치를 가리키는 포인터" 를 추가.
- 객체 이동 시 이 포인터만 업데이트.
- 애플리케이션은 항상 brooks pointer를 통해 접근.
Object:
[brooks_ptr][header][data]
↓
실제 위치 (이동 전: 자기 자신, 이동 후: 새 위치)
오버헤드: 객체마다 8바이트 추가. 하지만 compaction을 concurrent로 할 수 있게 해주는 대가.
Shenandoah 단계
- Init Mark (STW, 짧음).
- Concurrent Mark.
- Final Mark (STW).
- Concurrent Cleanup.
- Concurrent Evacuation: 객체 이동 (concurrent).
- Init Update Refs (STW).
- Concurrent Update Refs: 참조 업데이트.
- Final Update Refs (STW).
Load Reference Barrier
Shenandoah는 load barrier를 쓴다 (ZGC와 유사). 각 참조 load마다:
- Forwarding pointer 확인.
- 필요시 업데이트.
ZGC가 colored pointer로 메타데이터를 인코딩한다면, Shenandoah는 brooks pointer로 forwarding 정보를 명시적으로 저장한다.
ZGC vs Shenandoah
| 항목 | ZGC | Shenandoah |
|---|---|---|
| 메타데이터 | Colored pointers (64bit 일부) | Brooks pointer (객체마다 8B) |
| 오버헤드 | 낮음 | 약간 높음 |
| 배리어 타입 | Load + store | Load only (기본) |
| 힙 크기 범위 | 8MB ~ 16TB | 제한 없음 |
| 생산 지원 | JDK 15 GA, 21 generational | JDK 12+ GA |
| NUMA 인식 | 예 | 부분적 |
둘 다 서브밀리초 pause가 가능하며, 특정 워크로드에서 서로 우위를 가진다.
6. Generational ZGC: 결정판
문제
초기 ZGC는 non-generational이라 Generational Hypothesis를 활용하지 못했다. 결과:
- 전체 힙을 반복 스캔 → CPU 오버헤드.
- 할당 속도가 매우 빠를 때 GC가 따라잡기 어려움.
Java 21의 Generational ZGC
Java 21 (2023)은 Generational ZGC를 도입했다. Young과 Old generation을 분리:
- Young collection: 자주, 작은 작업.
- Old collection: 가끔, 큰 작업.
- 둘 다 concurrent.
이득
Oracle의 벤치마크:
- 2배 이상의 처리량 향상 (할당 많은 워크로드).
- Heap footprint 감소.
- Sub-millisecond pause 유지.
몇몇 대기업(Netflix, Amazon)이 이미 프로덕션에 채용.
활성화
# Java 21+
-XX:+UseZGC -XX:+ZGenerational
JDK 24부터 ZGenerational이 기본값이 될 계획.
7. JVM GC 선택 가이드
결정 트리
힙 크기가 얼마나 큰가?
├── < 1 GB
│ └── Serial GC (Client 모드)
├── 1 GB ~ 4 GB
│ └── Parallel GC (throughput 중시) 또는 G1
├── 4 GB ~ 32 GB
│ └── G1 (기본, 안정적)
├── 32 GB ~ 128 GB
│ ├── Pause 중요 → ZGC (Generational) 또는 Shenandoah
│ └── Throughput 중요 → Parallel
└── > 128 GB
└── ZGC (Generational) 또는 Shenandoah
워크로드별 추천
| 워크로드 | 추천 GC | 이유 |
|---|---|---|
| 배치 처리 | Parallel GC | 처리량 우선 |
| 웹 서비스 | G1 | 예측 가능한 pause |
| 실시간 거래 | ZGC/Shenandoah | sub-ms pause |
| 빅 메모리 캐시 | ZGC (Generational) | TB 힙 지원 |
| 작은 컨테이너 | Serial | 적은 오버헤드 |
| 마이크로서비스 | G1 또는 ZGC | pause 일관성 |
8. JVM GC 튜닝
출발점: GC 로그 활성화
무조건 첫 단계:
# Java 9+
-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=10,filesize=10M
로그 없이 튜닝은 불가능하다.
힙 크기 설정
-Xms4g # 초기 힙
-Xmx4g # 최대 힙
Tip: Xms와 Xmx를 같게 설정하라. 동적 조정의 오버헤드를 피할 수 있다.
Young Generation 비율
# G1
-XX:NewRatio=2 # Old:Young = 2:1
-XX:MaxNewSize=1g
# G1에선 자동 조정되므로 보통 건드리지 않음
Pause Time Goal
-XX:MaxGCPauseMillis=200 # 200ms 목표 (G1)
주의: 목표를 너무 낮추면 GC가 더 자주 일어나서 처리량 저하.
병렬/Concurrent 스레드 수
-XX:ParallelGCThreads=8 # STW 시 병렬 스레드 수
-XX:ConcGCThreads=4 # Concurrent 작업 스레드 수
CPU 수에 맞게 조정. 기본값은 보통 적절.
Humongous Region 제어 (G1)
-XX:G1HeapRegionSize=16m # region 크기 (기본 자동)
큰 객체를 자주 할당하는 경우 region 크기를 키우면 humongous 대상 축소.
Card Table Marking (Young GC 최적화)
-XX:+UseCondCardMark # 불필요한 카드 마킹 줄이기
Multi-socket 시스템에서 유용.
Full GC 방지
-XX:InitiatingHeapOccupancyPercent=45 # G1: old 점유율 45%에 concurrent mark 시작
Concurrent mark가 늦으면 Full GC 유발. 낮게 설정하면 일찍 시작.
ZGC 활성화
# Java 15+ (non-generational ZGC)
-XX:+UseZGC
# Java 21+ (Generational ZGC, 권장)
-XX:+UseZGC -XX:+ZGenerational
# Heap 설정은 일반과 동일
-Xms16g -Xmx16g
Shenandoah
-XX:+UseShenandoahGC
# Optional: 모드 선택
-XX:ShenandoahGCMode=satb # 또는 iu
언제 튜닝하면 안 되는가
"일단 기본값으로". 첫 단계는 측정이다:
- 프로덕션 부하로 며칠 실행.
- GC 로그 분석.
- 문제가 보이면 하나씩 바꾸고 재측정.
무작정 옵션을 마구 추가하는 건 거의 항상 악화를 초래한다.
9. GC 로그 분석하기
G1 로그 샘플
[2.345s][info][gc ] GC(5) Pause Young (Normal) (G1 Evacuation Pause)
Eden regions: 25->0(25)
Survivor regions: 3->3(4)
Old regions: 12->12
Humongous regions: 2->2
Metaspace: 18M->18M
0.0M->0.0M(64.0M) 2.345ms
해석:
- Eden regions: 25->0(25): Eden 25개가 0개로 (모두 비워짐).
- Survivor regions: 3->3(4): Survivor 3개가 그대로, 다음엔 4개.
- Old regions: 12->12: Old 변화 없음.
- 2.345ms: 이번 GC 시간.
ZGC 로그 샘플
[5.123s][info][gc,start] GC(12) Garbage Collection (Allocation Rate)
[5.123s][info][gc,phases] GC(12) Pause Mark Start 0.412ms
[5.128s][info][gc,phases] GC(12) Concurrent Mark 4.892ms
[5.128s][info][gc,phases] GC(12) Pause Mark End 0.218ms
[5.135s][info][gc,phases] GC(12) Concurrent Relocate 6.734ms
[5.135s][info][gc,heap] GC(12) Heap Before GC: 8192M → After GC: 2048M
ZGC는 각 phase의 pause를 별도 기록. 총 STW는 Pause Mark Start + Pause Mark End.
분석 도구
GCEasy (gceasy.io): GC 로그 업로드하면 시각화 + 분석. 무료. GCViewer: 오픈소스 GUI 도구. JDK Mission Control (JMC): Oracle의 공식 도구, JFR과 연동.
핵심 지표
- Pause time p99: 99%의 GC가 이 값 이하.
- Throughput %: 애플리케이션 시간 / 총 시간. > 95% 권장.
- Allocation rate: 초당 할당 바이트.
- Promotion rate: Young → Old로 승격 바이트.
- Full GC 빈도: 이상적으로 0.
Full GC가 일주일에 한 번이라도 일어나면 조사해야 한다.
10. 흔한 GC 문제와 해결
문제 1: 긴 Young GC
증상: Young GC가 평소보다 오래 걸림 (100ms+).
원인:
- Young generation이 너무 큼.
- Survivor가 부족해서 premature promotion.
- Old 객체가 Young을 참조 많음 (card table scan 오래).
해결:
-Xmn조정 (Young 크기 명시).- G1의 경우
-XX:G1NewSizePercent조정. - Reference object (WeakReference, SoftReference) 과다 사용 확인.
문제 2: Full GC 반복
증상: Full GC가 기록됨, 여러 초 pause.
원인:
- 메모리 누수.
- CMS/G1의 concurrent mode failure (할당이 수거보다 빠름).
- Large object 할당 실패.
- Metaspace 부족.
해결:
- 힙 덤프 분석 (
jmap -dump:format=b,file=heap.bin <pid>). - 메모리 누수 수정.
- G1
-XX:InitiatingHeapOccupancyPercent낮춰 더 일찍 concurrent mark 시작. -XX:MaxMetaspaceSize조정.
문제 3: Promotion Failure
증상: promotion failed 로그.
원인: Young 객체를 Old로 옮기려는데 Old에 공간 부족.
해결:
- Old 크기 확보.
- Concurrent mark 더 일찍.
- G1 기본값 유지 (자동 조정).
문제 4: Humongous Allocation
증상: G1 로그에 humongous allocation 많음.
원인: Region 크기의 절반 이상인 객체.
해결:
-XX:G1HeapRegionSize를 키움 (2m → 16m).- 애플리케이션 레벨에서 큰 배열 분할.
문제 5: High GC CPU
증상: GC 스레드가 CPU의 20% 이상 소모.
원인:
- Allocation rate 너무 높음.
- Concurrent GC 따라잡기 어려움.
해결:
- 힙 크기 늘림 (
-Xmx). - 객체 재사용 (pooling).
-XX:ConcGCThreads증가.
문제 6: GC Thrashing
증상: GC가 계속 돌아가지만 메모리가 회수되지 않음. OutOfMemoryError 임박.
원인: 힙이 live 데이터로 거의 가득.
해결:
- 힙을 늘리거나, 라이브 데이터를 줄이거나, 메모리 누수를 고쳐라.
- 단기 해결책 없음. 구조적 문제.
11. 실전 사례
사례 1: Netflix의 ZGC 채택
Netflix는 큰 힙의 실시간 스트리밍 서비스에서 GC pause가 문제였다. CMS와 G1로는 p99에서 수백 ms pause가 발생했다.
ZGC 도입 결과:
- p99 GC pause: 수백 ms → 1ms 미만.
- 처리량: 약간 감소 (~10%).
- 전체 사용자 경험: 크게 개선 (끊김 없음).
Netflix는 이후 Generational ZGC 초기 채택자가 되었다.
사례 2: LinkedIn의 G1 튜닝
LinkedIn은 대규모 Kafka 클러스터를 운영한다. Kafka는 JVM의 off-heap 사용이 많지만 힙도 크다.
튜닝:
- G1, 힙 16GB.
-XX:MaxGCPauseMillis=20(매우 공격적).-XX:G1HeapRegionSize=16m.-XX:InitiatingHeapOccupancyPercent=35.
결과: p99.9 pause < 50ms. Kafka 성능 안정화.
사례 3: Elasticsearch의 GC 이슈
Elasticsearch는 JVM 힙을 검색 인덱스 일부에 사용한다. 큰 문서 집계 시 OOM 흔함.
교훈:
- 힙을 32GB 이하로 유지 (compressed oops 유지).
- 큰 집계는 off-heap 메모리로 이동.
- Circuit breaker로 OOM 방지.
- G1 권장 (7.x+).
사례 4: Spring Boot 마이크로서비스
작은 마이크로서비스는 힙이 작지만(1~4GB), 빠른 시작과 적은 메모리가 중요하다.
추천:
- Serial GC 또는 G1 (컨테이너 크기에 따라).
-Xmx:MaxRAMPercentage=75.0(컨테이너 메모리의 75%).- Cold start 최적화: AppCDS, GraalVM.
12. 최신 트렌드
GraalVM Native Image
Ahead-of-Time 컴파일. JIT도 GC도 최소화. 작은 힙에 최적.
- 장점: 시작 즉시, 작은 메모리.
- 단점: 런타임 리플렉션 제한, 일부 Java 기능 미지원.
마이크로서비스/서버리스에 적합.
Project Loom (Virtual Threads)
Java 21의 virtual threads는 GC에 영향을 준다. 수백만 개의 가벼운 스레드가 있으면:
- Stack이 많음 → GC root 수 증가.
- 작은 객체 할당 많음.
Generational ZGC가 이와 잘 맞는다.
Project Valhalla (Value Types)
미래의 Java 기능: value types. 힙에 할당되지 않고 스택이나 필드에 직접 저장.
- GC 부담 크게 감소.
- 아직 preview 단계.
Epsilon GC
"아무 것도 안 하는 GC". 메모리가 가득 차면 OutOfMemory. 테스트와 벤치마크용.
-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC
성능 벤치마크에서 GC 오버헤드를 제거하고 싶을 때 유용.
퀴즈로 복습하기
Q1. Weak Generational Hypothesis가 GC 설계에 어떤 영향을 주었는가?
A. "대부분의 객체는 매우 짧게 살고, 오래 산 객체는 계속 오래 산다" 는 관찰이다. 이 통찰은 GC를 완전히 바꿨다:
- Young/Old 분리: 힙을 두 영역으로 나누어 각각 다른 알고리즘 적용.
- 빠른 Young GC: 대부분 객체가 죽으므로 young 영역만 수거하면 효율적. 복사할 live 객체가 적다.
- 드문 Old GC: Old에는 오래 살아남을 객체가 대부분이라 자주 수거할 필요 없음.
- Card Table: Old → Young 참조만 추적하면 되므로 cross-generation 참조 관리가 효율적.
Generational GC가 non-generational보다 훨씬 효율적인 이유다. Java, .NET, V8, Ruby 등 거의 모든 현대 managed runtime이 이를 따른다. 초기 ZGC가 non-generational이었다가 Java 21에서 generational로 전환된 것도 이 원칙의 중요성을 보여준다.
Q2. G1의 "Garbage First"가 구체적으로 무엇을 의미하는가?
A. G1은 힙을 고정 크기 region(1~32MB)으로 나누고, 각 region의 live 객체 비율을 추적한다. Mixed GC 시:
- 가장 live가 적은 region을 골라 수거 대상으로 선택.
- 즉, 가장 쓰레기가 많은 region부터 수거.
- 이런 region들의 live 객체를 다른 region으로 복사하고, 원본 region은 통째로 비움.
이 전략의 효과:
- 수거 효율 최대화: 복사해야 할 live 객체가 적으니 pause가 짧아진다.
- 예측 가능성: 몇 개의 region을 수거할지
MaxGCPauseMillis목표에 맞춰 선택. - 점진적 수거: 전체 힙을 한 번에 수거하지 않고 부분씩.
이는 CMS가 old generation을 통째로 처리하려다 발생하는 문제(concurrent mode failure, fragmentation)를 우아하게 해결한다. 이름 그대로 garbage가 많은 곳부터 먼저다.
Q3. ZGC의 "colored pointer"가 어떻게 concurrent collection을 가능하게 하는가?
A. 64비트 포인터의 상위 비트 중 4비트를 GC 메타데이터로 사용한다 (marked0, marked1, remapped, finalizable). 덕분에:
- 헤더 접근 불필요: 객체 상태를 포인터만 봐도 즉시 알 수 있다 (캐시 미스 없음).
- Load barrier: 애플리케이션이 참조를 load할 때마다 배리어가 color를 확인. 필요시 그 자리에서 업데이트.
- Concurrent relocation: GC가 객체를 복사하는 동안 애플리케이션은 구 위치를 계속 참조할 수 있다. Load barrier가 새 위치로 lazy하게 redirect.
- STW 최소화: 모든 참조를 "한꺼번에" 업데이트할 필요 없음. 각 참조가 load될 때 점진적으로 업데이트.
이것이 힙 크기와 무관한 pause 시간의 비밀이다. Pause 시간은 root 수(스택, 정적 필드 등)에만 비례하고, 힙 내부의 참조 수는 관계없다. 4GB 힙이든 1TB 힙이든 pause는 거의 동일하다.
비용: Load barrier로 인한 약 10~15%의 throughput 감소. 대부분의 지연 민감 워크로드에서 합당한 대가다.
Q4. 언제 Parallel GC를 선택하는 것이 여전히 합리적인가?
A. 배치 처리 워크로드에 Parallel GC는 여전히 좋은 선택이다. 이유:
- Throughput 우선: Parallel은 "얼마나 많은 일을 끝냈는가"를 최적화. G1이나 ZGC는 "얼마나 균등한 pause인가"를 최적화.
- 오버헤드 낮음: Write barrier만 쓰고 concurrent 작업 없어서 애플리케이션 스레드의 CPU를 덜 뺏음.
- 예측 가능한 긴 pause 허용: 사용자가 기다리는 서비스가 아니라 밤에 도는 배치라면 1분 pause도 문제없음.
구체적 상황:
- 야간 ETL 배치: 최대한 빨리 처리 끝내기. 10초 pause 괜찮음.
- 데이터 분석 대량 처리: 응답 시간 아닌 전체 완료 시간이 중요.
- Compute-heavy 작업: 할당이 많지 않은 경우.
- 단일 사용자 툴: 응답성보다 전체 계산 속도가 중요.
반대로 사용자 대면 서비스에는 항상 G1 이상을 써야 한다. p99 pause 시간이 사용자 경험을 좌우하기 때문이다. "서비스가 얼마나 자주 멈추는가"의 문제를 Parallel은 해결하지 못한다.
Q5. GC 로그에서 "Full GC"가 자주 나타나면 무엇을 가장 먼저 확인해야 하는가?
A. Full GC는 거의 항상 비정상 신호다. G1이나 ZGC에선 절대 자주 일어나면 안 된다. 진단 순서:
1. 메모리 누수 의심
# 힙 덤프 생성
jmap -dump:live,format=b,file=heap.bin <pid>
# MAT (Memory Analyzer Tool)로 분석
# - 가장 큰 객체 클래스
# - Retained size 큰 루트
# - Reference chain 추적
대부분의 Full GC 반복은 live data가 계속 증가해서 발생한다. Caching 실수, listener 미제거, ThreadLocal 누수가 흔함.
2. 힙 크기 적절성
-Xmx가 실제 working set보다 작으면 Full GC가 안정화 못함.jcmd <pid> GC.heap_info로 현재 사용량 확인.
3. Metaspace 부족
"Full GC (Metadata GC Threshold)" 로그
→ -XX:MaxMetaspaceSize 확인. 클래스 로더 누수 의심 (OSGi, 동적 클래스 생성).
4. Concurrent Mode Failure / Allocation Failure
- G1이 concurrent mark를 완료하기 전에 old가 가득.
-XX:InitiatingHeapOccupancyPercent를 낮춰 일찍 시작 (기본 45% → 35%).
5. Humongous Object
- G1에서 큰 객체(region 절반 이상) 할당이 Full GC를 유발할 수 있음.
-XX:G1HeapRegionSize를 키워 해결.
절대 하지 말 것: Full GC를 숨기려고 -XX:+DisableExplicitGC만 추가하는 것. 이는 System.gc() 호출만 막을 뿐, 근본 원인은 그대로 남는다. Full GC는 증상이지 원인이 아니다. 원인을 찾아 고쳐야 한다.
마치며: Pause의 종말
핵심 정리
- GC는 진화 중: Serial → Parallel → CMS → G1 → ZGC → Generational ZGC.
- Generational Hypothesis가 근간. 대부분 객체는 금방 죽는다.
- G1: 2012년 이후 표준. Region 기반, 예측 가능한 pause.
- ZGC/Shenandoah: Concurrent compaction, sub-ms pause.
- Generational ZGC (Java 21): 현재 최고봉. 처리량과 pause 모두 확보.
- 튜닝 전에 측정: GC 로그 없이는 추측일 뿐.
- 기본값을 믿어라: 대부분의 경우 JVM 기본값이 합리적.
언제 GC를 신경 써야 하는가?
- p99/p999 응답 시간이 중요: 어떤 GC든 튜닝 필수.
- 힙이 크다 (> 32GB): ZGC/Shenandoah 고려.
- Full GC 발생: 즉시 조사.
- OOM: 메모리 누수 또는 힙 크기 부족.
- CPU의 10% 이상을 GC가 소모: 할당 패턴 조사.
마지막 교훈
2013년에는 "Java 서버는 주기적으로 STW pause가 있다"가 상식이었다. 2023년에는 그것이 선택 가능한 옵션이 되었다. Generational ZGC는 수백 GB 힙에서 서브밀리초 pause를 달성한다.
이 발전은 하루아침에 이뤄지지 않았다. 수십 년의 연구, 수천 명의 엔지니어, 수만 건의 실제 워크로드 분석이 쌓인 결과다. 우리는 그 거대한 어깨 위에 서 있다.
다음에 Java 서버가 느려지면, GC 로그를 열어보자. 이 글의 지식으로 무엇이 일어났는지 정확히 해석할 수 있을 것이다. 그리고 대부분의 문제는, 공부한 사람 앞에선 풀리기 마련이다.
참고 자료
- Oracle: HotSpot Virtual Machine Garbage Collection Tuning Guide
- The Z Garbage Collector (Oracle)
- Shenandoah GC (Red Hat)
- G1: Garbage-First Garbage Collector (Detlefs et al., 2004)
- A Generational Mostly-concurrent Garbage Collector (Printezis & Detlefs, 2000)
- JEP 439: Generational ZGC
- GCeasy - GC Log Analyzer
- JVM at Netflix - Netflix의 JVM 경험담
- The Garbage Collection Handbook (Jones, Hosking, Moss) - GC의 바이블
- Understanding G1 GC Logs
현재 단락 (1/484)
2010년대 초, 많은 Java 백엔드 엔지니어들이 공유한 악몽이 있다: