ZGC 내부 구조 — Colored Pointer, Load Barrier, 그리고 멈추지 않는 객체 이동
GC가 객체를 옮기는데도 애플리케이션은 멈추지 않아요. 이게 어떻게 가능한지 궁금했던 분들을 위한 글이에요. ZGC가 포인터에 "색"을 칠하고, 참조를 읽는 순간마다 작은 코드를 끼워 넣어 멈춤 없는 압축을 해내는 원리를 따라가 봐요.
GC 이야기를 하다 보면 늘 같은 벽에 부딪혀요. 객체를 옮기려면(compaction) 멈춰야 한다는 벽이에요. 살아있는 객체를 새 자리로 복사하고, 그 객체를 가리키던 모든 포인터를 새 주소로 고쳐 줘야 하는데, 그 사이에 애플리케이션이 옛 주소를 읽어버리면 큰일이 나거든요. 그래서 전통적인 GC는 압축하는 동안 세상을 멈춰요(Stop-The-World).
ZGC는 이 전제를 깨요. 객체를 옮기는 그 순간에도 애플리케이션 스레드는 계속 달려요. 일시 정지는 보통 1밀리초보다 짧고, 힙이 수십 GB든 수 TB든 그 시간은 거의 변하지 않아요. 오늘은 ZGC가 이걸 어떻게 해내는지, Colored Pointer와 Load Barrier라는 두 축을 중심으로 내부를 들여다볼게요.
왜 일시 정지가 힙 크기에 비례했을까
ZGC의 설계를 이해하려면, 먼저 기존 GC의 일시 정지가 왜 힙에 비례했는지를 짚고 가야 해요.
전통적인 압축형 GC는 STW 구간에서 대략 이런 일을 해요.
- 살아있는 객체를 찾아 표시(mark)해요.
- 살아있는 객체를 새 영역으로 복사(relocate)해요.
- 그 객체를 가리키던 모든 참조를 새 주소로 고쳐(remap) 줘요.
문제는 두 번째와 세 번째예요. 힙이 커지면 살아있는 객체가 많아지고, 복사할 양도, 고쳐야 할 포인터도 그만큼 늘어나요. 일시 정지 시간이 힙 크기(정확히는 live set 크기)에 끌려다니는 거예요.
ZGC의 목표는 분명해요. STW 구간에서 하는 일을, 힙 크기와 무관한 상수 시간 작업만 남기는 것이에요. 마킹도, 객체 이동도, 포인터 수정도 전부 애플리케이션과 동시에(concurrent) 처리하고, 멈추는 건 루트 스캔처럼 양이 정해진 작업뿐이게 만드는 거죠.
이 목표를 이루는 핵심 도구가 바로 colored pointer와 load barrier예요.
Colored Pointer — 포인터에 색을 칠한다
ZGC는 64비트 포인터를 그냥 주소로만 쓰지 않아요. 주소 비트 외에 메타데이터 비트를 함께 실어 보내요. 이렇게 상태 정보가 칠해진 포인터를 colored pointer라고 불러요.
비(非)세대(non-generational) ZGC의 고전적인 비트 레이아웃은 이렇게 생겼어요.
6 4 4 4 4 4
3 7 6 5 4 3 0
+-------------------------------------+-+-+-+-+----------------------------------+
|00000000 00000000 000000 |F|R|M|M| object address (42 bits) |
| unused (18 bits) | | |1|0| |
+-------------------------------------+-+-+-+-+----------------------------------+
| | | |
| | | +-- Marked0
| | +---- Marked1
| +------ Remapped
+-------- Finalizable
- 하위 42비트는 실제 객체 주소예요. 42비트면 4TB까지 가리킬 수 있어요.
- 가운데 4비트가 색이에요.
Finalizable,Remapped,Marked1,Marked0. - 상위 비트는 사용하지 않아요.
여기서 규칙이 하나 있어요. Marked0, Marked1, Remapped 중에서 항상 정확히 하나만 1이에요. 이 "켜져 있어야 할 비트"를 그 시점의 good color라고 불러요. 어떤 포인터가 현재의 good color를 달고 있으면 "이미 처리가 끝난, 믿을 수 있는 포인터"라는 뜻이고, 아니라면 "GC가 손봐야 할 포인터"라는 뜻이에요.
마킹 비트가 Marked0와 Marked1 두 개로 나뉜 것도 이유가 있어요. ZGC는 GC 사이클마다 둘 중 어느 쪽을 good으로 칠지 번갈아 가며 바꿔요. 그래서 이번 사이클에 마킹된 객체와 지난 사이클에 마킹된 객체를 비트 하나로 깔끔하게 구분할 수 있어요. 사이클이 끝날 때 비트를 일일이 초기화하지 않아도 되는 거죠.
Multi-Mapped Memory — 같은 메모리를 여러 주소로
여기서 자연스러운 의문이 들어요. 주소 비트 위에 색 비트가 얹혀 있으면, 그 포인터를 그대로 메모리에 접근하는 데 쓸 수 있을까요? 색 비트 때문에 주소가 어긋날 텐데요.
비세대 ZGC의 답이 재밌어요. 같은 물리 메모리를 여러 개의 가상 주소에 동시에 매핑해 버려요. Marked0 색이 칠해진 주소, Marked1 색이 칠해진 주소, Remapped 색이 칠해진 주소가 전부 같은 물리 페이지를 가리키도록 가상 메모리를 다중 매핑(multi-mapping)해 두는 거예요. 그래서 어떤 색의 포인터를 역참조해도 결국 같은 물리 객체에 닿아요.
또 ZGC의 힙은 물리적으로 연속될 필요가 없어요. 흩어진 물리 페이지를 연속된 가상 주소 공간으로 묶어서 보여 줄 수 있어요. 덕분에 단편화된 메모리를 유연하게 활용할 수 있어요. (참고로 이 multi-mapping은 뒤에서 볼 Generational ZGC에서 제거돼요.)
Load Barrier — 참조를 읽는 순간 개입한다
colored pointer가 "상태를 표시하는 수단"이라면, load barrier는 "그 상태를 보고 행동하는 주체"예요.
load barrier는 애플리케이션이 객체의 참조 필드를 읽을 때마다 ZGC가 끼워 넣는 작은 코드 조각이에요. JIT 컴파일러(C2)가 참조 로드 지점에 이 코드를 삽입해요. 동작은 이렇게 요약돼요.
// 개념적인 의사 코드예요. 실제로는 JIT가 인라인한 기계어로 들어가요.
Object obj = field; // 참조 로드
if (color(obj) != GOOD_COLOR) { // load barrier
obj = slowPath(field); // 색이 나쁘면 치유
}
// 이제 obj는 항상 good color 보장
use(obj);
핵심은 good color일 때의 빠른 경로(fast path)에 최적화돼 있다는 점이에요. 대부분의 참조는 이미 good color라서, 비트 검사 한 번만 통과하면 바로 지나가요. 오버헤드가 거의 없어요.
문제는 색이 나쁠 때예요. 이때 load barrier는 그냥 통과시키지 않고 포인터를 치유(self-healing) 해요.
- 객체가 이미 다른 곳으로 옮겨졌다면, forwarding table을 보고 새 주소를 찾아 포인터를 새 주소 + good color로 고쳐 써요.
- 옮겨져야 하는데 아직 안 옮겨졌다면, 그 자리에서 객체를 직접 옮기고(또는 다른 스레드가 옮긴 결과를 받아서) 포인터를 갱신해요.
여기서 "치유"라는 표현이 중요해요. load barrier는 단지 올바른 값을 돌려주는 데서 끝나지 않고, 참조 필드 자체를 good color로 덮어써요. 그래서 같은 필드를 다음에 다시 읽을 땐 이미 good color라 빠른 경로로 지나가요. 한 번 손본 포인터는 다시 손볼 필요가 없는 거예요.
flowchart TD
A[App thread reads a reference field] --> B{Pointer has good color?}
B -- Yes --> C[Use object directly\nfast path]
B -- No --> D{Object already relocated?}
D -- Yes --> E[Look up new address\nin forwarding table]
D -- No --> F[Relocate object now\nrecord in forwarding table]
E --> G[Self-heal: rewrite field\nwith new address + good color]
F --> G
G --> C
이 구조 덕분에 GC는 "모든 포인터를 한꺼번에 STW로 고치는" 일을 안 해도 돼요. 포인터는 애플리케이션이 그 참조를 실제로 읽을 때, 그 한 번에 lazy하게 고쳐져요. 마킹과 리매핑이 STW 밖으로 빠져나오는 비밀이 여기에 있어요.
빠른 경로는 얼마나 빠를까
load barrier가 모든 참조 로드마다 끼는데 괜찮을까 걱정될 수 있어요. 여기서 중요한 게 "good color일 때의 빠른 경로"예요.
빠른 경로는 본질적으로 로드한 포인터의 색 비트가 현재 good color인지 검사하는 것이에요. 거의 모든 참조는 이미 good color라서, 이 검사는 "통과"로 분기되고 곧장 객체를 사용해요. 색이 나쁜 경우, 즉 느린 경로로 빠지는 경우는 GC가 한창 객체를 옮기는 동안의 일부 참조뿐이라 드물어요. 그래서 load barrier는 "드물게만 일어나는 일을 위해 항상 켜 두는 작은 검문소"에 가까워요.
이 비용은 공짜는 아니에요. 다만 그 대가로 얻는 게 힙 크기와 무관한 일시 정지예요. 매 참조마다 아주 작은 검사를 분산해서 치르는 대신, 멈춤이 한곳에 몰리지 않게 만드는 거예요. ZGC의 설계 철학은 "큰 멈춤 한 번보다, 잘게 흩뿌린 작은 비용이 낫다"로 요약할 수 있어요.
ZGC의 GC 사이클 — 세 번의 짧은 멈춤
이제 큰 그림을 볼게요. ZGC 한 사이클에는 세 번의 STW 일시 정지가 있고, 그 사이를 동시(concurrent) 단계가 채워요.
flowchart TD
P1[Pause Mark Start\nSTW: scan roots, flip global color] --> CM[Concurrent Mark\ntraverse object graph, mark live]
CM --> P2[Pause Mark End\nSTW: finalize marking]
P2 --> RP[Concurrent Reference Processing\n+ relocation set selection]
RP --> P3[Pause Relocate Start\nSTW: scan roots into relocation set]
P3 --> CR[Concurrent Relocate\nmove objects, build forwarding tables]
CR --> P1
- Pause Mark Start (STW): 루트가 가리키는 객체부터 마킹을 시작해요. 이 멈춤에서는 글로벌 good color를 정하고 루트를 스캔하는, 양이 정해진 일만 해요.
- Concurrent Mark: 애플리케이션과 동시에 객체 그래프를 따라가며 살아있는 객체에 마킹 색을 칠해요. 이때 load barrier가 아직 마킹 안 된 객체를 발견하면 마킹 작업을 거들어 줘요.
- Pause Mark End (STW): 마킹을 마무리하고, 약한 참조 처리와 재배치 대상(relocation set) 선택으로 넘어갈 준비를 해요.
- Concurrent Relocate: 재배치 대상 영역의 객체를 동시에 새 영역으로 옮기고, 옮긴 기록을 forwarding table에 남겨요.
여기서 STW 구간이 하는 일을 보면 전부 루트 스캔처럼 양이 한정된 작업이에요. 살아있는 객체 수나 힙 크기에 비례하는 작업은 전부 concurrent 단계로 빠져 있어요. 그래서 일시 정지가 힙 크기와 무관하게 상수에 가깝게 유지돼요. 실제로 ZGC의 일시 정지는 보통 1밀리초 미만이고, 멀티 TB 힙에서도 그 수준을 유지해요.
루트 스캔마저 동시로 — JEP 376
초기 ZGC에서도 STW에 남아 있던 일이 하나 있었어요. 스레드 스택 같은 루트를 스캔하는 작업이었죠. 스레드가 많아지면 이 부분이 일시 정지를 늘릴 수 있었어요.
JDK 16의 JEP 376 (ZGC: Concurrent Thread-Stack Processing) 이 이걸 해결했어요. 스레드 스택 처리를 세이프포인트 밖, 즉 동시 단계로 옮겼어요. 이때부터 JVM의 거의 모든 루트를 동시 단계에서 처리할 수 있게 됐고, ZGC의 일시 정지는 한층 더 짧고 일정해졌어요.
Concurrent Relocation — 살아있는데 객체를 옮긴다
ZGC에서 가장 짜릿한 부분이에요. 애플리케이션이 돌아가는 와중에 객체를 옮기는 과정을 따라가 볼게요.
먼저 ZGC의 힙은 ZPage라는 영역(region) 단위로 나뉘어요. 크기가 고정이 아니라 객체 크기에 맞춰 세 종류로 나뉘어요.
| 종류 | 크기 | 담는 객체 |
|---|---|---|
| Small | 2MB | 256KB 이하 객체 |
| Medium | 32MB | 256KB 초과 ~ 4MB 이하 객체 |
| Large | 2MB의 배수 | 4MB 초과 객체 하나 |
재배치는 이런 순서로 흘러가요.
- Relocation set 선택: 살아있는 객체 비율이 낮은(즉 비울 가치가 큰) ZPage들을 골라요.
- Forwarding table 준비: 재배치 대상으로 뽑힌 각 ZPage마다, 옮긴 객체의 옛 주소를 새 주소로 매핑하는 forwarding table을 힙 바깥에 하나씩 만들어요.
- Concurrent Relocate: GC 스레드가 대상 ZPage의 살아있는 객체를 새 ZPage로 복사하고, 옛 주소에서 새 주소로 가는 매핑을 forwarding table에 기록해요.
- Lazy remap (self-healing): 애플리케이션이 옛 주소를 가리키는 포인터를 읽으면, load barrier가 forwarding table을 보고 새 주소로 포인터를 치유해요. 만약 GC가 아직 그 객체를 안 옮겼는데 애플리케이션이 먼저 읽으면, load barrier가 그 자리에서 객체를 옮겨 버려요. 누가 먼저 닿든 결과는 같아요.
flowchart LR
subgraph Before
O1[Old ZPage\nlive + garbage]
end
subgraph During
GC[GC thread copies\nlive objects]
LB[Load barrier may copy\non first access]
FT[(Forwarding Table\noff-heap)]
end
subgraph After
N1[New ZPage\nonly live objects]
FREE[Old ZPage freed]
end
O1 --> GC --> N1
O1 --> LB --> N1
GC --> FT
LB --> FT
N1 --> FREE
포인터 수정이 모든 포인터를 한꺼번에 도는 대신, 각 포인터가 다음에 읽힐 때 한 번씩 분산되어 처리된다는 점이 핵심이에요. 그리고 모든 살아있는 객체가 새 ZPage로 빠져나가 옛 ZPage에 더 이상 닿을 포인터가 없다고 보장되면, 그 forwarding table과 옛 ZPage 메모리를 회수해요.
재배치 대상은 어떻게 고를까
ZGC가 모든 ZPage를 다 비우는 건 아니에요. 마킹이 끝나면 각 ZPage가 얼마나 살아있는지(live 비율)를 알 수 있는데, 이 정보를 바탕으로 비울 가치가 큰 페이지부터 relocation set에 담아요. 살아있는 객체가 적은 페이지를 비우면, 적은 복사로 많은 빈 공간을 돌려받을 수 있거든요. G1의 "Garbage-First"와 비슷한 직관이에요. 쓰레기가 많은 곳부터 손대는 거죠.
여기서 한 가지 현실적인 문제가 있어요. 객체를 옮기려면 옮겨 갈 빈 ZPage가 필요한데, 메모리가 빠듯해서 새 페이지를 확보하기 어려운 상황이 올 수 있어요. ZGC는 이런 경우에도 멈추지 않으려고, 비우려던 페이지 안에서 살아있는 객체를 앞쪽으로 다져 넣는 in-place relocation 같은 방식으로 대응해요. 핵심 원칙은 한결같아요. "어떤 상황에서도 긴 STW로 도망가지 않는다"는 거예요.
이렇게 고른 relocation set만 concurrent relocate 단계에서 옮기기 때문에, 한 사이클이 옮기는 객체 양이 힙 전체가 아니라 "비울 가치가 큰 일부"로 제한돼요. 일시 정지뿐 아니라 동시 작업량까지도 합리적인 선에서 관리되는 셈이에요.
Generational ZGC — 젊은 객체는 자주 죽는다
지금까지 본 ZGC는 사실 단일 세대(single-generation) 모델이에요. 힙 전체를 하나로 보고 매번 전체를 마킹했죠. 이건 잘 동작하지만 한 가지 비효율이 있어요.
약한 세대 가설(weak generational hypothesis) — 대부분의 객체는 만들어진 직후 금방 죽는다는 경험칙이에요. 그렇다면 금방 죽을 젊은 객체만 자주, 따로 수거하는 게 CPU를 훨씬 아끼는 길이에요. 단일 세대 ZGC는 이 이점을 못 누리고 있었어요.
단일 세대 모델의 비용을 좀 더 풀어 볼게요. 객체 대부분이 금방 죽는다면, 사실 GC가 신경 쓸 가치가 있는 건 그 "젊고 곧 죽을" 영역이에요. 그런데 단일 세대 ZGC는 한 사이클마다 힙 전체를 마킹해요. 오래 살아남아 앞으로도 한참 살아 있을 old 객체까지 매번 같이 훑는 거예요. 이건 마치 매번 집 전체를 청소하는 것과 같아요. 정작 먼지가 빨리 쌓이는 곳은 입구 한쪽인데 말이에요.
세대를 나누면 자주 더러워지는 young만 자주 청소하고, old는 가끔만 손대면 돼요. 같은 colored pointer와 배리어 구조를 그대로 쓰면서, "어디를 얼마나 자주 볼 것인가"만 똑똑하게 바꾼 거예요. 그래서 Generational ZGC는 새로운 알고리즘이라기보다, 기존 ZGC 위에 세대라는 렌즈를 더한 진화에 가까워요.
그래서 나온 게 JEP 439 Generational ZGC 예요. JDK 21에 들어왔어요. 힙을 논리적으로 두 세대로 나눠요.
- Young generation: 새로 만든 객체. 자주, 빠르게 수거해요.
- Old generation: 오래 살아남은 객체. 덜 자주 수거해요.
두 세대는 서로 독립적으로 수거돼요.
flowchart TD
A[Object allocated] --> Y[Young Generation\ncollected frequently]
Y -- survives enough --> O[Old Generation\ncollected less often]
Y -- dies young --> R1[Reclaimed quickly\ncheap]
O -- becomes unreachable --> R2[Reclaimed in old collection]
Store Barrier와 Remembered Set
세대를 나누면 새로운 숙제가 생겨요. old 세대 객체가 young 세대 객체를 가리키는 경우예요. young만 수거하려면 "어떤 old 객체가 young을 가리키고 있는지"를 알아야 하거든요. old 전체를 스캔하면 세대를 나눈 의미가 없어지죠.
Generational ZGC는 이걸 위해 store barrier를 도입했어요. load barrier가 참조를 읽을 때 끼는 코드라면, store barrier는 참조를 쓸 때 끼는 코드예요. old 객체의 필드에 young 객체 참조를 저장하면, store barrier가 그 위치를 remembered set에 기록해 둬요.
remembered set은 old 세대 영역마다 붙는 비트맵 한 쌍이에요. 비트 하나가 잠재적인 객체 필드 주소 하나를 나타내요. 그런데 이 비트맵을 한 쌍으로 둔 게 영리해요. double-buffered remembered set 이라고 불러요.
- 한 비트맵은 활성 상태로, 애플리케이션 스레드의 store barrier가 채워요.
- 다른 비트맵은 GC가 읽기 전용으로, young 수거 때 "old에서 young을 가리키는 필드 목록"으로 사용해요.
- young 수거가 시작될 때마다 두 비트맵을 원자적으로 맞바꿔요(swap).
이렇게 하면 GC가 한쪽 비트맵을 처리하고 비우는 동안, 애플리케이션 스레드는 다른 비트맵을 동시에 채울 수 있어요. 비트맵이 비워지길 기다릴 필요가 없고, 두 종류의 스레드가 서로 다른 비트맵을 만지니 추가적인 메모리 배리어도 필요 없어요.
store barrier도 빠른 경로에 최적화돼 있어요. 매번 비싼 느린 경로를 타지 않고, 덮어쓸 값과 필드 주소를 store barrier buffer에 적어 두고 바로 컴파일된 코드로 돌아가요. 이 버퍼가 가득 찼을 때만 느린 경로를 타요.
무엇이 또 달라졌나
Generational ZGC는 내부적으로 꽤 많이 바뀌었어요.
- 마킹 작업이 load barrier에서 store barrier로 이동했어요. load barrier에서 마킹 부담을 덜어내면 load barrier를 더 공격적으로 최적화할 수 있어요. load barrier가 store barrier보다 훨씬 자주 실행되니까 이게 이득이에요.
- multi-mapped memory를 더 이상 쓰지 않아요. 덕분에 힙 메모리 사용량을 측정하기가 쉬워졌어요.
- colored pointer의 색 체계도 새로 설계됐어요. young/old 마킹 상태와 remembered set 상태를 함께 표현하도록 메타데이터 비트 구성이 바뀌었어요.
JDK 23에서는 JEP 474 로 Generational 모드가 ZGC의 기본값이 됐고(ZGenerational 옵션 기본값이 true로 바뀜), JDK 24의 JEP 490 으로 비세대 모드 코드가 아예 제거됐어요. 지금 ZGC라고 하면 사실상 Generational ZGC를 뜻해요.
ZGC의 발자취
ZGC가 실험 기능에서 표준으로 자리 잡기까지의 흐름이에요.
flowchart LR
J11[JDK 11\nJEP 333\nExperimental] --> J15[JDK 15\nJEP 377\nProduction]
J15 --> J16[JDK 16\nJEP 376\nConcurrent thread-stack]
J16 --> J21[JDK 21\nJEP 439\nGenerational ZGC]
J21 --> J23[JDK 23\nJEP 474\nGenerational by default]
J23 --> J24[JDK 24\nJEP 490\nNon-generational removed]
- JDK 11 / JEP 333: ZGC가 실험 기능으로 처음 등장했어요. region 기반, NUMA-aware, 압축형 동시 수집기였어요.
- JDK 15 / JEP 377: 안정성과 플랫폼 지원이 충분해져 production 기능으로 승격됐어요.
- JDK 16 / JEP 376: 스레드 스택 처리를 동시 단계로 옮겨, 일시 정지를 더 짧고 일정하게 만들었어요.
- JDK 21 / JEP 439: Generational ZGC가 추가됐어요.
- JDK 23 / JEP 474: Generational 모드가 기본값이 됐어요.
- JDK 24 / JEP 490: 비세대 모드가 제거됐어요.
G1과 ZGC, 무엇이 다를까
ZGC를 이해할 때 G1과 나란히 놓고 보면 차이가 또렷해져요. 둘 다 region 기반 동시 수집기지만, 멈춤을 다루는 철학이 달라요.
| 관점 | G1 | ZGC |
|---|---|---|
| 일시 정지 목표 | MaxGCPauseMillis로 목표 시간을 정하고 그 안에 수집 가능한 만큼 |
힙 크기와 무관하게 보통 1밀리초 미만 |
| 객체 이동 | 대피(evacuation) 시 STW 구간에서 복사 | concurrent relocate로 애플리케이션과 동시에 복사 |
| 포인터 수정 | STW 구간에서 처리 | load barrier가 lazy하게 self-heal |
| 핵심 장치 | Remembered Set + Write Barrier | Colored Pointer + Load/Store Barrier |
| 세대 모델 | 처음부터 세대형 | JEP 439부터 세대형 |
G1은 "목표 시간 안에 최대한 비운다"는 접근이에요. 일시 정지를 0으로 만들기보다, 예측 가능한 범위로 묶는 데 초점이 있어요. 힙이 아주 커지면 목표 시간을 지키기 어려워질 수 있어요.
ZGC는 "멈춤이 힙 크기에 끌려가지 않게 한다"는 접근이에요. 그 대가로 배리어 오버헤드를 상시 부담하지만, 힙이 수 TB가 돼도 일시 정지가 거의 변하지 않아요. 큰 힙에서 꼬리 지연을 눌러야 한다면 ZGC의 설계가 빛을 발해요.
어느 쪽이 무조건 낫다기보다, 무엇을 일정하게 유지하고 싶은가가 다른 거예요. G1은 "예측 가능한 멈춤", ZGC는 "힙과 무관한 짧은 멈춤"을 약속해요.
자주 부딪히는 오해
ZGC를 처음 접할 때 흔히 겪는 오해 몇 가지를 정리해 볼게요.
"ZGC는 압축을 안 해서 단편화가 생긴다?" 아니에요. ZGC는 compaction을 해요. concurrent relocate가 바로 살아있는 객체를 모아 새 ZPage로 옮기는, 멈춤 없는 압축이에요. "압축을 안 하는 게 아니라, 멈추지 않고 압축한다"가 정확한 표현이에요.
"일시 정지가 0이다?" 0은 아니에요. STW 일시 정지가 세 번 있어요. 다만 그 일이 루트 스캔처럼 양이 정해진 작업이라, 시간이 보통 1밀리초 미만이고 힙 크기에 비례하지 않아요. "멈춤이 없다"가 아니라 "멈춤이 작고 일정하다"가 맞아요.
"작은 힙에서는 의미가 없다?" 큰 힙에서 이점이 가장 두드러지는 건 맞아요. 하지만 작은 힙이라도 꼬리 지연을 눌러야 하는 지연 민감 서비스라면 충분히 쓸 이유가 있어요. 기준은 힙 크기 자체보다 "이 서비스가 지연에 얼마나 민감한가"예요.
"load barrier 때문에 항상 느리다?" load barrier의 빠른 경로는 색 검사 한 번이에요. 비용이 0은 아니지만, 그 대신 멈춤을 잘게 분산해요. 처리량이 절대적으로 중요한 배치성 작업이 아니라면, 대개 이 트레이드오프가 유리한 쪽이에요.
언제 ZGC를 쓰면 좋을까
ZGC의 강점은 분명해요. 일시 정지가 극도로 짧고, 힙이 커져도 그 값이 거의 변하지 않아요. 응답 지연(latency)이 중요한 서비스, 특히 큰 힙을 쓰면서도 꼬리 지연(tail latency)을 눌러야 하는 워크로드에 잘 맞아요.
다만 공짜는 아니에요. load barrier와 store barrier가 끼면서 처리량(throughput) 측면에서는 약간의 비용을 치러요. 배치 작업처럼 지연보다 총 처리량이 중요한 경우엔 G1 같은 처리량 지향 수집기가 더 나을 수 있어요. 결국 "지연이냐 처리량이냐"라는 익숙한 트레이드오프예요.
활성화는 간단해요.
# ZGC 활성화 (JDK 15+)
java -XX:+UseZGC -jar app.jar
# JDK 21~22에서 Generational ZGC를 명시적으로 켜기
java -XX:+UseZGC -XX:+ZGenerational -jar app.jar
# JDK 23+ 에서는 -XX:+UseZGC 만으로 Generational 모드가 기본이에요
java -XX:+UseZGC -jar app.jar
튜닝 노브가 거의 없다는 것
ZGC의 설계 목표 중 하나가 "쓰기 쉬울 것" 이에요. 다른 수집기를 만져 본 분들은 young/old 비율, survivor 크기, 목표 일시 정지 시간 같은 노브를 떠올릴 거예요. ZGC는 이런 세세한 조정에 의존하지 않도록 설계됐어요.
실무에서 사실상 가장 중요한 결정은 힙 크기를 충분히 주는 것 하나예요. ZGC는 동시에 객체를 옮기는 동안에도 애플리케이션이 새 객체를 계속 할당하기 때문에, 수거가 따라잡을 여유 공간이 필요해요. 힙이 너무 빠듯하면 할당 속도가 수거 속도를 앞질러 문제가 생길 수 있어요. 일시 정지를 직접 조이는 노브로 씨름하기보다, 워크로드의 할당 속도에 견딜 만큼 힙을 넉넉히 주는 쪽이 ZGC와 더 잘 맞아요.
요컨대 ZGC를 쓰는 마음가짐은 "노브를 정교하게 깎는다"가 아니라 "구조가 알아서 하게 두고, 공간만 충분히 준다"에 가까워요.
마무리
ZGC를 한 문장으로 줄이면 이래요. 포인터에 색을 칠해 상태를 싣고, 참조를 읽고 쓰는 순간마다 작은 배리어를 끼워, 마킹과 객체 이동과 포인터 수정을 전부 애플리케이션과 동시에 처리해요.
전통적인 GC가 "멈춰서 한꺼번에 정리"했다면, ZGC는 "달리면서 조금씩, 닿을 때마다 치유"해요. colored pointer가 상태를 표현하는 언어라면, load barrier와 store barrier는 그 언어를 읽고 행동하는 손인 셈이에요. 그리고 Generational ZGC는 여기에 "젊은 객체는 자주 죽는다"는 오래된 통찰을 더해, 같은 구조 위에서 CPU를 더 아끼는 방향으로 진화했어요.
GC 로그에서 일시 정지가 1밀리초 아래로 찍히는 걸 볼 때, 그 뒤에서 colored pointer와 배리어가 쉬지 않고 포인터를 치유하고 있다는 걸 떠올려 보면 좋겠어요.
ZGC가 던지는 메시지는 의외로 단순해요. 멈춰서 한꺼번에 해결해야만 하는 일은 생각보다 적다는 거예요. 큰 작업을 잘게 쪼개 평소에 조금씩 흘려보내면, 사용자가 체감하는 멈춤은 거의 사라져요. 이건 GC만의 이야기가 아니라, 지연에 민감한 시스템을 설계할 때 두고두고 곱씹어 볼 만한 관점이라고 생각해요.
참고자료
- JEP 333: ZGC: A Scalable Low-Latency Garbage Collector (Experimental) — https://openjdk.org/jeps/333
- JEP 377: ZGC: A Scalable Low-Latency Garbage Collector (Production) — https://openjdk.org/jeps/377
- JEP 376: ZGC: Concurrent Thread-Stack Processing — https://openjdk.org/jeps/376
- JEP 439: Generational ZGC — https://openjdk.org/jeps/439
- JEP 474: ZGC: Generational Mode by Default — https://openjdk.org/jeps/474
- JEP 490: ZGC: Remove the Non-Generational Mode — https://openjdk.org/jeps/490
- OpenJDK ZGC Wiki — https://wiki.openjdk.org/display/zgc/Main

