ZGC는 어떻게 거의 멈추지 않고 힙을 정리할까

ZGC의 colored pointer, load barrier, forwarding table, concurrent relocation, Generational ZGC를 정리합니다.

G1은 멈춤 시간을 예측 가능하게 줄인다. 하지만 객체를 실제로 옮기는 evacuation은 여전히 STW 안에서 처리한다. 힙이 커지고 살아있는 객체가 많아질수록, 이 비용은 무시하기 어려워진다.

ZGC의 질문은 조금 더 과감하다.

객체를 옮기는 동안에도 애플리케이션을 계속 실행할 수는 없을까?

ZGC는 이 질문에 colored pointerload barrier로 답한다. 참조 자체에 작은 상태 비트를 붙이고, 객체를 읽는 순간 포인터가 현재 GC 상태에 맞는지 검사한다. 낡은 주소를 만나면 그 자리에서 고친다.

ZGC가 풀려는 문제

GC가 해야 할 일은 결국 두 가지다.

1. 어떤 객체가 살아 있는지 찾는다.
2. 죽은 객체가 차지하던 메모리를 다시 쓸 수 있게 만든다.

문제는 애플리케이션도 동시에 객체 그래프를 바꾼다는 점이다. 참조가 생기고 끊기고, 어떤 객체는 방금까지 살아 있다가 바로 죽는다. 가장 쉬운 해결책은 애플리케이션을 멈춘 뒤 한 번에 검사하는 것이다. 하지만 힙이 커질수록 pause가 길어진다.

ZGC는 STW를 매우 짧은 동기화 지점으로 제한하고, 마킹과 재배치 대부분을 애플리케이션과 동시에 수행한다. 그래서 목표는 처리량 최대화가 아니라 극단적으로 낮은 pause time이다.

Colored Pointer: 포인터에 상태를 담는다

64비트 환경에서는 실제 객체 주소를 표현하는 데 모든 비트를 다 쓰지 않는 경우가 많다. ZGC는 이 여유 비트 일부를 GC 메타데이터로 사용한다. 이를 colored pointer라고 부른다.

개념적으로 보면 다음과 같다.

[ address bits ][ metadata bits ]
                 ├─ marked
                 ├─ remapped
                 └─ remembered / finalizable 등

여기서 주의할 점이 있다. 색은 이전 주소나 새 주소를 뜻하지 않는다. 색은 이 포인터를 읽을 때 어떤 검사가 필요한지 알려주는 상태값이다.

예를 들어 marking 관련 bit는 이 객체가 이번 GC cycle에서 live로 확인되었는지와 관련된다. remap 관련 bit는 이 포인터의 주소가 relocation 이후에도 현재 주소라고 확인되었는지와 관련된다.

두 상태는 독립적이다.

marked?   -> 이번 cycle에서 살아 있음이 확인되었는가
remapped? -> relocation 이후 주소가 최신임이 확인되었는가

객체가 살아 있다는 사실과, 그 객체를 가리키는 포인터가 최신 주소를 담고 있다는 사실은 같은 말이 아니다. ZGC가 metadata를 여러 축으로 나누는 이유가 여기에 있다.

Multi-mapping: 색이 다른 주소가 같은 객체를 본다

여기서 자연스럽게 드는 의문이 있다.

포인터의 일부 비트를 색으로 쓰면, CPU 입장에서는 서로 다른 주소 아닌가?

초기 non-generational ZGC의 답은 multi-mapping이었다. 같은 heap memory를 OS 수준에서 여러 가상 주소 영역에 매핑해 두고, 색이 다른 포인터가 같은 물리 페이지로 번역되게 만든다.

같은 객체 B가 들어 있는 물리 page
        ^
        |
  OS page table
        |
+----------------------+----------------------+----------------------+
| Remapped view        | Marked0 view         | Marked1 view         |
| 0xR...B              | 0xM0...B             | 0xM1...B             |
+----------------------+----------------------+----------------------+

이때 “N개의 가상 주소”는 JVM 안에서만 쓰는 추상 이름이 아니라, Java 프로세스의 유저 공간에 예약되고 매핑된 OS 가상 주소다. 물리 메모리가 N벌 생기는 것은 아니다. 같은 물리 page가 색별 virtual view에 alias로 붙어 있어서, 도구에 따라 RSS나 가상 메모리 사용량이 실제보다 커 보일 수 있다.

다만 이 설명은 non-generational ZGC의 핵심 모델에 대한 설명이다. JDK 21에서 Generational ZGC가 들어왔고, JDK 23부터 -XX:+UseZGC의 기본 모드가 generational이 되었으며, JDK 24에서는 non-generational 모드가 제거되었다. 현재의 Generational ZGC는 같은 heap을 세 가상 주소 범위에 multi-map하는 방식이 아니라, load/store barrier가 colored pointer와 colorless pointer를 변환하는 방식으로 동작한다. 객체 필드 안의 참조는 colored pointer일 수 있지만, JVM 스택과 CPU 레지스터에서 애플리케이션이 실제로 사용하는 값은 metadata가 제거된 colorless pointer로 다뤄진다.

그래서 이 부분은 이렇게 구분하면 덜 헷갈린다.

non-generational ZGC
  colored pointer의 색별 view가 OS 가상 주소 alias로 존재할 수 있음
  같은 물리 heap page를 여러 virtual address range에 매핑

Generational ZGC
  colored pointer는 주로 heap object field에 저장되는 GC 프로토콜 값
  barrier가 colorless pointer로 바꿔 실제 접근에 사용
  multi-mapped heap view는 사용하지 않음

Load Barrier: 읽는 순간 포인터를 고친다

ZGC의 핵심은 load barrier다. 자바 코드에서는 단순히 필드를 읽는 것처럼 보인다.

Object child = node.child;

하지만 JIT가 만든 실제 코드에는 참조를 읽은 직후 검사하는 경로가 들어간다.

ref = node.child

if ref의 metadata가 현재 GC 상태와 맞지 않으면:
    ref = slow_path(ref)
    node.child = ref

return ref

대부분의 경우는 fast path다. 포인터 색이 현재 상태와 맞으면 거의 비용 없이 지나간다. 하지만 stale pointer를 만나면 slow path로 들어간다.

slow path는 다음 일을 할 수 있다.

  • non-generational ZGC에서는 아직 marking되지 않은 객체를 marking한다.
  • relocation 대상 객체인지 확인한다.
  • 옛 주소라면 forwarding table에서 새 주소를 찾는다.
  • 필드 안의 참조를 새 주소와 현재 metadata로 다시 써서 self-healing한다.

Generational ZGC에서는 marking 책임이 store barrier 쪽으로 많이 옮겨졌다. 현재 load barrier의 핵심 책임은 colored pointer의 metadata를 해석해 접근 가능한 주소로 만들고, 객체가 옮겨졌다면 stale pointer를 최신 주소로 고치는 것이다.

이 self-healing 성질이 중요하다. 한 번 읽으며 고친 포인터는 다음에 다시 읽을 때 fast path로 지나간다. 시간이 지나면서 낡은 포인터는 자연스럽게 현재 GC cycle의 색으로 수렴한다.

이전 주소와 새 주소는 어디에 있나

ZGC에서 old address -> new address 매핑은 colored pointer 안에 들어 있지 않다. 포인터 안에는 주소 비트와 상태 비트가 있을 뿐이다.

객체가 relocation set에 들어가면 ZGC는 live object를 새 page로 복사하고, 별도의 forwarding table에 매핑을 기록한다.

Heap
  A.child -> B_old   // stale pointer일 수 있음

Forwarding Table
  B_old -> B_new

애플리케이션이 A.child를 읽는 순간 load barrier가 현재 remap 상태와 맞지 않는 포인터를 발견한다. 그 주소가 relocation 대상이면 forwarding table을 조회해서 B_new를 얻고, A.child를 새 주소로 고친다.

읽기 전
A.child = [B_old + old remap metadata]

load barrier
B_old가 relocation set인가?
  yes -> forwarding table에서 B_new 조회

읽기 후
A.child = [B_new + current remap metadata]

G1과의 차이는 뚜렷하다. G1은 STW evacuation 안에서 참조 slot을 새 주소로 고쳐 두어야 한다. ZGC는 주소 갱신 작업을 읽기 시점으로 흩어 놓는다. 그래서 relocation을 애플리케이션과 동시에 진행할 수 있다.

ZGC의 GC cycle

ZGC cycle은 크게 marking과 relocation으로 나눌 수 있다. 각 단계의 시작과 끝에는 짧은 STW가 있지만, 오래 걸리는 작업은 대부분 concurrent하게 진행된다.

Pause Mark Start        root marking 시작
Concurrent Mark         힙을 동시에 추적
Pause Mark End          marking 종료 동기화
Concurrent Prepare      relocation set 선정
Pause Relocate Start    root relocation 시작
Concurrent Relocate     객체 이동과 pointer remap
Concurrent Remap        남은 stale pointer 정리

Pause Mark StartPause Mark End, Pause Relocate Start는 전체 세계를 오래 멈추는 구간이 아니라, GC cycle의 기준점을 맞추는 짧은 동기화 지점에 가깝다.

긴 작업은 애플리케이션이 계속 실행되는 동안 진행된다. GC 스레드가 객체를 옮기고, 애플리케이션 스레드는 낡은 포인터를 읽을 때 load barrier를 통해 새 주소로 고친다. 때로는 mutator가 relocation을 돕는 구조가 된다.

Page 단위 힙 관리

ZGC는 힙을 page 단위로 관리한다. G1의 region과 비슷하게 보일 수 있지만, 크기와 역할이 다르다.

  • Small page: 작은 객체를 담는다.
  • Medium page: 중간 크기 객체를 담는다.
  • Large page: 큰 객체를 담고, 보통 객체 하나가 page 하나를 차지한다.

큰 객체를 자주 옮기면 비용이 커진다. 그래서 ZGC는 page 단위의 회수와 relocation set 선택으로 비용을 조절한다. garbage가 많은 page를 고르고, live object만 새 page로 옮긴다.

Generational ZGC: 젊은 객체를 더 자주 본다

초기 ZGC는 non-generational이었다. 매 cycle마다 힙 전체를 바라보는 구조라 단순했지만, 자바 객체 대부분이 빨리 죽는다는 경험칙을 충분히 활용하지 못했다.

JDK 21부터는 Generational ZGC가 도입되었다. Young과 Old를 나누고, 대부분의 수집을 Young에서 자주 처리한다. JDK 23부터는 ZGC의 기본 모드가 generational로 바뀌었고, JDK 24에서는 non-generational 모드가 제거되었다. 낮은 pause 철학은 유지하면서도, 전체 힙을 매번 같은 무게로 보지 않도록 바뀐 것이다.

Generational ZGC에서 기억할 부분은 RSet의 방향이다.

G1의 RSet은 target-side 성격이 강하다. collection set 안으로 들어오는 외부 card를 기억한다. 반면 Generational ZGC의 RSet은 source-side에 가깝다. old page 안에 있는 필드 주소 중 young을 가리킬 가능성이 있는 slot을 기록한다.

G1
  target region/group RSet
  <- source card 후보

Generational ZGC
  source old page RSet
  <- old page 안의 field slot 후보

또 하나의 차이는 해상도다. G1은 기본적으로 512바이트 card 단위로 후보를 기록한다. ZGC는 old page별 bitmap에 potential object field address를 더 정밀하게 기록한다. 다만 ZGC도 그 slot이 반드시 young을 가리킨다고 확정하는 것은 아니다. young collection 때 현재 값을 다시 읽어 확인한다.

정밀한 기록은 비용이 있다. 그래서 Generational ZGC는 store barrier를 fast path, medium path, slow path로 나누고, 같은 cycle 안에서 같은 필드가 반복해서 느린 경로를 타지 않도록 act-once 성질을 활용한다.

여기서 말하는 double buffering도 정확히 잡아야 한다. Marked0/Marked1을 번갈아 쓰는 것은 mark epoch를 구분하기 위한 두 색의 전환이지, Generational ZGC 문맥에서 말하는 double buffering의 핵심은 아니다. Generational ZGC의 double buffering은 old page마다 remembered-set bitmap을 두 장 두는 구조다.

평상시
  bitmap A: application store barrier가 채우는 active bitmap
  bitmap B: GC가 이전 cycle 기록을 읽는 bitmap

Young GC 시작
  atomic swap

수집 중
  bitmap B: application이 새 기록을 채움
  bitmap A: GC가 읽고 처리한 뒤 비움

이렇게 하면 애플리케이션 스레드는 GC가 bitmap을 비우는 동안 기다리지 않고 새 active bitmap에 계속 기록할 수 있다. 또한 GC 스레드와 애플리케이션 스레드가 서로 다른 bitmap을 보므로, remembered set 유지에 필요한 동기화 비용을 줄일 수 있다.

ZGC의 비용

ZGC가 pause를 줄이는 대신 공짜로 모든 것을 얻는 것은 아니다.

가장 큰 비용은 barrier다. 참조를 읽을 때마다 load barrier 검사가 들어간다. 대부분은 빠르게 끝나지만, 그래도 처리량에는 영향을 준다.

또한 colored pointer, forwarding table, page metadata, remembered set 같은 구조가 필요하다. 운영 환경에서는 낮은 pause time이 정말 필요한지, 처리량 손실과 CPU 여유가 감당 가능한지 함께 봐야 한다.

ZGC가 잘 맞는 경우는 대체로 분명하다.

  • pause time SLO가 매우 엄격하다.
  • p99, p999 tail latency가 중요하다.
  • 힙이 크고 live set도 크다.
  • 짧은 정지가 비즈니스 장애로 이어진다.

반대로 일반적인 API 서버처럼 수십에서 수백 ms 수준의 pause가 허용되고 처리량이 더 중요하다면, G1이 더 단순하고 좋은 선택일 수 있다.

ZGC를 한 문장으로 정리하면

ZGC는 포인터에 GC 상태를 담고, load barrier로 읽는 순간 낡은 참조를 고친다. 이전 주소와 새 주소의 매핑은 별도 forwarding table에 두고, 객체 이동과 참조 갱신을 애플리케이션 실행 중에 나누어 처리한다.

G1이 region을 골라 멈춤 시간을 관리하는 collector라면, ZGC는 참조 자체를 GC 프로토콜에 참여시켜 멈춤 시간을 거의 없애려는 collector다. 그래서 ZGC의 핵심은 단순히 빠른 GC가 아니라, 애플리케이션과 GC가 동시에 같은 힙 위를 조심스럽게 걷도록 만드는 설계에 있다.


© 2024. All rights reserved.