운영 중인 서비스에서 GC 로그를 보다가 MaxGCPauseMillis 옵션을 만졌는데, "그럼 이걸 더 줄이면 멈춤이 무조건 짧아지나?"라는 의문이 들었다. 찾다 보니 결국 "어떤 컬렉터를 쓰느냐"의 문제로 귀결됐다. JDK 9부터 기본값이 된 G1과, 저지연을 내세우는 ZGC가 멈춤(STW, Stop-The-World)을 다루는 방식이 근본적으로 다르기 때문이다.
이 글에서는 두 컬렉터가 힙을 어떻게 나누고, 언제 애플리케이션을 멈추며, 압축(compaction)을 어떤 식으로 처리하는지를 정리한다. 미세한 버전별 동작까지 단정하기보다는, Oracle의 HotSpot GC Tuning Guide에서 설명하는 큰 흐름을 따라가 보는 쪽으로 적었다.
두 컬렉터의 설계 목표를 한 문장으로 줄이면 이렇게 이해했다.
G1은 "정해 둔 멈춤 시간 목표 안에서 최대한 많은 쓰레기를 회수"하려 하고, ZGC는 "힙이 아무리 커도 멈춤 시간을 거의 일정하게 유지"하려 한다.
-XX:MaxGCPauseMillis, 기본 200ms)를 두고, 그 안에 처리할 수 있는 만큼만 수집 대상(collection set)에 담는 방식으로 알려져 있다.여기서 가장 중요한 차이는 "멈춤 시간이 무엇에 비례하는가"다. G1의 멈춤은 한 번에 처리하는 수집 대상의 크기에 영향을 받지만, ZGC는 무거운 작업 대부분을 애플리케이션과 동시에(concurrent) 돌리기 때문에 멈춤 자체가 짧게 묶인다.
G1은 힙을 보통 2048개 안팎의 리전으로 나눈다(리전 크기는 힙 크기에 따라 1MB~32MB 사이에서 정해지는 것으로 알려져 있다). 각 리전은 고정된 역할이 아니라 그때그때 Eden / Survivor / Old / Humongous(리전 크기의 절반을 넘는 큰 객체용) 역할을 맡는다.
수집은 크게 두 종류로 이해했다.
Old 영역을 정리하기 위한 동시 마킹 사이클(concurrent marking cycle) 은 힙 점유율이 임계치(-XX:InitiatingHeapOccupancyPercent, 기본 45%)를 넘으면 시작되는 것으로 알려져 있다. 이때 SATB(Snapshot-At-The-Beginning) 방식으로, 마킹 시작 시점의 객체 그래프 스냅샷을 기준으로 살아있는 객체를 식별한다. 다만 실제 객체 대피(이동)는 STW 구간에서 일어난다는 점이 ZGC와의 결정적 차이다.
한 가지 더 짚을 부분은 세대 간 참조 추적이다. Young GC를 할 때 Old 리전이 Young 객체를 참조하고 있으면 그것도 살아있는 것으로 봐야 하는데, 매번 Old 전체를 스캔하면 너무 비싸다. G1은 이를 위해 각 리전마다 Remembered Set(RSet) 을 두어 "나를 가리키는 다른 리전의 참조"를 기록해 둔다. 애플리케이션이 참조를 쓸 때 끼어드는 쓰기 배리어(write barrier) 가 이 RSet과 카드 테이블을 갱신하는 것으로 알려져 있다. ZGC가 읽을 때 배리어를 거는 것과 대비된다.
자주 헷갈렸던 지점: G1에서 MaxGCPauseMillis는 목표일 뿐 보장이 아니다. 너무 작게 잡으면 한 번에 회수하는 양이 줄어 GC가 더 자주 돌고, 결과적으로 처리량(throughput)이 떨어질 수 있다. 반대로 목표를 너무 키우면 한 번에 많이 회수해 처리량은 좋아지지만 개별 멈춤이 길어진다. 결국 G1 튜닝은 이 둘 사이의 타협이라고 이해했다.
ZGC가 멈춤 없이 객체를 옮길 수 있는 핵심 장치는 컬러드 포인터(colored pointers) 와 로드 배리어(load barrier) 로 이해했다.
이 구조 덕분에 마킹, 재배치(relocation = 압축), 참조 처리 같은 무거운 작업을 애플리케이션과 동시에 진행한다. STW 구간은 주로 루트 스캔처럼 동시에 처리하기 어려운, 짧은 동기화 지점에만 남는다고 알려져 있다. 핵심은 이 멈춤이 처리해야 할 객체 수가 아니라 GC 스레드 진입/종료 비용 같은 고정 작업에 가깝다는 점이다. 그래서 힙이 수십 GB~TB로 커지고 살아있는 객체가 늘어도 멈춤 시간이 그에 비례해 늘지 않는다.
한 가지 짚어 둘 점: JDK 21에서 세대 구분(Generational) ZGC 가 도입됐다. 초기 ZGC는 세대를 나누지 않았는데, 대부분의 객체가 금방 죽는다는 약한 세대 가설(weak generational hypothesis)을 활용하면 같은 저지연을 유지하면서 효율을 더 끌어올릴 수 있기 때문이다.
컬렉터 선택과 동작 확인은 보통 이렇게 한다.
# G1 명시적으로 켜기 (JDK 9+에서는 기본값)
java -XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-Xlog:gc*:file=gc-g1.log:time,uptime,level,tags \
-jar app.jar
# ZGC 켜기 (JDK 21+: 세대 구분 ZGC)
java -XX:+UseZGC -XX:+ZGenerational \
-Xlog:gc*:file=gc-z.log:time,uptime,level,tags \
-jar app.jar
G1 로그에서는 Pause Young (Normal), Pause Young (Mixed), Pause Remark 같은 STW 구간이 보인다. 반면 ZGC 로그에서는 Pause Mark Start, Concurrent Mark, Concurrent Relocate처럼 Pause가 붙은 줄은 아주 짧고, 무거운 작업은 대부분 Concurrent 줄로 찍힌다. 로그만 봐도 "멈추고 일하는" G1과 "동시에 일하는" ZGC의 차이가 드러난다.
엣지 케이스로, ZGC라도 객체 할당 속도가 회수 속도를 앞지르면 할당 지연(allocation stall) 이 생길 수 있다. "동시 컬렉터 = 절대 안 멈춤"이 아니라, 동시에 회수하는 속도를 따라잡을 수 있을 때 저지연이 유지된다고 이해하는 편이 정확하다.
아래는 지금까지 정리한 차이를 한눈에 본 것이다.
| 항목 | G1 | ZGC |
|---|---|---|
| 설계 목표 | 멈춤 시간 목표 내 처리량 균형 | 힙 크기와 무관한 저지연 |
| 멈춤 시간 | 수집 대상 크기에 영향받음(수십~수백 ms) | 매우 짧음(sub-ms 지향), 힙 크기와 거의 무관 |
| 압축 시점 | STW 대피 중 복사로 압축 | 애플리케이션과 동시에 재배치 |
| 핵심 배리어 | 쓰기 배리어(SATB, remembered set) | 로드 배리어 + 컬러드 포인터 |
| 세대 구분 | 항상 세대 구분 | 초기 비세대 → JDK 21부터 세대 구분 추가 |
| 적합 상황 | 일반적 균형, 중간 규모 힙 | 큰 힙 + 엄격한 지연 요구 |
핵심을 한 줄로 줄이면, G1은 "멈춤을 목표 안에 가두는" 컬렉터이고, ZGC는 "무거운 일을 동시에 처리해 멈춤 자체를 짧게 묶는" 컬렉터다. 그래서 멈춤 시간 목표를 더 줄인다고 G1이 ZGC처럼 되지는 않는다 — 둘은 멈춤을 다루는 알고리즘 자체가 다르다.
실무 선택 기준도 같은 맥락으로 정리했다. 응답 지연의 꼬리(tail latency)가 SLA에 직접 걸리고 힙이 큰 서비스라면 ZGC가 유리하지만, ZGC는 로드 배리어와 동시 작업에 드는 비용 때문에 순수 처리량에서는 G1보다 약간 손해를 보는 경향이 있다고 알려져 있다. 반대로 배치성 작업처럼 멈춤보다 총 처리량이 중요하면 G1(혹은 Parallel GC)이 더 맞을 수 있다. 결국 "멈춤이 비싼가, 처리량이 비싼가"를 먼저 정하는 게 컬렉터 선택의 출발점이라고 이해했다.
더 파고들 만한 주제로는 (1) ZGC 로드 배리어가 실제 어셈블리 수준에서 어떻게 끼어드는지, (2) G1의 Remembered Set과 카드 테이블이 세대 간 참조를 어떻게 추적하는지를 다음에 정리해 보려 한다.
-Xlog:gc* 로깅 옵션