F-LAB JAVA · 2주차 · Phase 4 · G1 GC 심화
🏁 Phase 4 마지막 Unit — Garbage First의 정체
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
"Garbage First" = "쓰레기 많은 리전 먼저 처리"
G1은 모든 리전을 동등하게 회수하지 않는다.
각 리전의 garbage 비율 + 회수 시간을 계산해, 정지시간 한도 안에서 효과 큰 리전부터 선택.
이 우선순위 알고리즘이 G1을 G1답게 만드는 마지막 조각이다.
| 시스템 | 비유 |
|---|---|
| 정지시간 200ms | 청소 시간 30분 제한 |
| garbage 비율 | 각 방의 더러운 정도 |
| 회수 시간 | 각 방 청소에 걸리는 시간 |
| Garbage First | "가장 더럽고 빨리 청소되는 방부터" |
청소부 전략:
→ 시간 대비 효과 가 기준.
1. Garbage First의 본질
2. 회수 효과 계산 방식
3. 정지시간 예측 모델 정밀화
4. Mixed Collection의 우선순위
5. Concurrent Cycle 전체 흐름
6. ILIC 실무 — GC 튜닝 종합 가이드
7. 운영 사고 디버깅 시나리오
8. 흔한 실수 + 디버깅
9. 면접 + Phase 4 졸업 시험
Parallel GC:
"Young 전체 회수" 또는 "Old 전체 회수"
→ 영역 단위 회수
CMS:
"Old 전체 마킹 후 Sweep"
→ 영역 단위 + Concurrent
G1:
"리전별 garbage 비율 계산"
→ "효과 큰 리전부터 선택적 회수"
→ 절대 모든 리전 한꺼번에 안 함
A 리전:
- 크기: 2 MB
- garbage: 1.8 MB (90%)
- 회수 시간: 5 ms
→ 시간당 회수 효과: 360 MB/s
B 리전:
- 크기: 2 MB
- garbage: 0.4 MB (20%)
- 회수 시간: 5 ms
→ 시간당 회수 효과: 80 MB/s
→ 같은 시간 쓰면 A 회수가 4.5배 효과적
→ A를 먼저 선택
1. 모든 Old 리전의 garbage 정보 수집
(Concurrent Marking 단계에서)
2. 각 리전 정렬:
효과 = garbage / 회수시간
(높은 순)
3. 정지시간 한도 안에서 선택:
while (시간 남음 && 후보 있음) {
상위 후보 선택
예상 시간 만큼 시간 차감
}
4. 선택된 리전들만 회수
Young 리전은 다른 규칙:
- Young GC에서 모두 회수
- Eden 가득 차면 발동
- 회수 효과 계산 없이 다 처리
Mixed GC:
- Young + 선택된 Old 일부
- Old만 Garbage First 알고리즘 적용
이유:
모든 리전을 다 회수하지 않아도 메모리가 부족해지지 않는 이유는?
답:
1. 정지시간 한도 내에서 가장 효과적인 리전 선택
2. 한 번에 다 회수 안 해도, 점진적으로 누적
3. 매 Mixed GC마다 일부 Old 회수
4. Old gen 전체를 여러 사이클에 걸쳐 비움
5. 만약 정말 부족하면 → Full GC 발동 (최후 수단)
→ 연속된 Mixed GC가 결국 Old 전체를 처리.
→ 단, 너무 빠르게 채워지면 따라가지 못해 Full GC.
Concurrent Marking 단계:
1. GC Roots에서 시작
2. 도달 가능한 객체 마킹 (살아있음)
3. 각 리전의 통계 수집:
- 마킹된 객체 크기 = live
- 리전 크기 - live = garbage
- garbage 비율 = garbage / 리전 크기
4. 각 리전에 "회수 효과 점수" 저장
G1이 과거 GC 데이터 학습:
- 리전 A 회수 시간: 8ms (5번 평균)
- 리전 B 회수 시간: 12ms (5번 평균)
- 리전 X 회수 시간: 50ms (1번)
새 GC 사이클 시:
과거 평균을 기반으로 예측
"이 리전 회수에 약 10ms 걸릴 것"
리전의 종합 점수 = garbage 크기 / 예상 회수 시간
= 시간당 회수 가능한 garbage 양 (MB/s)
높은 점수 = 좋은 후보.
G1은 매 GC마다 학습:
- 예측 시간 vs 실제 시간 비교
- 차이가 크면 모델 조정
- 점점 정확해짐
운영 초기:
- 예측 부정확
- 정지시간 변동 큼
- 안정화까지 수 분~수 시간
운영 안정 후:
- 예측 정확
- 일관된 STW
→ 박승제씨가 ILIC 배포 직후 정지시간이 들쭉날쭉한 이유.
→ 시간 지나면 안정화.
예시 — Old 리전 10개 + 정지시간 한도 100ms:
Old 리전들:
R1: garbage 90%, 예상 8ms → 점수 매우 높음 ★
R2: garbage 80%, 예상 10ms → 높음
R3: garbage 70%, 예상 12ms → 중간
R4: garbage 60%, 예상 9ms → 중간
R5: garbage 50%, 예상 15ms → 낮음
R6: garbage 30%, 예상 11ms → 낮음
R7: garbage 95%, 예상 30ms → 중간 (시간 김)
R8: garbage 20%, 예상 7ms → 매우 낮음
R9: garbage 85%, 예상 9ms → 높음 ★
R10: garbage 75%, 예상 11ms → 높음
선택 (정지시간 한도 100ms):
R1 (8ms) + R9 (9ms) + R2 (10ms) + R10 (11ms)
+ R3 (12ms) + R4 (9ms) + R5 (15ms) + R7 (30ms - 안 됨)
= 74ms 사용 ✓ 한도 안
선택 안 된 리전:
R6, R7, R8 → 다음 Mixed GC로 미룸
사용자: "Pause 200ms 안에 끝내줘" (-XX:MaxGCPauseMillis=200)
G1: "최선 다해볼게"
내부 처리:
1. 과거 GC 시간 학습
2. 회수 후보 리전들의 점수 계산
3. 한도 안에서 최대 효과 조합 선택
4. 그것만 처리
사용자 설정: MaxGCPauseMillis=200
G1의 처리:
목표: < 200ms
실제: 평균 50~150ms
하지만:
- Full GC 발생 시: 수 초 (목표 무관)
- Humongous 처리 시: 한도 초과 가능
- 매우 큰 root set: 한도 초과 가능
→ "목표"이지 "보장"은 아님.
-XX:MaxGCPauseMillis=10 (10ms 목표)
문제:
- 한 번에 회수할 수 있는 리전 수가 매우 적음
- 자주 GC 발동
- Old gen 회수 속도 느림
- 결국 Old 가득 → Full GC
권장 범위: 100~500ms (대부분 200ms 적절)
-XX:MaxGCPauseMillis=2000 (2초 목표)
문제:
- GC 시간 길어짐 (한 번에 많이 처리)
- 응답 시간 폭증
- SLA 위반
- G1의 의미 없음 (Parallel GC와 비슷해짐)
ILIC의 SLA에 따라:
SLA P99 < 500ms:
→ MaxGCPauseMillis=200
SLA P99 < 200ms:
→ MaxGCPauseMillis=100 + ZGC 검토
SLA P99 < 50ms:
→ G1 부족. ZGC/Shenandoah 권장
박승제씨가 ILIC SLA에 맞춰 조정.
Old gen 사용량 도달:
≥ -XX:InitiatingHeapOccupancyPercent=45 (기본 45%)
→ Concurrent Cycle 시작 (Marking)
→ Marking 완료 후 Mixed Collection 발동
1. Initial Mark (STW, 짧음)
- GC Roots부터 시작점 마킹
- Young GC와 동시 수행
2. Concurrent Mark (앱과 동시)
- 그래프 탐색
- 가장 오래 걸리는 단계
- 앱은 정상 동작
3. Remark (STW, 짧음)
- Concurrent 중 변경된 부분 처리
- SATB(Snapshot-At-The-Beginning) 버퍼 처리
4. Cleanup (STW, 짧음)
- 통계 수집 (각 리전의 garbage 비율)
- 빈 리전 즉시 회수
- 다음 Mixed GC 후보 리스트 생성
5. Mixed Collections (STW, 여러 사이클)
- Young + 선택된 Old 리전
- Garbage First 우선순위 적용
- 후보 리스트 다 처리할 때까지
하나의 Concurrent Cycle 후:
- 후보 Old 리전: 100개 (예)
- 한 번에 처리 가능: 10~20개 (정지시간 한도)
- → 5~10번의 Mixed GC 사이클
진행:
Mixed GC 1: 가장 효과 큰 20개 처리
Mixed GC 2: 다음 20개
Mixed GC 3: 다음 20개
...
Mixed GC N: 마지막 처리
완료 후:
Old gen 사용량 다시 떨어짐
새 Concurrent Cycle 발동 시점까지 일반 Young GC
한 Concurrent Cycle 후 Mixed GC 횟수 목표:
-XX:G1MixedGCCountTarget=8 (기본 8)
작게 하면:
- 한 번에 많이 처리
- 정지시간 길어짐
- 빠른 Old 정리
크게 하면:
- 한 번에 적게 처리
- 정지시간 짧음
- Old 정리 느림
Old 리전의 live 비율이 이 값 이하면 Mixed GC 대상:
-XX:G1MixedGCLiveThresholdPercent=85 (기본 85)
의미:
- 리전의 85% 이상이 live = garbage 15% 이하
- "그 정도면 회수해도 효과 적음" → 건드리지 않음
- 다음 Concurrent Cycle까지 대기
→ G1이 "어차피 깨끗한 리전 회수에 시간 낭비 안 해"
시간 →
┌──Initial Mark (STW 짧음)─┐
│ Young GC + Root 마킹 │
└──────────────────────────┘
↓
┌──Concurrent Mark (앱과 동시)──┐
│ 그래프 탐색 (긴 시간) │
│ 앱: 정상 동작 │
└────────────────────────────────┘
↓
┌──Remark (STW)─┐
│ SATB 처리 │
└─────────────────┘
↓
┌──Cleanup (STW)─┐
│ 통계 + 빈 리전 │
└────────────────┘
↓
┌──Mixed GCs─┐
│ Young + Old │
│ (8번 정도) │
└────────────┘
STW 시간 (4GB 힙 기준):
- Initial Mark: ~5ms
- Concurrent Mark: STW 없음 (앱과 동시)
- Remark: ~10ms
- Cleanup: ~5ms
- Mixed GC 1번: ~50-100ms
총 STW (1 cycle):
~ 100ms (Initial + Remark + Cleanup)
+ Mixed GC 8회 × 80ms = 640ms
하지만 분산됨:
- Initial Mark, Remark, Cleanup: 짧고 빈번
- Mixed GC: 1초~분 간격
- Concurrent Mark: 앱 영향 적음
SATB (Snapshot-At-The-Beginning):
Initial Mark 시점에 "객체 그래프 스냅샷" 작성
↓
Concurrent Mark 진행 중 앱이 참조 변경 시:
- 변경 전 참조 정보를 SATB 버퍼에 기록
- 변경은 정상 수행
↓
Remark 단계:
- SATB 버퍼의 변경분 처리
- 스냅샷 + 변경 반영해서 최종 마킹
→ Concurrent 중에도 앱 영향 최소.
→ G1의 핵심 메커니즘.
Phase 4.3에서 본 Write Barrier는 사실 두 가지 일:
1. Card Table 마킹 (RSet용)
- 리전 경계 넘는 참조 → 카드 dirty
2. SATB 버퍼 기록 (Concurrent용)
- Concurrent Mark 중 → 변경 전 참조 기록
→ 일반 코드 실행 시마다 ~2-5% 오버헤드.
시나리오:
Concurrent Marking 중
앱이 너무 빨리 객체 생성
Old gen이 가득 참
결과:
to-space exhausted
Full GC 발동 (Serial 또는 Parallel)
매우 긴 STW (수 초)
해결:
# 더 일찍 Concurrent 시작
-XX:InitiatingHeapOccupancyPercent=30 (기본 45 → 30)
# 예비 공간 늘림
-XX:G1ReservePercent=15 (기본 10)
# Heap 크기 증가
-Xmx8g (4g → 8g)
1단계: 기본 옵션 사용 (G1 자동)
-XX:+UseG1GC
-Xms4g -Xmx4g
2단계: 모니터링 (1-2주)
- GC 로그 분석
- P99 응답 시간
- Full GC 빈도
3단계: 필요 시 튜닝
- 정지시간 목표 조정
- Concurrent 시작 시점 조정
- 리전 크기 조정
4단계: 부하 테스트
- 변경 후 효과 검증
- SLA 만족 여부
# === 메모리 ===
-Xms4g # 초기 = 최대
-Xmx4g
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
# === G1 GC ===
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 정지시간 목표
# (선택, 기본 OK)
-XX:G1HeapRegionSize=8m # 리전 크기 (자동 권장)
-XX:InitiatingHeapOccupancyPercent=45 # Concurrent 시작 임계치
-XX:G1MixedGCCountTarget=8 # Mixed GC 횟수 목표
-XX:G1MixedGCLiveThresholdPercent=85 # Old 리전 처리 임계치
-XX:G1ReservePercent=10 # 예비 공간
# === GC 로깅 (Java 17+) ===
-Xlog:gc*:file=/var/log/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=100M
# === OOM 대응 ===
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heap-dumps/
-XX:OnOutOfMemoryError="kill -9 %p"
# === JFR (선택) ===
-XX:StartFlightRecording=disk=true,maxsize=1g,maxage=24h,filename=/var/log/jfr/app.jfr
-Xms4g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
기본만으로 충분.
# Concurrent를 일찍 시작
-XX:InitiatingHeapOccupancyPercent=30
# 예비 공간 늘림
-XX:G1ReservePercent=15
# Heap 증가
-Xms8g -Xmx8g
# 리전 크기 증가
-XX:G1HeapRegionSize=32m
또는 코드 수정 (Unit 4.4).
# ZGC로 전환
-XX:+UseZGC
-Xms16g -Xmx16g
# 1. 로그 수집 (1일치)
cp /var/log/gc-*.log analysis/
# 2. 도구 분석
# GCViewer
java -jar gcviewer-1.36.jar analysis/gc.log
# GCEasy (웹)
# https://gceasy.io 에 업로드
# 3. 분석 지표 확인
# - Throughput (앱 시간 / 전체 시간)
# - Average / P99 GC pause
# - Full GC count
# - Memory pool usage trends
일일 점검:
☐ Full GC 발생 횟수 (0이어야 함)
☐ 평균 Young GC pause (~ 50ms)
☐ P99 GC pause (~ 200ms)
☐ Concurrent Mode Failure 여부
주간 점검:
☐ Old gen 사용률 추세
☐ Mixed GC 횟수 정상
☐ Humongous Allocation 빈도
☐ Metaspace 사용률
월간 점검:
☐ JVM 옵션 재검토
☐ Heap 크기 적정성
☐ GC 알고리즘 적정성 (G1 vs ZGC)
☐ JDK 버전 업데이트 검토
14:30 알림:
P99 응답시간 200ms → 1500ms
1단계: GC 로그
tail -100 /var/log/gc-*.log | grep "Pause"
결과:
GC(456) Pause Full (G1 Evacuation Pause) 3500.123ms
↑ Full GC!
2단계: Heap 상태
jcmd <PID> GC.heap_info
결과:
Old gen 사용률: 95% ← 거의 가득
Humongous regions: 50 ← 거대 객체 많음
3단계: Heap dump
jmap -dump:format=b,file=/tmp/heap.hprof <PID>
4단계: MAT 분석
- Histogram: byte[] 가 50% 차지
- Path to GC Roots: ImageCache.cache
5단계: 원인 식별
- 이미지 캐시가 메모리에 5MB씩 100장 보관
- Humongous 누적
- Concurrent Cycle 따라가지 못함
- 결국 Full GC
6단계: 해결
- 이미지 캐시를 Caffeine + 크기 제한
- 또는 외부 스토리지 (S3, Redis)
7단계: 재배포 + 검증
배포 후 시간 흐름:
Day 1: 평균 50ms
Day 3: 평균 80ms
Day 7: 평균 120ms
원인 추정: 메모리 누수
1단계: GC 로그 추세
awk로 GC pause 시간 추출 → 그래프
- Full GC 빈도 점점 증가
2단계: Heap 모니터링
Old gen 사용률이 시간이 갈수록 회복 안 됨
3단계: Heap dump (현재 + 시작 시)
비교 분석
- 어떤 객체가 증가하고 있는지
- GC 못 시키는 이유
4단계: 코드 리뷰
- static 컬렉션? (Unit 4.1)
- ThreadLocal 미정리?
- Listener 미해제?
- 캐시 만료 정책 없음?
5단계: 수정 후 재배포
GC 로그 패턴:
매 분마다 G1 Humongous Allocation
→ Mixed GC 부담 증가
원인 추적:
- 새 기능 배포 시점부터 발생
- PDF 생성 기능 추가
코드 확인:
byte[] pdfBytes = pdfGenerator.generate(report); // 5MB
return ResponseEntity.ok(pdfBytes);
문제:
매 요청마다 5MB byte[] 거대 객체
요청 100건/초 = 초당 500MB Humongous 할당!
해결:
스트리밍 응답으로 변경
byte[] 메모리 적재 X
GC 로그:
GC(789) To-space exhausted
GC(789) Pause Full (Allocation Failure) 4500ms
원인:
- Mixed GC가 Old 채우는 속도 못 따라감
- 또는 evacuation 중 빈 리전 부족
해결 옵션:
1. -XX:InitiatingHeapOccupancyPercent=30 (45 → 30)
2. -XX:G1ReservePercent=15 (10 → 15)
3. Heap 크기 증가
4. 코드 최적화 (객체 생성 줄이기)
# 실시간 GC 통계
jstat -gc <PID> 1000
# 출력:
# S0C S1C S0U S1U EC EU OC OU MC MU
# 16384 16384 0.0 13800 131072 98456 262144 142000 77824 76341
# Heap 정보
jcmd <PID> GC.heap_info
# 메모리 풀별 사용량
jcmd <PID> VM.native_memory summary
# Class 통계
jcmd <PID> GC.class_histogram
# JFR 시작
jcmd <PID> JFR.start duration=60s filename=app.jfr
# Heap dump
jmap -dump:live,format=b,file=heap.hprof <PID>
# Prometheus 알림 규칙 예시
groups:
- name: jvm_gc
rules:
- alert: FullGCDetected
expr: increase(jvm_gc_collection_seconds_count{gc="G1 Old Generation"}[5m]) > 0
annotations:
summary: "Full GC 발생 감지"
- alert: GCPauseHigh
expr: rate(jvm_gc_pause_seconds_sum[5m]) > 0.5
annotations:
summary: "GC 시간 비중 50% 초과"
- alert: OldGenHigh
expr: jvm_memory_used_bytes{area="heap", id="G1 Old Gen"} / jvm_memory_max_bytes{area="heap", id="G1 Old Gen"} > 0.8
annotations:
summary: "Old gen 사용률 80% 초과"
# ❌ 10ms 목표
-XX:MaxGCPauseMillis=10
# 결과:
# - 한 번에 회수 못 함
# - 자주 GC 발동
# - 결국 Full GC
→ 100-500ms가 현실적. 10ms 원하면 ZGC.
# ❌ 70%까지 기다림
-XX:InitiatingHeapOccupancyPercent=70
# 결과:
# - Concurrent Marking이 늦게 시작
# - Marking 중 Old 가득 → Concurrent Mode Failure
→ 기본 45% 유지. Full GC 자주면 30%로.
# ❌ 인터넷에서 본 옵션 다 적용
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapRegionSize=4m \
-XX:G1MixedGCCountTarget=4 \
-XX:G1MixedGCLiveThresholdPercent=70 \
-XX:G1OldCSetRegionThresholdPercent=20 \
-XX:G1HeapWastePercent=5 \
... (수십 개)
옵션끼리 충돌하거나 불필요한 제약 발생.
→ 진짜 필요한 옵션만. 한 번에 하나씩 변경.
"이 옵션 적용했는데 운영 어때?"
❌ 운영에 바로 적용
✓ Stage에서 부하 테스트 → 측정 → 적용
"GC가 문제인지 모르겠어"
→ 로그를 봐야 알지!
-Xlog:gc*:file=gc.log
운영 환경에 GC 로그 안 켜는 건 큰 실수.
"메모리 누수가 의심돼"
"Heap dump는 어렵네"
"그냥 재시작"
→ 매번 재시작은 임시 해결.
→ 진짜 원인은 누군가 분석해야 함.
MAT, JProfiler 등 도구 익히기.
| Q | 핵심 답변 |
|---|---|
| Garbage First 이름의 의미? | 쓰레기 많은 리전부터 회수 |
| 회수 효과 점수? | garbage / 회수시간 |
| 정지시간 한도 보장? | 목표일 뿐, Full GC 등에선 깨짐 |
| MaxGCPauseMillis 적정 값? | 100-500ms. ILIC는 200ms 권장 |
| Concurrent Cycle 5단계? | Initial Mark, Concurrent Mark, Remark, Cleanup, Mixed GCs |
| SATB의 역할? | Concurrent Mark 중 객체 그래프 스냅샷 |
| InitiatingHeapOccupancyPercent? | Concurrent 시작 임계치 (기본 45%) |
| Mixed GC 횟수? | G1MixedGCCountTarget=8 (기본) |
| Concurrent Mode Failure? | Concurrent 속도 못 따라가서 Full GC |
| GC 튜닝 우선순위? | 모니터링 → 측정 → 한 번에 하나씩 |
다음 질문에 즉답할 수 있다면 Phase 4 졸업:
모두 답할 수 있다면 Phase 4 완주. 운영 GC 마스터.
1. Garbage First = "효과 큰 리전 먼저"
2. Concurrent Cycle 5단계
3. ILIC 운영 GC 마스터
🚀 Phase 4 — G1 GC 심화
✅ Unit 4.1 참조 카운팅의 한계
✅ Unit 4.2 G1 GC의 등장 배경
✅ Unit 4.3 리전 기반 레이아웃
✅ Unit 4.4 거대 리전
✅ Unit 4.5 우선순위 기반 회수 ← 여기, Phase 4 완주
→ 박승제씨는 이제 ILIC 운영 GC 마스터
1주차에서 컬렉션을 큰 그림으로 봤다면, 2주차 Phase 5는:
→ Phase 4가 운영 실무였다면, Phase 5는 코드 실무 직결.
✅ Phase 1 — 자바 변수 ↔ 메모리 매핑 (1.1 ~ 1.6 완주)
✅ Phase 2 — JVM 메서드 실행 메커니즘 (2.1 ~ 2.4 완주)
✅ Phase 3 — 바이트코드와 상수 풀 (3.1 ~ 3.4 완주, 정점)
✅ Phase 4 — G1 GC 심화 (4.1 ~ 4.5 완주, 운영 마스터)
🚀 Phase 5 — 컬렉션 내부 구조 (다음)
⏭ Phase 6 — Reflection & Iterator
⏭ Phase 7 — Buffer
Phase 1: 6개 Unit (1.1 ~ 1.6)
Phase 2: 4개 Unit (2.1 ~ 2.4)
Phase 3: 4개 Unit (3.1 ~ 3.4) ★ 정점
Phase 4: 5개 Unit (4.1 ~ 4.5) — 운영 마스터
─────────────────────────────
누적: 19개 Unit
2주차의 약 80% 완주