들어가며
이전 편에서 CMS의 한계를 살펴봤습니다. Fragmentation, Concurrent Mode Failure, 예측 불가능한 pause time... 이 문제들을 해결하기 위해 완전히 새로운 접근이 필요했습니다.
이번 편에서는 G1(Garbage-First) GC를 다룹니다. Java 9부터 기본 GC가 된 G1은 기존 컬렉터들과 근본적으로 다른 구조를 가집니다.
G1의 설계 목표
G1은 다음을 목표로 설계되었습니다:
- 예측 가능한 pause time: 목표 시간 내에 GC 완료
- Compaction 포함: Fragmentation 문제 해결
- 대용량 힙 지원: 수십 GB 힙에서도 효율적
- CMS 대체: CMS의 장점은 유지, 단점은 해결
-XX:+UseG1GC (Java 9부터 기본)
-XX:MaxGCPauseMillis=200 (목표 pause time, 기본값)
Region 기반 힙 구조
G1의 가장 큰 변화는 힙 구조입니다.
기존 컬렉터
┌──────────────────┬────────────────────────────────┐
│ Young 영역 │ Old 영역 │
│ [Eden][S0][S1] │ [ 연속된 공간 ] │
└──────────────────┴────────────────────────────────┘
물리적으로 고정된 경계
G1
┌───────────────────────────────────────────────────────┐
│ [E][O][E][S][O][O][H][H][ ][E][O][S][ ][O][E][ ][O] │
└───────────────────────────────────────────────────────┘
E = Eden, S = Survivor, O = Old, H = Humongous, [ ] = Free
- 힙을 동일 크기의 Region으로 분할 (1MB ~ 32MB)
- 약 2048개 내외의 Region
- 각 Region의 역할이 동적으로 변경됨
- 세대 구분은 유지, 물리적 경계만 없앰
Region 크기 결정
힙 크기에 따라 자동 설정:
- 힙 / 2048 ≈ Region 크기
- 1MB ~ 32MB 범위 (2의 제곱수)
예: 8GB 힙 → 4MB Region
예: 32GB 힙 → 16MB Region
Humongous Object
Region 크기의 50%를 초과하는 객체는 Humongous로 분류됩니다:
일반 객체: 하나의 Region 안에 여러 개 존재
Humongous: 연속된 Region들을 독점
┌────────┬────────┬────────┐
│ H │ HC │ HC │ (Humongous Start + Continues)
│ (시작) │ (연속) │ (연속) │
└────────┴────────┴────────┘
큰 배열 등이 Humongous가 되며, Old 영역처럼 취급됩니다.
Remembered Set (RSet)
Region 기반에서 가장 까다로운 문제는 Cross-Region Reference입니다.
문제
Region A를 수집할 때:
- Region B에서 Region A를 참조하면?
- 전체 힙을 스캔? → 비효율적!
해결: Points-into RSet
각 Region은 "나를 가리키는 외부 참조"를 기록합니다:
Region A의 RSet (해시 테이블):
┌─────────────────────────────────────┐
│ Key: Region B 시작 주소 │
│ Value: {Card 3, Card 7, Card 12} │
├─────────────────────────────────────┤
│ Key: Region C 시작 주소 │
│ Value: {Card 5} │
└─────────────────────────────────────┘
의미: "B의 Card 3, 7, 12와 C의 Card 5에서 나(A)를 참조함"
왜 Points-into인가?
Points-out (내가 가리키는 곳):
Region A 수집 → A의 RSet만 봐도 소용없음
→ 전체 Region을 확인해야 "A를 가리키는 곳" 찾음
Points-into (나를 가리키는 곳):
Region A 수집 → A의 RSet만 보면 됨!
→ O(RSet 크기)로 외부 참조 찾기 가능
"양방향" 구조
모든 Region이 각자 RSet을 가지므로:
A → B 참조 있으면 → B의 RSet에 기록
B → A 참조 있으면 → A의 RSet에 기록
시스템 전체로는 어떤 방향이든 추적 가능
→ 책에서 "양방향"이라고 표현한 이유
RSet의 세 단계 Granularity
┌─────────────────────────────────────────┐
│ Sparse PRT: (Region, Card) 쌍 직접 저장 │ ← 참조 적을 때
├─────────────────────────────────────────┤
│ Fine PRT: Region → Card bitmap │ ← 참조 많을 때
├─────────────────────────────────────────┤
│ Coarse Map: Region 단위로만 표시 │ ← 너무 많으면
└─────────────────────────────────────────┘
메모리와 정밀도의 trade-off
TAMS (Top-at-Mark-Start)
Concurrent Marking 중 새로 할당된 객체를 구분하는 포인터입니다.
문제
Marking 시작: Region에 A, B, C 존재
Marking 중: 새 객체 D, E 할당
Marking 끝: D, E는 마킹 안 됐는데, 죽은 걸로 처리?
해결
Region 구조:
┌─────────────────────────────────────────────────────┐
│ ████████████████████░░░░░░░░░░░░░ │
│ ^ ^ ^ │
│ Bottom prevTAMS nextTAMS Top → │
└─────────────────────────────────────────────────────┘
████ = 이전 marking에서 확인된 영역
░░░░ = 현재 marking 대상
= marking 중 새로 할당 (암묵적 live)
객체가 live인지 판단:
if (객체 주소 >= nextTAMS) {
return true; // marking 이후 할당 → 무조건 live
} else {
return bitmap.isMarked(객체); // bitmap 확인
}
TAMS 덕분에 새 객체를 O(1)로 live 판정할 수 있습니다.
SATB (Snapshot-At-The-Beginning)
Concurrent Marking의 정확성을 보장하는 방식입니다.
CMS vs G1
| 항목 | CMS | G1 |
| 방식 | Incremental Update | SATB |
| 추적 대상 | 새로 생긴 참조 | 끊어진 참조 (이전 값) |
| 종료 예측 | 어려움 | 쉬움 |
SATB 동작
Marking 시작 시점의 객체 그래프를 "스냅샷"으로 간주
Write Barrier:
if (markingInProgress && oldValue != null) {
satbQueue.enqueue(oldValue); // 이전 참조 기록
}
[예시]
Marking 시작: A → B → C
Marking 중 앱이 변경:
A → B (B → C 참조 제거)
↓
D (B → D 참조 추가)
SATB:
- C의 이전 참조(B→C)가 끊어짐 → C를 SATB 큐에 기록
- C는 이번 사이클에서 live로 처리 (floating garbage 가능)
- 하지만 D를 놓치지 않음 (안전!)
SATB의 장점: Marking 시작 시점 기준이라 종료 시점이 예측 가능
GC 사이클 전체 흐름
┌─────────────────────────────────────────────────────┐
│ Young-only Phase │
│ Young GC → Young GC → ... → (IHOP 도달) │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Concurrent Marking Cycle │
│ │
│ Initial Mark (STW) │
│ - GC Root 직접 참조 마킹 │
│ - Young GC와 동시 수행 (piggyback) │
│ - TAMS 설정 │
│ ↓ │
│ Concurrent Marking │
│ - 애플리케이션과 동시 실행 │
│ - 객체 그래프 전체 탐색 │
│ - SATB로 변경 사항 기록 │
│ ↓ │
│ Remark (STW) │
│ - SATB 버퍼 처리 │
│ - 최종 live 객체 확정 │
│ ↓ │
│ Cleanup (일부 STW) │
│ - Region별 live 비율 계산 │
│ - 빈 Region 즉시 반환 │
│ - Mixed GC 대상 선정 │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Space Reclamation Phase │
│ Mixed GC → Mixed GC → ... → (Old 충분히 비워짐) │
└─────────────────────────────────────────────────────┘
│
▼
(다시 Young-only로)
IHOP (Initiating Heap Occupancy Percent)
Concurrent Marking을 시작하는 Old 점유율 임계치입니다:
-XX:InitiatingHeapOccupancyPercent=45 (기본값)
Java 9+에서는 Adaptive IHOP로 자동 조절됩니다.
Young GC vs Mixed GC
Young GC
트리거: Eden Region들이 가득 참
수집 대상: 모든 Eden + 모든 Survivor Region
[동작]
Eden Regions + Survivor Regions
↓
살아있는 객체만 복사(STW)
↓
새 Survivor 또는 Old Region
Mixed GC
트리거: Concurrent Marking 완료 후
수집 대상: Young 전체 + Old 일부 (garbage 많은 Region 우선)
[동작]
Young Regions + 선택된 Old Regions
↓
살아있는 객체만 복사(STW)
↓
새 Region들
"Garbage-First": 쓰레기가 가장 많은 Region부터 선택!
Mark-Copy 방식
G1은 Mark-Copy(Evacuation) 방식을 사용합니다:
[Collection Set]
┌─────────────────────────┐
│ [A][ ][B][ ][ ][C][ ] │ 수집 대상 Region
└─────────────────────────┘
↓ 살아있는 것만 복사
┌─────────────────────────┐
│ [A][B][C] │ 새 Region
└─────────────────────────┘
원본 Region → 즉시 Free로 반환
장점:
- 살아있는 객체만 다룸
- 복사하면서 자연스럽게 Compaction
- Region 단위 병렬 처리 용이
Pause Time Goal
G1의 핵심 철학입니다:
-XX:MaxGCPauseMillis=200 (기본값)
동작 원리
예측 모델:
"Region 하나 수집에 평균 N ms"
"목표 200ms면 Region 약 M개까지 가능"
Mixed GC:
→ Old Region을 M개만 선택
→ 나머지는 다음 Mixed GC에서 수집
이전 GC 통계를 기반으로 Collection Set 크기를 동적 조절합니다.
Soft Real-time: 보장은 아니지만 최대한 맞추려 노력
Full GC
G1도 Full GC가 발생할 수 있습니다:
발생 조건
- Allocation Failure: Mixed GC로 공간 확보가 할당 속도를 못 따라감
- To-space Exhausted: 객체를 이동시킬 빈 Region이 없음
- Humongous Allocation 실패
Full GC의 변화
| Java 버전 | Full GC 방식 |
| ~ Java 9 | Single-threaded (매우 느림) |
| Java 10+ | Parallel Full GC |
Java 10 이전에는 Full GC가 터지면 수십 초 pause가 발생할 수 있었습니다.
GC 로그 살펴보기
Young GC
[GC pause (G1 Evacuation Pause) (young), 0.0112345 secs]
[Parallel Time: 9.8 ms, GC Workers: 4]
[GC Worker Start (ms): Min: 123.4, Avg: 123.5, Max: 123.6]
[Ext Root Scanning (ms): Min: 0.3, Avg: 0.4, Max: 0.5]
[Update RS (ms): Min: 1.2, Avg: 1.3, Max: 1.4]
[Scan RS (ms): Min: 0.1, Avg: 0.2, Max: 0.3]
[Code Root Scanning (ms): ...]
[Object Copy (ms): Min: 7.5, Avg: 7.8, Max: 8.1]
[Eden: 24.0M(24.0M)->0.0B(24.0M)
Survivors: 4096.0K->4096.0K
Heap: 52.0M(128.0M)->28.5M(128.0M)]
- G1 Evacuation Pause (young): Young GC
- GC Workers: 4: 4개 스레드 병렬 처리
- Update RS: RSet 업데이트
- Object Copy: 실제 객체 복사 (가장 오래 걸림)
Mixed GC
[GC pause (G1 Evacuation Pause) (mixed), 0.0234567 secs]
...
[Eden: 24.0M(24.0M)->0.0B(20.0M)
Survivors: 4096.0K->4096.0K
Heap: 98.0M(128.0M)->65.0M(128.0M)]
- (mixed): Mixed GC
- Heap이 98MB → 65MB로 감소 (Old도 수집됨)
Concurrent Marking
[GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.0156789 secs]
[GC concurrent-root-region-scan-start]
[GC concurrent-root-region-scan-end, 0.0012345 secs]
[GC concurrent-mark-start]
[GC concurrent-mark-end, 0.0345678 secs]
[GC remark, 0.0023456 secs]
[GC cleanup 85M->65M(128M), 0.0012345 secs]
CMS와 비교
| 항목 | CMS | G1 |
| 힙 구조 | 연속적 Young/Old | Region 기반 |
| Compaction | 없음 | 있음 (Evacuation) |
| Concurrent Marking | Incremental Update | SATB |
| Old 수집 | 전체 Sweep | 선택적 Region (Mixed GC) |
| Pause 예측 | 어려움 | 목표 기반 조절 |
| Fragmentation | 문제됨 | 해결됨 |
| Fallback | Serial Old | Parallel Full GC (Java 10+) |
G1의 Trade-offs
| 장점 | 단점 |
| 예측 가능한 pause time | Write Barrier 오버헤드 |
| Compaction으로 fragmentation 해결 | RSet 메모리 오버헤드 (~5%) |
| 대용량 힙 지원 | 작은 힙에서 Parallel GC보다 비효율 |
| 튜닝 포인트 적음 | Full GC 시 여전히 긴 pause |
적합한 환경
- 힙 크기 4GB 이상
- pause time 요구사항이 있는 애플리케이션
- CMS에서 문제를 겪고 있는 경우
비적합한 환경
- 힙 크기 수백 MB 수준
- Throughput이 최우선인 배치 작업
- 메모리가 극도로 제한된 환경
정리
| 항목 | G1 |
| 힙 구조 | Region 기반 (동적 역할) |
| 세대 구분 | 유지 (물리적 경계 없음) |
| Young 수집 | Young GC (Mark-Copy) |
| Old 수집 | Mixed GC (선택적 Region) |
| Concurrent Marking | SATB 방식 |
| Pause 목표 | MaxGCPauseMillis (soft) |
| 기본 GC | Java 9+ |
시리즈 마무리
이 시리즈에서 클래식 가비지 컬렉터의 발전 흐름을 살펴봤습니다:
Serial/Serial Old (기초)
↓
ParNew/PS/Parallel Old (병렬화 → Throughput)
↓
CMS (동시성 → 저지연)
↓
G1 (Region 기반 → 예측 가능한 pause)
각 컬렉터는 이전 세대의 한계를 극복하기 위해 등장했습니다:
| 세대 | 문제 | 해결책 |
| Serial | 싱글 스레드 | 병렬화 (Parallel) |
| Parallel | 긴 STW | 동시성 (CMS) |
| CMS | Fragmentation | Region 기반 (G1) |
G1 이후로도 ZGC, Shenandoah 같은 초저지연 컬렉터들이 등장했습니다. 이들은 G1의 아이디어를 더 발전시켜 수 ms 이하의 pause time을 목표로 합니다.
GC의 역사는 곧 "STW를 줄이기 위한 끊임없는 노력"의 역사입니다. 이 흐름을 이해하면, 새로운 GC가 등장해도 "왜 이런 설계를 했는지"를 쉽게 파악할 수 있을 것입니다.