F-LAB JAVA · 2주차 · Phase 4 · G1 GC 심화
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
-XX:G1HeapRegionSize 옵션을 어떻게 설정해야 하나?G1은 힙을 1MB~32MB 크기의 동일한 리전 수천 개로 분할하고, 각 리전의 역할을 동적으로 부여한다.
리전 간 참조는 Remembered Set으로 추적하고, Card Table로 효율화한다.
이 구조 덕에 G1은 "필요한 리전만" 회수할 수 있어 STW 시간을 통제한다.
| 시스템 | 비유 |
|---|---|
| 기존 GC | 창고를 "원자재", "반제품", "완제품" 큰 구역으로 나눔 |
| G1 리전 | 창고를 같은 크기의 1000개 격자로 분할 |
| 동적 역할 | 각 격자가 그때그때 원자재든 반제품이든 |
| Remembered Set | "격자 A에서 격자 B의 물건 사용 중" 메모 |
| Card Table | 큰 격자 안의 작은 카드 단위 추적 (효율) |
→ 부분 청소가 자유로움. "오늘은 격자 5, 27, 99만 청소"
1. 리전 분할의 본질 — 왜 동일 크기
2. 리전의 4가지 역할
3. 리전 크기 결정 — 자동 vs 수동
4. Remembered Set — 리전 간 참조 추적
5. Card Table — RSet의 효율화
6. Young Collection vs Mixed Collection
7. ILIC 실무 — 리전 크기 튜닝
8. 흔한 실수 + 디버깅
9. 면접 질문 + 자기 점검
기존 GC (Parallel, CMS):
┌──────── Young ─────────┬─────── Old ───────┐
│ Eden │ Surv │ Surv │ │
│ (큰 영역) │ 작음 │ 작음 │ (가장 큰 영역) │
└────────┴──────┴──────┴────────────────────┘
특징:
- 영역 크기 고정 (실행 중 변경 불가)
- 영역 위치 연속
- 한 영역 = 한 가지 역할
G1 GC:
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│E│O│S│ │O│E│ │S│ │O│O│E│ │ │S│O│E│
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
특징:
- 모든 리전 같은 크기 (1MB ~ 32MB)
- 리전 수: 2048개 권장 (목표)
- 각 리전 역할은 동적
- 시간이 지나면서 역할 변경
관리 단순화:
시간 예측 가능성:
비유 — 표준 컨테이너:
표준 컨테이너 (G1):
- 모든 컨테이너 20피트로 통일
- 어디든 같은 크기
- 운반/적재 단순화
- 처리 시간 예측 가능
비표준 (기존 GC):
- 컨테이너마다 크기 다름
- 매번 처리 방식 다름
- 시간 예측 어려움
각 리전은 시간에 따라 역할 변경:
시점 T1:
┌─┬─┬─┬─┬─┬─┐
│E│E│E│ │ │ │ ← Eden 3개 (앱이 객체 빠르게 할당 중)
└─┴─┴─┴─┴─┴─┘
시점 T2 (Eden 가득 → Young GC):
┌─┬─┬─┬─┬─┬─┐
│ │ │ │S│ │ │ ← Eden 비고, Survivor 1개에 생존 객체 모음
└─┴─┴─┴─┴─┴─┘
시점 T3 (앱이 계속 객체 생성):
┌─┬─┬─┬─┬─┬─┐
│E│E│ │S│ │ │ ← Eden 다시 생기고, Survivor 유지
└─┴─┴─┴─┴─┴─┘
시점 T4 (Survivor의 객체가 살아남아 Old로 승격):
┌─┬─┬─┬─┬─┬─┐
│E│E│E│O│ │ │ ← S → O (역할 변경)
└─┴─┴─┴─┴─┴─┘
→ 리전 자체는 그대로, 역할만 변경.
리전 단위로 쪼개면 어떤 이점이 있는가?
답:
1. 부분 회수: 필요한 리전만 처리 → STW 단축
2. 시간 예측: 리전당 시간 일정 → 정지시간 계산 가능
3. 유연성: 역할 동적 변경 → 메모리 활용 효율
4. 단편화 줄임: 빈 리전 재사용 쉬움
5. 확장성: 큰 힙에서도 동작 (수천 리전)
G1 리전의 역할:
E = Eden : 새 객체가 처음 할당되는 곳
S = Survivor : Young GC 후 살아남은 객체가 임시로
O = Old : 오래 살아남은 객체
H = Humongous : 거대 객체 전용 (Unit 4.4)
(빈 리전) : 아직 할당 안 됨
역할: 새 객체의 첫 거주지.
Shipment s = new Shipment("BL-001");
// → Eden 리전 중 하나에 할당
특징:
비율 (기본):
-XX:G1NewSizePercent=5 (최소)-XX:G1MaxNewSizePercent=60 (최대)역할: Young GC에서 살아남은 객체의 임시 거처.
Young GC 흐름:
Eden 가득 → Young GC 발동
↓
Eden + Survivor의 객체 중 살아있는 것:
Survivor 리전으로 복사 (Evacuation)
↓
새 Survivor 리전 + 비워진 Eden
특징:
-XX:MaxTenuringThreshold=15 (조정 가능)역할: 오래 살아남은 객체의 거주지.
승격(Promotion) 조건:
- Survivor에서 N번 살아남음 (Tenuring Threshold)
- 또는 Survivor 공간 부족
특징:
역할: 매우 큰 객체 전용.
거대 객체 기준:
객체 크기 > 리전 크기 / 2
예: 리전 16MB라면
9MB 객체 = Humongous
여러 리전을 연속 차지
특징:
역할: 아직 사용 안 된 리전.
빈 리전 풀:
- 새 Eden이 필요할 때 여기서 가져옴
- GC 후 회수된 리전이 여기로
- 재사용 가능
// ILIC의 일반적인 요청 처리
@GetMapping("/shipments/{id}")
public ShipmentResponse get(@PathVariable Long id) {
Shipment s = repository.findById(id).get(); // ← 객체 N개 생성
return ShipmentResponse.from(s); // ← 또 객체 생성
}
객체들의 생애:
1. 요청 처리 중:
Shipment, ShipmentResponse, Cargo 등 → Eden 리전
2. 요청 종료 직후:
대부분 객체가 참조 끊어짐 → 곧 Young GC 대상
3. Young GC 발동:
살아있는 것 → Survivor로 이동
대부분 → 그냥 회수
4. 시간이 지나면:
Spring Bean들 (ShipmentService 등) → Old로 승격
Repository 캐시 등 → Old에 살아있음
5. 더 오래:
Old gen에 살아있는 객체들이 누적
→ 가끔 Mixed GC로 Old 일부 회수
→ 전형적인 Spring 앱의 메모리 패턴.
G1은 힙 크기를 보고 리전 크기 자동 결정.
공식:
리전 크기 = Heap 크기 / 2048
(그리고 2의 제곱수로 반올림: 1MB, 2MB, 4MB, 8MB, 16MB, 32MB)
예시:
Heap 1GB → 1024/2048 = 0.5MB → 1MB 리전 → 약 1024개
Heap 4GB → 4096/2048 = 2MB → 약 2048개 ★
Heap 16GB → 16384/2048 = 8MB → 약 2048개
Heap 64GB → 64GB/2048 = 32MB → 약 2048개
Heap 128GB → 32MB 리전 (최대) → 약 4096개
→ G1은 약 2048개 리전을 목표.
-XX:G1HeapRegionSize=16m
값 제약:
장점:
단점:
장점:
단점:
리전 크기를 너무 작게 하면 어떤 문제가?
답:
1. 리전 수 폭증 → 관리 오버헤드 증가
2. Card Table 크기 증가 → 메모리 사용 ↑
3. 큰 객체가 자주 Humongous 처리 대상 → GC 효율 ↓
4. Remembered Set 갱신 빈도 ↑
일반 권장:
G1의 자동 결정 신뢰 (대부분 OK)
조정이 필요한 경우:
- 큰 객체 자주 생성 (16MB 캐시 객체 등)
→ 리전 크기 32MB로 늘려 Humongous 줄이기
- 작은 객체만 생성 + 정지시간 더 단축 원함
→ 리전 작게 (단, 보통 의미 큼 X)
- Heap 매우 큼 (128GB+)
→ 32MB 자동 선택. 그대로 OK
박승제씨가 ILIC에서 직접 조정할 일은 거의 없음.
모니터링 결과 큰 객체 문제 보이면 그때 조정.
리전 분할의 부작용:
Old 리전 A의 객체 ───참조──► Young 리전 B의 객체
Young GC를 하려면:
"B 리전의 객체 중 살아있는 것?"
→ Young 리전만 보면 안 됨!
→ Old 리전의 참조도 확인해야 함
만약 Old 전체를 매번 스캔하면?
→ Young GC가 Old 스캔만큼 느려짐
→ STW 시간 폭발 → G1의 의미 사라짐
각 리전이 자기를 가리키는 외부 참조를 기록.
Region B의 RSet:
"Region A의 카드 X에서 나에게 참조 있음"
"Region C의 카드 Y에서 나에게 참조 있음"
...
Young GC 시:
RSet (개념적):
┌─────────────────────────────────────┐
│ Region B의 RSet │
├─────────────────────────────────────┤
│ "Region A의 카드 0x123에 참조 있음" │
│ "Region C의 카드 0x456에 참조 있음" │
│ "Region X의 카드 0x789에 참조 있음" │
└─────────────────────────────────────┘
각 리전 = 자기 RSet 보유.
언제 RSet이 업데이트되나?
// 객체 필드에 참조 할당
shipment.cargo = c;
JVM의 동작:
1. 평소처럼 필드 할당 (putfield 명령)
2. Write Barrier 발동: "이게 리전 경계 넘는 참조인가?"
3. 만약 그렇다면 RSet 업데이트:
Write Barrier 비용:
- 모든 참조 할당마다 약간의 오버헤드
- 보통 2~5% 처리량 감소
- 트레이드오프: GC 속도 vs 일반 코드 속도
Heap이 클수록:
- 리전 수 많음
- RSet도 많음
- 메모리 사용량 증가 가능
추정:
- 일반: Heap의 1~3%
- 최악: Heap의 20%까지 (다수의 cross-region 참조)
ILIC 운영 시 RSet 크기 모니터링은:
# GC 로그에서 확인
-Xlog:gc+remset=trace
RSet 갱신을 매 참조 할당마다 하면?
→ 비용 너무 큼
해결책: 더 큰 단위로 추적
→ Card
Card Table:
- 힙을 512 byte 또는 1KB 단위 "카드"로 분할
- 각 카드마다 1 byte의 상태 플래그
- "dirty" / "clean"
힙 분할:
┌──────────────────────────────────────┐
│ Heap (예: 4GB) │
│ ┌────┬────┬────┬────┬────┬────┬────┐│
│ │카드│카드│카드│카드│카드│카드│ ... ││ ← 512B 단위
│ └────┴────┴────┴────┴────┴────┴────┘│
└──────────────────────────────────────┘
Card Table (별도 메모리):
┌──────────────────────────────────────┐
│ [0|0|1|0|1|0|0|0|1|0| ... ] │ ← 카드별 1바이트
│ ↑ ↑ ↑ ↑ │
│ clean dirty clean dirty │
└──────────────────────────────────────┘
1. 참조 할당 시:
shipment.cargo = c;
2. Write Barrier:
- shipment가 있는 카드의 위치 계산
- 그 카드를 "dirty" 마킹
- 끝. (RSet 직접 갱신 X)
3. 나중에 (백그라운드 또는 GC 직전):
- dirty 카드들을 스캔
- 카드 안의 모든 객체 검사
- cross-region 참조 발견 시 RSet 갱신
방법 1: 매번 RSet 갱신
- 정확함
- 매번 비싼 작업
- 처리량 손실 큼
방법 2: Card Table 사용 (G1의 선택)
- 약간의 부정확함 (dirty 카드에 cross-ref 없을 수도)
- 매우 빠른 마킹 (1 byte 쓰기)
- 백그라운드 처리 가능
Heap 4GB, Card 512 bytes:
카드 수 = 4GB / 512B = 8M개
Card Table 크기 = 8MB
Heap 32GB:
Card Table 크기 = 64MB
→ 무시 못할 메모리 비용.
→ 그래도 RSet 직접 갱신보다 훨씬 효율적.
G1은 백그라운드 스레드(Refinement)가:
- dirty 카드를 주기적으로 스캔
- 진짜 cross-region 참조 발견 시 RSet 갱신
- dirty → clean
이 스레드들이 처리 못 따라가면:
- dirty 카드 누적
- GC 시 한꺼번에 처리 → STW 증가
옵션:
-XX:G1ConcRefinementThreads=4 # Refinement 스레드 수
기본값은 시스템 코어 수에 따라 자동.
G1의 또 다른 핵심 메커니즘:
GC 시작 시점의 객체 그래프를 "스냅샷"
↓
GC 중에 변경된 참조는 따로 기록 (SATB Buffer)
↓
GC가 그 스냅샷 기준으로 마킹 작업
↓
변경분은 별도로 처리
→ GC 중에도 앱이 동작 가능 (Concurrent).
→ Phase 4.5에서 더 자세히.
G1의 GC 사이클:
1. Young Collection (Young GC)
- Eden + Survivor만 회수
- STW
- 빈번 (수 초~분 단위)
2. Mixed Collection
- Young + 일부 Old 리전 회수
- STW
- Concurrent Marking 후 발동
3. Full GC (최후의 수단)
- 전체 회수
- 매우 긴 STW
- 발생하면 안 됨 (튜닝 실패 의미)
1. Eden 리전들 가득 참
2. Young Collection 시작 (STW)
- 회수 대상: 모든 Eden + 현재 Survivor
3. 마킹:
- GC Roots에서 도달 가능한 객체 찾기
- RSet 사용 (Old → Young 참조)
4. Evacuation (복사):
- 살아있는 객체를 새 Survivor로
- 충분히 오래된 객체는 Old로 승격
- 또는 새 빈 리전 사용
5. Eden 비움, 사이클 종료
Before Young GC:
E1, E2, E3 (Eden, 가득)
S1 (Survivor)
O1, O2 (Old)
After Young GC:
(Eden 비었음)
S2 (새 Survivor, 살아남은 객체)
O1, O2, O3 (Old, 일부 승격 추가)
Old 사용량이 임계치 도달 (-XX:InitiatingHeapOccupancyPercent=45)
↓
Concurrent Marking 시작 (앱과 동시)
- GC Roots부터 그래프 탐색
- 살아있는 객체 마킹
- 각 Old 리전의 "garbage 비율" 계산
↓
Marking 완료
↓
Mixed Collection 시작 가능
1. Young + 가장 garbage 많은 Old 리전 선택
2. Mixed Collection 시작 (STW)
- Young 회수 + 선택된 Old 회수
- 일반 Young GC보다 약간 길음
3. 살아있는 객체를 다른 리전으로 복사
4. 빈 Old 리전 확보
→ Old 전체를 한 번에 처리하지 않고 여러 사이클에 걸쳐 점진 처리.
→ STW 시간 통제 가능.
발생 조건:
- Mixed Collection이 따라가지 못함
- Old 가득 + Concurrent Marking 실패
- 매우 큰 객체 할당 시 공간 부족
동작:
- Serial Full GC (단일 스레드, 매우 느림)
- 또는 Parallel Full GC (Java 10+)
- STW 수 초 ~ 수십 초
대응:
- Heap 크기 증가
- InitiatingHeapOccupancyPercent 조정
- 메모리 누수 확인
[gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 100M->10M(256M) 15.234ms
[gc] GC(1) Pause Young (Concurrent Start) (G1 Evacuation Pause) 110M->20M(256M) 18.567ms
[gc] GC(2) Pause Remark 22M->22M(256M) 5.123ms
[gc] GC(3) Pause Cleanup 22M->22M(256M) 1.234ms
[gc] GC(4) Pause Young (Mixed) (G1 Evacuation Pause) 22M->15M(256M) 30.456ms
[gc] GC(5) Pause Young (Normal) 50M->15M(256M) 12.789ms
해독:
(Normal): 일반 Young GC(Concurrent Start): Concurrent Marking 시작Remark: Concurrent Marking의 마무리 (짧은 STW)Cleanup: 리전 정리 (짧은 STW)(Mixed): Young + Old 일부 처리→ G1의 사이클이 로그로 보임.
대원칙: G1의 자동 설정 신뢰
튜닝 검토 시점:
1. Humongous 리전 발생 빈번 (GC 로그)
2. Young GC 시간이 일관되지 않음 (변동 큼)
3. 큰 객체 할당 패턴 (캐시, 대용량 컬렉션)
증상 (GC 로그):
Pause Young (Concurrent Start) (G1 Humongous Allocation) ...
↑ 이런 메시지가 자주
원인:
객체 > 리전 / 2
해결:
1. 리전 크기 증가
-XX:G1HeapRegionSize=32m
2. 큰 객체 자체를 작게 (코드 변경)
- 큰 배열을 작은 단위로 분할
- 큰 String → byte[] 처리
3. 캐시 라이브러리 검토
- 한 큰 객체 vs 여러 작은 객체
ILIC 운영에서 가장 중요한 작업:
# Java 11+의 통합 로그
java -Xlog:gc*:file=/var/log/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=100M \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-Xms4g -Xmx4g \
-jar ilic-app.jar
분석 도구:
체크 지표:
✓ 평균 GC STW 시간
✓ P99 GC STW 시간
✓ Full GC 빈도 (이상적: 0)
✓ Young vs Mixed 비율
✓ Concurrent Cycle 실패 여부
✓ Humongous 발생 여부
상황: "응답시간이 갑자기 길어졌어요"
# 1. GC 로그 확인
tail -100 /var/log/gc-*.log | grep "Pause"
# 결과:
[gc] GC(127) Pause Full (G1 Evacuation Pause) 3500.123ms
↑ Full GC 발생!
# 2. Heap dump 확보
jmap -dump:format=b,file=/tmp/heap.hprof <PID>
# 3. MAT로 분석
# - Histogram에서 의외로 많은 클래스 확인
# - "Path to GC Roots" 추적
# ILIC 권장 JVM 옵션
# === 메모리 ===
-Xms4g # 초기 힙
-Xmx4g # 최대 힙 (Xms와 동일 권장)
-XX:MetaspaceSize=256m # Metaspace 초기
-XX:MaxMetaspaceSize=512m # Metaspace 최대
# === G1 GC ===
-XX:+UseG1GC # G1 GC 명시
-XX:MaxGCPauseMillis=200 # 정지시간 목표
-XX:G1HeapRegionSize=8m # 리전 크기 (자동도 OK)
-XX:InitiatingHeapOccupancyPercent=45 # Concurrent Marking 시작 임계치
# === GC 로깅 ===
-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" # OOM 시 즉시 종료 (재시작 위해)
# === JFR (선택) ===
-XX:StartFlightRecording=disk=true,maxsize=1g,maxage=24h,filename=/var/log/jfr/app.jfr
정상 ILIC 앱 (Heap 4GB):
매분:
Young GC 약 3~5회
STW 평균: 20~50ms
P99 STW: 100~150ms
매시간:
Mixed GC 약 1~3회
STW: 30~80ms
매일:
Full GC: 0회 (이상적)
Concurrent Marking: 약 24~48회
→ 이 패턴 벗어나면 문제.
# ❌ 1MB 리전 강제 (작은 리전)
-XX:G1HeapRegionSize=1m
# 32GB Heap이라면?
# 리전 수 = 32GB / 1MB = 32,768개
# 메타데이터 폭증
→ 자동 결정 신뢰. 정말 필요할 때만 조정.
# ❌ 초기와 최대 다르게
-Xms256m -Xmx4g
# 문제:
# - JVM이 힙을 점진적으로 늘림
# - 늘릴 때마다 GC 발동
# - 워밍업 시간 증가
→ 운영은 -Xms와 -Xmx를 같은 값으로.
# ❌ 10ms 목표
-XX:MaxGCPauseMillis=10
# 문제:
# - G1이 회수할 수 있는 리전 수 줄어듦
# - 더 자주 GC 발동
# - Old 가득 차서 Full GC 위험
→ ILIC는 100~200ms가 적절. 그 이하는 ZGC 고려.
GC 로그:
Pause Full (Allocation Failure) 5000ms
"가끔 발생하는 거니까 괜찮아"
❌ 절대 아님:
- Full GC = 튜닝 실패 신호
- 누수 가능성 높음
- SLA 위반
조사:
1. Heap dump
2. Old gen 사용률 추적
3. Concurrent Marking 실패 여부
GC 로그에서:
- 갑자기 Young GC 시간 증가
- 원인: cross-region 참조 많아져서 RSet 처리 비용 ↑
가능성:
- 큰 객체 그래프 (Cargo가 수많은 Item 참조 등)
- 캐시가 다양한 리전의 객체 가리킴
해결:
GC 로그:
to-space exhausted
Full GC 발동
원인:
해결:
-XX:InitiatingHeapOccupancyPercent 낮춤 (Concurrent 일찍 시작)-XX:G1ReservePercent 증가 (예비 공간)# 1. GC 로그 실시간
tail -f /var/log/gc.log
# 2. JFR 분석
jcmd <PID> JFR.start duration=60s filename=app.jfr
# JDK Mission Control로 분석
# 3. RSet 정보
jcmd <PID> GC.heap_info
# 4. Heap 상세
jcmd <PID> GC.class_histogram
| Q | 핵심 답변 |
|---|---|
| G1이 리전으로 나누는 이유? | 부분 회수 + 시간 예측 + 유연성 |
| 리전 크기 결정? | Heap/2048, 2의 제곱수 자동. 1MB~32MB |
| 리전의 4가지 역할? | Eden, Survivor, Old, Humongous |
| Remembered Set이란? | 각 리전이 자기를 가리키는 외부 참조 기록 |
| RSet이 왜 필요? | Young GC 시 Old 전체 스캔 회피 |
| Card Table은? | 힙을 512B 카드로 분할. dirty 마킹으로 RSet 효율화 |
| Write Barrier 비용? | 참조 할당마다 ~2-5% 처리량 손실 |
| Young vs Mixed Collection? | Young만 vs Young + 일부 Old |
| Mixed Collection 트리거? | Concurrent Marking 완료 + Old 사용량 임계치 |
| Full GC 발생 시 대응? | 누수 확인, Heap 증가, InitiatingHeapOccupancyPercent 조정 |
1. G1의 핵심 = 동일 크기 리전 분할
2. Remembered Set + Card Table
3. GC 종류 3가지
이번 Unit에서 리전 모델을 봤다면, 다음은 큰 객체의 특수 처리.
🚀 Phase 4 — G1 GC 심화
✅ Unit 4.1 참조 카운팅의 한계
✅ Unit 4.2 G1 GC의 등장 배경
✅ Unit 4.3 리전 기반 레이아웃 ← 여기
⏭ Unit 4.4 거대 리전
⏭ Unit 4.5 우선순위 기반 회수
✅ Phase 1 — 자바 변수 ↔ 메모리 매핑 (1.1 ~ 1.6 완주)
✅ Phase 2 — JVM 메서드 실행 메커니즘 (2.1 ~ 2.4 완주)
✅ Phase 3 — 바이트코드와 상수 풀 (3.1 ~ 3.4 완주, 정점)
🚀 Phase 4 — G1 GC 심화 (3/5 진행)
⏭ Phase 5 — 컬렉션 내부 구조
⏭ Phase 6 — Reflection & Iterator
⏭ Phase 7 — Buffer