본문 바로가기

CS/JVM

[JVM 밑바닥까지 파헤치기] JVM 가비지 컬렉터의 역사 (4) - G1: 새로운 패러다임

들어가며

이전 편에서 CMS의 한계를 살펴봤습니다. Fragmentation, Concurrent Mode Failure, 예측 불가능한 pause time... 이 문제들을 해결하기 위해 완전히 새로운 접근이 필요했습니다.

 

이번 편에서는 G1(Garbage-First) GC를 다룹니다. Java 9부터 기본 GC가 된 G1은 기존 컬렉터들과 근본적으로 다른 구조를 가집니다.

 


G1의 설계 목표

G1은 다음을 목표로 설계되었습니다:

  1. 예측 가능한 pause time: 목표 시간 내에 GC 완료
  2. Compaction 포함: Fragmentation 문제 해결
  3. 대용량 힙 지원: 수십 GB 힙에서도 효율적
  4. 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가 발생할 수 있습니다:

발생 조건

  1. Allocation Failure: Mixed GC로 공간 확보가 할당 속도를 못 따라감
  2. To-space Exhausted: 객체를 이동시킬 빈 Region이 없음
  3. 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가 등장해도 "왜 이런 설계를 했는지"를 쉽게 파악할 수 있을 것입니다.