이전 포스트에서 Shenandoah가 Brooks Pointer를 사용해 동시 압축을 구현했다면, ZGC는 완전히 다른 접근 방식을 택한다. Colored Pointer와 Load Barrier라는 기법으로 같은 목표를 달성하면서도, 구현 전략은 정반대에 가깝다.
1. ZGC 개요
ZGC란?
ZGC(Z Garbage Collector)는 JDK 11에서 실험적 기능으로 처음 도입되고, JDK 15에서 프로덕션 레디 상태가 된 저지연(Low-Latency) 가비지 컬렉터다. Oracle이 개발했으며, 'Z'라는 이름에는 특별한 의미가 없다(ZFS 파일시스템에 대한 오마주라고 알려져 있다).
ZGC의 핵심 설계 목표는 다음과 같다.
- 초저지연: GC 일시 정지 시간을 서브밀리초(sub-millisecond) 수준으로 유지
- 확장성: 8MB부터 16TB까지의 힙 크기 지원
- 힙 크기와 무관한 일시 정지 시간: 힙이 아무리 커져도 일시 정지 시간이 증가하지 않음
Shenandoah가 Red Hat 주도로 개발되어 Oracle JDK에서 지원하지 않는 것과 달리, ZGC는 Oracle이 직접 개발한 컬렉터이므로 Oracle JDK와 OpenJDK 모두에서 사용할 수 있다.
2. ZGC의 핵심 기술
2.1 Colored Pointers (착색 포인터)
ZGC의 가장 핵심적인 혁신은 Colored Pointers다. 64비트 아키텍처에서 실제 메모리 주소 지정에는 48비트만 사용되므로, 나머지 비트를 메타데이터 저장에 활용할 수 있다. ZGC는 이 여유 비트에 GC 상태 정보를 삽입하여, 포인터 자체로 객체의 마킹 상태와 재배치 여부를 판단한다. Shenandoah가 객체 헤더에 포워딩 포인터를 추가하는 방식과는 근본적으로 다른 접근이다.
┌────────────────────────────────────────────────────────────────┐
│ 64-bit Object Pointer Layout (Non-Generational ZGC) │
├────────────────────────────────────────────────────────────────┤
│ [18 bits: 예약] [4 bits: 메타데이터] [42 bits: 객체 주소] │
│ ├─ Finalizable │
│ ├─ Remapped │
│ ├─ Marked0 │
│ └─ Marked1 │
└────────────────────────────────────────────────────────────────┘
각 메타데이터 비트의 역할은 다음과 같다.
| 비트 | 설명 |
| Marked0 / Marked1 | 객체의 마킹 상태를 나타낸다. GC 사이클마다 번갈아가며 사용한다. |
| Remapped | 참조가 재배치된 객체의 새 위치를 가리키는지 표시한다. |
| Finalizable | Finalizer 처리가 필요한 객체인지 표시한다. |
이 메타데이터 비트들이 곧 "색상(Color)"이다. ZGC는 포인터의 색상으로 현재 상태를 판단한다.
- Good color: 포인터가 유효하고 올바른 위치를 가리킴
- Bad color: 포인터가 재배치되었거나 추가 처리가 필요함
Colored Pointer의 큰 장점 중 하나는 객체의 마킹 상태와 재배치 여부를 포인터 자체에서 판단할 수 있다는 것이다. 참조를 읽는 시점에 포인터의 색상만 확인하면 되므로, 참조를 덮어쓸 때 별도로 GC에 알릴 필요가 없다. 즉 Non-generational ZGC에서는 Write Barrier(쓰기 장벽) 없이 Load Barrier(읽기 장벽)만으로 동시 수집이 가능했다. 이는 G1의 Write Barrier나 Shenandoah의 Read/Write Barrier 조합과 비교했을 때 쓰기 경로의 오버헤드를 줄이는 설계 선택이었다.
참고: 이후 Generational ZGC에서는 세대 간 참조 추적을 위해 Store Barrier가 도입된다. "Write Barrier가 없다"는 것은 Non-generational ZGC에 한정된 특성이다.
2.2 Load Barrier (읽기 장벽)
Colored Pointer 자체는 상태를 기록할 뿐, 행동을 취하지는 않는다. 실제 행동을 담당하는 것이 Load Barrier다.
Load Barrier는 애플리케이션이 힙에서 객체 참조를 읽을 때마다 JIT 컴파일러가 삽입하는 코드 조각이다.
// Load Barrier 개념적 의사코드
Object loadFromHeap(Object* field) {
Object* ptr = *field;
// Load Barrier: 포인터 색상 확인
if (isBadColor(ptr)) {
ptr = slowPath(ptr); // 포인터 치유(healing)
}
return ptr;
}
Load Barrier의 동작을 정리하면:
- 포인터의 색상(메타데이터 비트)을 확인한다.
- Bad color인 경우, Forwarding Table을 확인하여 객체의 새 위치를 조회하고, 포인터를 새 위치로 업데이트하며, 색상을 good color로 변경한다.
- 업데이트된 포인터를 반환한다.
이 메커니즘을 Self-Healing이라고 부른다. 애플리케이션 스레드가 stale 포인터를 만나면 자동으로 수정하기 때문이다. 한 번 치유된 포인터는 다시 slow path를 통과할 필요가 없어서 오버헤드가 점차 줄어든다.
2.3 Multi-Mapped Memory
착색 포인터에는 한 가지 풀어야 할 문제가 있다. 메타데이터 비트가 포함된 포인터를 그대로 역참조(dereference)하면 잘못된 메모리 주소에 접근하게 된다. 올바른 주소를 얻으려면 매번 메타데이터 비트를 제거해야 하는데, 이는 추가 CPU 명령어를 필요로 한다.
Non-generational ZGC는 이 문제를 Multi-Mapped Memory로 해결했다. 같은 물리 메모리를 세 개의 다른 가상 주소 범위에 매핑하는 것이다.
Virtual Memory Layout:
┌────────────────────────────┐ 0x0000140000000000 (20TB)
│ Remapped View │
├────────────────────────────┤ 0x0000100000000000 (16TB)
│ (Reserved) │
├────────────────────────────┤ 0x00000c0000000000 (12TB)
│ Marked1 View │
├────────────────────────────┤ 0x0000080000000000 (8TB)
│ Marked0 View │
├────────────────────────────┤ 0x0000040000000000 (4TB)
│ │
└────────────────────────────┘
세 개의 View가 모두 같은 물리 메모리를 가리킨다!
이렇게 하면 Marked0, Marked1, Remapped 중 어떤 색상 비트가 설정되어 있든 해당 가상 주소 범위를 통해 같은 물리 메모리에 접근할 수 있다. 메타데이터 비트를 별도로 제거할 필요가 없어지는 것이다.
다만 이 방식에는 부작용이 있다. ps 같은 OS 도구에서 ZGC 프로세스의 메모리 사용량이 실제의 약 3배로 보인다. 같은 물리 메모리를 세 번 매핑하기 때문이다.
참고: Generational ZGC(JDK 21+)에서는 Multi-Mapped Memory를 사용하지 않는다. 이 부분은 뒤에서 자세히 다룬다.
3. ZGC 사이클
ZGC는 3번의 짧은 STW 일시 정지와 여러 동시(Concurrent) 단계로 구성된다. 각 STW 일시 정지는 대부분 1ms 이하로, 힙 크기와 무관하다.
┌──────────────────────────────────────────────────────────────────┐
│ ZGC Cycle │
├──────────────────────────────────────────────────────────────────┤
│ │
│ [STW] Pause Mark Start (표시 시작) │
│ │ └─ GC Root 마킹, Good Color 설정 │
│ ▼ │
│ [Concurrent] Mark / Remap (동시 표시 / 동시 재매핑) │
│ │ └─ 전체 객체 그래프 순회, 살아있는 객체 마킹 │
│ ▼ │
│ [STW] Pause Mark End (표시 종료) │
│ │ └─ 마킹 완료 동기화, 엣지 케이스 처리 │
│ ▼ │
│ [Concurrent] Reference Processing (동시 참조 처리) │
│ │ └─ Weak/Soft/Phantom Reference 처리 │
│ ▼ │
│ [Concurrent] Relocation Set Selection (동시 재배치 준비) │
│ │ └─ 재배치할 Region 선택 (단편화 심한 것 우선) │
│ ▼ │
│ [STW] Pause Relocate Start (재배치 시작) │
│ │ └─ Root가 가리키는 객체만 재배치 │
│ ▼ │
│ [Concurrent] Relocate (동시 재배치) │
│ └─ 나머지 객체 재배치, Forwarding Table 업데이트 │
│ │
└──────────────────────────────────────────────────────────────────┘
각 단계 상세
1. Pause Mark Start — 표시 시작 (STW)
GC Root(스택 변수, 정적 필드 등)에서 직접 참조되는 객체를 마킹하고, 현재 사이클의 "good color"를 설정한다. Root의 수에 비례하는 매우 짧은 작업이다.
2. Concurrent Mark / Remap — 동시 표시 / 동시 재매핑
전체 객체 그래프를 순회하며 도달 가능한 객체를 마킹한다. 동시에 이전 사이클에서 재배치된 참조들의 재매핑도 처리한다. 이 단계에서 Load Barrier가 마킹되지 않은 참조를 발견하면 마킹에 참여(Self-Healing)하므로, 애플리케이션 스레드도 GC에 기여한다.
3. Pause Mark End — 표시 종료 (STW)
마킹을 완료하고 동기화한다. SATB(Snapshot-At-The-Beginning) 스타일의 동시 마킹에서 누락된 엣지 케이스를 처리한다.
4. Concurrent Reference Processing — 동시 참조 처리
Weak Reference, Soft Reference, Phantom Reference, Finalizer 큐를 처리한다. 이 단계도 애플리케이션과 동시에 실행된다.
5. Relocation Set Selection — 동시 재배치 준비
전체 힙을 대상으로 가비지 비율이 높은(단편화가 심한) Region들을 선택하여 재배치 집합(Relocation Set)을 구성한다.
"쓰레기가 가장 많은 곳을 먼저"라는 점에서 G1의 Garbage First 전략과 유사해 보이지만, 목적이 다르다.
G1의 회수 집합(Collection Set)은 STW 안에서 객체를 복사해야 하므로, 일시 정지 시간 목표(MaxGCPauseMillis) 내에서 처리할 수 있는 만큼만 집합 크기를 조절한다. 시간 예산이 핵심 제약이다. 반면 ZGC의 재배치 집합은 이후 동시 재배치 단계에서 애플리케이션과 함께 처리하므로, 일시 정지 시간 예산에 맞출 필요가 없다. 단편화 해소가 목적이며, 필요한 만큼 자유롭게 Region을 선택할 수 있다.
6. Pause Relocate Start — 재배치 시작 (STW)
GC Root가 직접 가리키는 객체만 재배치한다. Root의 수에 비례하는 매우 짧은 작업이다.
7. Concurrent Relocate — 동시 재배치
ZGC 사이클에서 가장 핵심적인 단계다. 재배치 집합에 포함된 Region의 생존 객체들을 애플리케이션 실행 중에 새 Region으로 복사한다.
Forwarding Table
재배치 시 Region 단위로 Forwarding Table을 생성하여 이전 주소 → 새 주소 매핑을 관리한다. Shenandoah가 객체 헤더(초기에는 Brooks Pointer, 이후 Mark Word)에 포워딩 정보를 저장하는 것과 달리, ZGC는 객체 외부의 별도 테이블에 매핑 정보를 유지한다.
GC 스레드와 애플리케이션 스레드의 경합
동시 재배치 중에는 GC 스레드가 객체를 옮기는 동시에 애플리케이션 스레드가 같은 객체에 접근할 수 있다. 이 경합은 Load Barrier가 해결한다.
- 애플리케이션 스레드가 재배치 집합에 속한 객체에 접근하면, Load Barrier가 Forwarding Table을 확인한다.
- 이미 재배치되었다면 새 주소로 리다이렉트한다.
- 아직 재배치되지 않았다면 애플리케이션 스레드가 직접 해당 객체를 재배치하고 Forwarding Table에 등록한다.
이 방식 덕분에 GC 스레드와 애플리케이션 스레드가 동시에 안전하게 재배치 작업을 수행할 수 있다.
즉시 Region 해제
재배치 집합에 속한 Region의 생존 객체 복사가 모두 끝나면, 참조 갱신이 완료되지 않았더라도 해당 Region의 메모리를 즉시 회수하여 재활용할 수 있다. Forwarding Table이 이전 주소와 새 주소의 매핑을 별도로 관리하고 있으므로, 원본 Region을 유지할 필요가 없기 때문이다.
Shenandoah는 동시 참조 갱신(Concurrent Update References) 단계에서 힙 전체의 참조를 새 주소로 갱신한 뒤에야 원본 Region을 해제할 수 있다. 이 차이가 ZGC의 메모리 회수 속도를 더 빠르게 만드는 요인 중 하나다.
참조 갱신의 위임
재배치 후 힙에 남아있는 stale 참조(여전히 이전 주소를 가리키는 참조)를 즉시 전부 갱신하지 않는다. 대신 두 가지 경로로 점진적으로 갱신한다.
- Load Barrier의 Self-Healing: 애플리케이션이 stale 참조에 접근하면 그 시점에 새 주소로 갱신한다.
- 다음 사이클의 동시 재매핑: 다음 GC 사이클의 동시 표시 단계에서 객체 그래프를 순회하면서 남아있는 stale 참조들을 일괄 갱신한다.
힙 전체를 순회하며 참조를 갱신하는 별도 단계가 불필요하므로, 사이클 구조가 단순해지고 GC 시간도 단축된다.
Shenandoah와의 차이
Shenandoah는 9단계로 구성되며, 재배치(Evacuation) 후 별도의 동시 참조 갱신(Concurrent Update References) 단계에서 힙 전체의 참조를 갱신한다. 반면 ZGC는 참조 갱신을 별도 단계로 두지 않고, Load Barrier의 Self-Healing과 다음 사이클의 동시 재매핑에 위임한다. 이 때문에 ZGC의 사이클이 더 단순하며, 재배치 완료 즉시 Region을 해제할 수 있어 메모리 회수도 더 빠르다.
4. Non-Generational ZGC의 한계
초기 ZGC(JDK 11~20)는 단일 세대(Single-Generation) 컬렉터였다. 세대 구분을 하지 않는 대신 모든 객체를 동일하게 취급했는데, 이 방식에는 몇 가지 구조적 한계가 있었다.
4.1 전체 힙 스캔 오버헤드
모든 GC 사이클에서 전체 힙을 스캔해야 했다. 세대 구분이 없으므로 수명이 짧은 객체와 긴 객체를 구분 없이 매번 전부 마킹해야 하고, 힙이 커질수록 마킹 단계에서 더 많은 CPU 리소스가 소모되었다.
4.2 Allocation Stall 문제
Allocation Stall은 객체 할당 속도가 GC의 메모리 회수 속도보다 빠를 때 발생한다.
┌──────────────────────────────────────────────────────────┐
│ │
│ 할당 속도 >>>>>>>> GC 회수 속도 │
│ │
│ → 새 객체를 위한 공간 부족 │
│ → 애플리케이션 스레드가 GC 완료를 기다림 (Stall!) │
│ │
└──────────────────────────────────────────────────────────┘
단일 세대에서는 전체 힙을 한 바퀴 돌아야 메모리를 회수할 수 있으므로, 할당률이 높은 애플리케이션에서 GC가 할당을 따라잡지 못하는 상황이 빈번했다. 이 경우 STW와 다름없는 실질적 정지가 발생한다.
4.3 세대 가설 미활용
결국 Non-generational ZGC의 근본적인 문제는 약한 세대 가설을 활용하지 못한다는 것이다. 대부분의 객체가 생성 직후 죽는다는 사실을 알면서도, 수명이 짧은 객체와 긴 객체를 동일하게 취급해야 했다. 이 문제를 해결하기 위해 Generational ZGC가 등장한다.
5. Generational ZGC
5.1 개요
JDK 21에서 JEP 439를 통해 도입된 Generational ZGC는 위의 한계들을 해결하기 위해 힙을 Young Generation과 Old Generation으로 논리적으로 분리한다.
┌─────────────────────────────────────────────────────────────┐
│ Generational ZGC Heap │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────┐ ┌────────────────────────────┐ │
│ │ Young Generation │ │ Old Generation │ │
│ │ │ │ │ │
│ │ - 새로 할당된 객체 │ │ - 오래 살아남은 객체 │ │
│ │ - 자주 수집 (Minor) │ │ - 덜 자주 수집 (Major) │ │
│ │ - Remembered Set 활용 │ │ - 전체 마킹 필요 │ │
│ │ │ │ │ │
│ └───────────────────────┘ └────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
설계 목표는 명확하다.
| 목표 | 설명 |
| Allocation Stall 위험 감소 | Young 객체를 자주 수집하여 메모리 회수 속도 향상 |
| GC CPU 오버헤드 감소 | 전체 힙이 아닌 Young Generation만 자주 스캔 |
| 힙 메모리 오버헤드 감소 | 단명 객체를 빠르게 회수하여 효율적 메모리 사용 |
| 기존 특성 유지 | 서브밀리초 일시 정지, 대용량 힙 지원 등 |
5.2 Multi-Mapped Memory 제거
Generational ZGC의 중요한 설계 변경 중 하나는 Multi-Mapped Memory를 사용하지 않는다는 것이다.
Non-generational ZGC에서는 착색 포인터의 메타데이터 비트 때문에 Multi-Mapping이 필요했다. 하지만 Generational ZGC는 Load Barrier와 Store Barrier에서 명시적 코드로 메타데이터 비트를 처리하는 방식으로 전환했다.
이 변경의 장점은 다음과 같다.
ps같은 도구에서 메모리 사용량이 정확하게 표시된다 (3배 부풀림 해소).- 메타데이터 비트가 가상 주소 공간에 종속되지 않으므로, 더 많은 메타데이터 비트를 추가할 수 있다.
- 최대 힙 크기 제한(16TB)을 넘어설 수 있는 가능성이 열렸다.
5.3 Store Barrier (쓰기 장벽) 도입
Non-generational ZGC는 Load Barrier만 사용했지만, Generational ZGC는 Store Barrier도 사용한다. 세대를 구분하면 Old → Young 참조를 추적해야 하기 때문이다.
// Store Barrier 개념적 의사코드
void storeToField(Object obj, Object* field, Object newValue) {
Object oldValue = *field;
// Store Barrier
if (needsProcessing(oldValue, newValue)) {
// 1. SATB 마킹: 덮어쓰기 전 값을 마킹하여 동시 마킹 중 객체 누락 방지
markForSATB(oldValue);
// 2. Remembered Set 업데이트: 세대 간 참조 추적
if (isOldToYoung(obj, newValue)) {
recordInRememberedSet(field);
}
}
// 실제 저장
*field = colorPointer(newValue);
}
Store Barrier는 두 가지 역할을 한다.
- SATB (Snapshot-At-The-Beginning) 마킹: 덮어쓰기 전의 참조를 마킹하여 동시 마킹 중 객체가 누락되는 것을 방지한다.
- Remembered Set 유지: Old Generation에서 Young Generation을 가리키는 포인터를 추적한다.
5.4 Remembered Set
Generational ZGC의 Remembered Set은 기존 방식과 다르게 Double-Buffered Bitmap 구조를 사용한다.
┌──────────────────────────────────────────────────────┐
│ Double-Buffered Remembered Set │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Active Bitmap │ │ Read-Only Bitmap │ │
│ │ │ │ │ │
│ │ App 스레드가 │←swap→│ GC 스레드가 │ │
│ │ 새 참조를 기록 │ │ 읽어서 처리 │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ 정밀도: 1비트 = 1개 필드 주소 │
│ 장점: App과 GC가 별도 비트맵 → 동기화 오버헤드 최소화│
└──────────────────────────────────────────────────────┘
G1의 Card Table은 512바이트 힙 영역당 1바이트로, 더티 카드에 해당하는 전체 영역을 스캔해야 한다. 반면 Generational ZGC의 Remembered Set은 필드 단위의 정밀한 추적이 가능하다.
Young GC 시작 시 두 비트맵이 원자적으로 교체된다. 애플리케이션 스레드는 새 Active Bitmap에 기록하고, GC 스레드는 교체된 Read-Only Bitmap을 처리한다. 두 작업이 서로 다른 비트맵에서 이루어지므로 동기화 오버헤드가 거의 없다.
5.5 Minor GC와 Major GC
Generational ZGC는 두 종류의 GC를 동시에 실행할 수 있다.
| Minor GC (Young Collection) | Major GC (Old Collection) | |
| 대상 | Young Generation | Old Generation |
| 빈도 | 자주 실행 | 덜 자주 실행 |
| Root | GC Root + Remembered Set (Old → Young) | GC Root + Young → Old 참조 |
| 특징 | 빠른 메모리 회수, 대부분의 단명 객체 수집 | 더 많은 CPU 필요, 오래된 쓰레기 수집 |
두 GC가 완전히 독립적이지는 않다. 서로 상호작용하는 경우가 있어 구현 복잡도가 높지만, 사용자 관점에서는 별도 설정 없이 ZGC가 자동으로 두 수집기를 조율한다.
5.6 Colored Pointer의 변화
Generational ZGC에서는 착색 포인터의 구조도 변경되었다. 세대 구분, Remembered Set 관련 정보 등 더 많은 메타데이터가 필요하기 때문이다.
Root에서 사용하는 포인터는 colorless pointer(색상이 없는 포인터)로, 메타데이터 비트 없이 직접 역참조할 수 있다. 반면 힙의 객체 필드에 저장된 참조는 colored pointer로, Load/Store Barrier를 통해 처리된다. 이 두 타입은 C++ 수준에서 구분되어 암묵적 변환이 불가능하게 설계되었다.
6. Shenandoah와의 비교
같은 "저지연 동시 컬렉터"를 목표로 하지만 접근 방식이 상당히 다르다.
| 항목 | ZGC | Shenandoah |
| 개발 | Oracle | Red Hat |
| 도입 | JDK 11 | JDK 12 |
| Oracle JDK 지원 | O | X |
| 동시 이동 기법 | Colored Pointer + Load Barrier | Brooks Pointer (→ Load Reference Barrier) |
| 포인터 오버헤드 | 포인터 비트 활용 (객체 크기 변화 없음) | 초기 객체당 1 word (JDK 14부터 제거) |
| Compressed Oops | 미지원 (64비트 포인터 필수) | 지원 |
| 세대 구분 | JDK 21+ (Generational ZGC) | JDK 24+ (Generational Shenandoah) |
| Multi-Mapped Memory | 초기 사용, Generational ZGC에서 제거 | 미사용 |
| 일시 정지 목표 | 서브밀리초 | 1~10ms (스택 워터마크 도입 후 서브밀리초 가능) |
Compressed Oops 미지원은 ZGC의 알려진 단점이다. 모든 포인터가 64비트여야 하므로, G1이나 Shenandoah 대비 메모리 사용량이 약 15~30% 더 높을 수 있다.
7. 사용 방법
JDK 21~23
# Generational ZGC 명시적 활성화 (JDK 21)
java -XX:+UseZGC -XX:+ZGenerational -Xmx16g -Xms16g YourApplication
# JDK 23부터는 Generational이 기본값이므로 -XX:+ZGenerational 불필요
java -XX:+UseZGC -Xmx16g -Xms16g YourApplication
주요 튜닝 옵션
| 옵션 | 설명 |
| -Xmx | 최대 힙 크기. 가장 중요한 옵션. ZGC는 동시 수집 중에도 할당이 가능해야 하므로 충분한 여유(25~35%)가 필요하다. |
| -XX:ConcGCThreads | 동시 GC 스레드 수. JDK 17+에서는 동적 조절되므로 대부분 설정 불필요. |
| -XX:SoftMaxHeapSize | 소프트 최대 힙 크기. 이 크기를 넘으면 ZGC가 더 적극적으로 수집하지만, 강제 제한은 아니다. |
| -XX:-ZUncommit | 미사용 메모리의 OS 반환을 비활성화. 지연 시간에 민감한 경우 설정 고려. |
8. 언제 ZGC를 선택해야 하나?
ZGC가 적합한 경우
- 서브밀리초 수준의 저지연이 최우선인 애플리케이션 (고빈도 트레이딩, 실시간 게임 서버, 인터랙티브 웹 서비스)
- 대용량 힙(수십 GB 이상)을 사용하는 인메모리 데이터베이스, 캐시 서버
- SLA로 99.9 퍼센타일 응답 시간을 보장해야 하는 서비스
- 충분한 메모리 여유(25% 이상)를 확보할 수 있는 환경
ZGC가 부적합할 수 있는 경우
- 매우 작은 힙(수백 MB 이하)이나 제한된 컨테이너 환경에서는 G1이 더 효율적일 수 있다.
- 처리량(throughput)만 중요하고 지연은 상관없는 배치 작업에서는 Parallel GC가 나은 처리량을 제공한다.
- CPU가 이미 포화 상태인 환경. ZGC는 동시 작업을 위해 G1 대비 5~10% 더 많은 CPU를 사용한다.
- Compressed Oops가 필요한 메모리 제약 환경. ZGC는 64비트 포인터만 사용하므로 메모리 오버헤드가 있다.
9. 결론
ZGC는 Colored Pointer라는 독창적인 기법으로 서브밀리초 수준의 일시 정지를 달성한 컬렉터다. Generational ZGC의 도입으로 초기의 한계였던 Allocation Stall과 전체 힙 스캔 문제를 해결했으며, JDK 24부터는 유일한 ZGC 모드로 자리잡았다.
JDK 25는 Generational ZGC를 포함한 첫 번째 LTS 릴리스다. Non-generational 대비 약 10%의 처리량 향상과 p99 일시 정지 시간 개선이 보고되고 있으며, Uber와 Netflix 등 대규모 서비스에서도 메모리 사용량 감소와 CPU 오버헤드 절감 효과를 확인했다.
Java 애플리케이션의 p99 지연 시간이 문제라면, JDK 21 이상으로 업그레이드하고 -XX:+UseZGC를 사용해 보는 것을 권장한다. 별도 튜닝 없이도 대부분의 워크로드에서 의미 있는 개선을 경험할 수 있다.
참고 자료
- JVM 밑바닥까지 파헤치기