ZGC(Z Garbage Collector)는 JVM 진영에서 Serial → Parallel → CMS → G1 이후에 새롭게 등장한 Garbage Collector 입니다. JDK 11에 Experimental로 추가되었고, JDK 15에서 Production Ready 상태로 전환되었으며, LDT 버전인 JDK 17에도 반영되었습니다. ZGC의 주요 목표 중 하나는 Stop-The-World(STW) 시간을 1ms 이하로 줄이는 것입니다.

과거에 CMS GC를 사용하는 애플리케이션을 운영하다보면 STW 시간이 길어지다 보면 요청을 제대로 처리하지 못하는 현상 때문에 애를 먹었던게 생각나네요. ZGC는 대용량 힙 메모리에서도 낮은 지연 시간(low latency)을 목표로 설계되었기 때문에, 이를 적용하는 것만으로도 충분한 가치가 있어보이는 GC 알고리즘이었습니다. 이번 글에서는 애플리케이션의 성능을 개선하면서 적용했던 ZGC에 대해 소개하려고 합니다.


ZGC

ZGC는 대용량 힙 메모리에서도 낮은 지연 시간(low latency)을 목표로 설계되었습니다. Heap Allocation, Compaction 등 거의 모든 면에서 변화가 있었기 때문에, 꼭 바로 이전에 나온 G1과 비교하지 않더라도 ZGC 자체로도 재미있는 피쳐들을 가지고 있습니다.

아래는 2023년 Oracle DevLive Level Up에서 발표된 Z Garbage Collector: The Next Generation에 소개된 ZGC의 특징들입니다.

  • Scalable: 확장 가능하도록 설계되어 수백 MB에서 최대 수 TB 메모리 처리
  • Low Latency: 1MS 미만의 일정한 STW 시간(puase-time)
  • Auto-tuning: 성능 최적화에 필요한 튜닝의 양을 줄이기기 위해 GC의 많은 튜닝 설정들을 자동화
  • Throughput: 동일한 워크로드에서 전체 성능의 15% 내에서 성능 유지

ZGC

ZGC

  • Concurrent: ZGC는 대부분의 작업을 Java 애플리케이션 스레드가 실행되는 동안 동시에 수행. GC 동안 애플리케이션의 응답성을 유지하는 데 도움을 준다.
  • Constant pause times: GC 동안 발생하는 STW(pause-time)이 일정하게 유지되며, 힙 크기나 라이브 객체의 크기에 따라 비례적으로 증가하지 않는다.
  • Parallel: 여러 스레드를 동시에 사용하여 GC 수행해서 더 빠르게 완료. Reference 할당마다 실행되는 Load Barrier를 통해 GC에 필요한 작업들을 멀티 스레드로 애플리케이션 스레드와 동시에 진행.
  • Compacting: 메모리 단편화를 줄이기 위해 객체를 이동시켜서 메모리 할당 효율성을 높인다.
    • G1GC는 Compaction을 STW 동안 수행하는 반면, ZGC는 애플리케이션 스레드와 병렬로 수행.
    • 즉, 객체 이동 및 업데이트 작업을 애플리케이션이 실행되는 동안 동시에 수행한다.
  • Region-based: 메모리를 여러 영역으로 나누어 관리하며, 많은 가비지가 생성되는 영역에 집중하여 효율적으로 GC 수행
  • NUMA-aware: NUMA(Non-Uniform Memory Access) 아키텍처를 인식하고, CPU에 가까운 메모리에 객체를 할당하여 성능을 최적화
  • Load barriers & Colored pointers:
    • Load barriers: 메모리 로드 시 특정 조건을 확인하는 메커니즘, G C중에도 안전한 메모리 접근을 보장
    • Colored pointers: 포인터에 색상을 지정하여 객체의 상태를 추적하고, 이를 통해 GC 효율적 수행
  • Auto tuning: 복잡한 설정 없이 자동으로 최적화되도록 설계되었다. 복잡한 설정을 조정할 필요 없이 ZGC의 혜택을 누릴 수 있다.

ZGC

Heap Allocation

G1 GC(Garbage First Garbage Collector)

G1GC는 Garbage Collection을 보다 효율적으로 수행하기 위해 Heap을 동일한 크기의 여러 힙 영역(Regions)으로 나눕니다. 그리고 각 Heap 영역은 서로 다른 역할을 담당합니다.

  • Eden Space: 새로 생성된 객체들이 할당되는 공간.
  • Survivor Space: Eden 영역에서 생존한 객체들이 이동되는 공간.
  • Old Generation: Survivor 영역에서 생존한 오래된 객체들이 이동되는 공간.

G1 Heap Allocation

ZGC (Z Garbage Collector)

ZGC는 메모리 단편화를 줄이고 관리 효율성을 높이기 위해 다양한 크기의 메모리 블록으로 Heap을 나누어 사용합니다. 마찬가지로 각 Heap 영역은 서로 다른 역할을 담당합니다.

  • Small: 작은 객체들이 할당되는 영역.
  • Medium: 중간 크기 객체들이 할당되는 영역.
  • Large(N x 2 MB): 큰 객체들이 할당되는 영역. N은 객체 크기에 따라 달라집니다.

ZGC Heap Allocation

ZGC를 도입하며 발생하는 Trade-off

ZGC는 별도 설정 없이 자동으로 최적화까지 해주니 모든 면에서 우수해보입니다. 그런데 ZGC를 도입하며 발생할 수 있는 trade-off가 몇가지 있습니다.

1) CPU 자원 효율 문제

ZGC는 초당 1ms 이하의 pause time을 유지하면서도 최대 16TB 크기의 힙을 지원하는 것으로 설계되었습니다. 대부분의 작업을 Java 애플리케이션 스레드가 실행되는 동안 동시에 수행하기 때문에, 그만큼 CPU 자원을 사용하게 되고, 이는 애플리케이션의 처리량(Throughput) 감소에 영향을 줄 수 있습니다.

특히 초기 ZGC(~JDK 21)는 단일 세대 ZGC(Single-generational ZGC 또는 Non-generational ZGC)라고도 부르는데, 이제 막 생성된 객체와 오래된 객체 간의 구분이 따로 없어서 단일 세대라고 부릅니다. 그러다보니 GC 수행 과정에서 모든 객체를 대상으로 정리가 발생하기 때문에, CPU 활용 효율면에서는 조금은 더 취약합니다.

2) 객체 할당 지연(Allocation stall) 문제(← 1번으로 인해 발생)

새로운 객체가 메모리에 할당되는 속도가 GC가 메모리를 회수하는 속도보다 빠를 때 할당 지연(Allocation stall) 문제가 발생합니다. 이건 메모리가 부족하기 때문에 발생하는데, GC가 메모리를 회수하는 동안 애플리케이션은 새로운 객체를 할당하기 위해 기다려야되고, 이로 인해 애플리케이션의 성능이 저하될 수 있습니다. 이건 1번으로 인한 부수 효과인데, 전체 힙 대상으로 한 번에 GC를 수행하기 때문에 처리가 오래 걸리기 때문입니다.


Generational ZGC

그래서 이 문제를 해결하기 위해 JDK 21에 Generational ZGC이 등장했습니다. ZGC와 동일한데 Heap을 논리적으로 두 세대로 분리한 차이가 있습니다(Young, Old). 객체가 할당되면 먼저 자주 스캔되는 Young Generation에 배치되고, 객체가 충분히 오래 살아남으면 Old Generation으로 승격됩니다.

처리량 측면에서, Generational ZGC는 JDK 17의 단일 세대 ZGC 대비 약 10%의 개선을 보였으며, 약간의 저하가 있었던 JDK 21의 단일 세대 ZGC 대비 약 10% 이상의 성능 향상을 보였다고 합니다.

ZGC Young & Old

주요 차이점

ZGC vs. Generational ZGC

Performances

단일 세대와의 성능 차이는 Introducing Generational ZGC에 잘 소개되어 있으니 참고하시면 좋겠습니다.

ZGC Performance

ZGC Performance

ZGC Performance

ZGC Performance

Generational ZGC 활성화

Generational ZGC를 활성화하려면 ZGC 사용을 위한 -XX:+UseZGC 옵션에 더해 -XX:+ZGenerational argument도 추가해줘야 합니다(JDK 버전은 21 이상).

  • 참고로, JDK 21에서 -XX:+ZGenerational 옵션이 없으면 Single-generation ZGC가 기본값입니다. 따라서 이 옵션을 추가하지 않으면 단일 세대의 ZGC가 동작합니다.
  • 미래에는 Generational ZGC가 기본값이 될 예정이라고 합니다.

JVM 메모리 할당량이 커보이는 현상

ZGC 적용 이후에 메트릭을 살펴보면 선언한 Max Heap Size 보다 더 큰 메모리가 관측됩니다. JVM 애플리케이션의 Max Heap Size를 3GB로 설정했는데, Grafana(export by prometheus)에서 관측된 메트릭은 2배 정도인 6GB가 관측되었습니다. 의도하지 않았는데 재밌는 현상입니다.

ZGC JVM Heap

`jvm_memory_max_bytes`: The total memory (in bytes) in the JVM runtime.

1. 다중 매핑:

  • ZGC는 동일한 주소(메모리)를 3개의 다른 view로 매핑: marked0, marked1, remapped
  • 이 3개의 view는 virtual memory address에 반영되는데, 동일 물리 메모리를 3개의 다른 가상 메모리에 맵핑되는 형태. 즉, 각 물리 메모리 페이지에 3개의 가상 페이지가 맵핑됩니다.

2. 가상 메모리 사용 증가:

  • 이런 다중 매핑으로 인해 virtual memory 사용량이 실제 physical memory 사용량보다 크게 나타날 수 있어요.
  • 예를 들어, 힙 사이즈가 1GB인 경우 virtual memory는 최대 3GB까지 관측될 수 있어요.

3. Generational GC 영향:

  • 만약 ZGC가 현재 Single-generation이 아닌 Young, Old 세대로 구성된 generational GC 으로 되면 맵핑 수가 6배까지 증가할 수 있어요.
  • 즉, max heap size의 6배에 해당하는 virtual memory 사용량이 관측될 수 있어요.

결론은 ZGC의 다중 매핑으로 인해 memory 사용량이 실제보다 크게 관측되기 때문이다. 처음엔 Grafana JVM Metric만 보고 잘못 설정한게 있나 헷갈렸는데, 검색하다가 Stackoverflow에 재미난 답변도 발견했다.

ZGC Virtual Memory Space


ZGC 적용후 메트릭 변화

ZGC를 적용하기 전에는 G1GC를 사용중이었고, Eden, Survivor 영역 그리고 jvm parameter 등에 대한 별도 튜닝은 하지 않고 기본 설정을 사용하고 있었습니다.

G1 GC 적용된 애플리케이션 메트릭

아래는 임의의 2개 Pod에서 CPU, Heap Memory와 STW에 의한 Pause Time을 관측한 결과다.

G1GC metric - memory

Eden used: max(1.79Gb), avg(958MB) | CPU usage: system avg(0.114), max(0.137)

G1GC metric - memory

Eden used: max(1.77Gb), avg(888MB) | CPU usage: system avg(0.105), max(0.161)

G1GC metric - pause time

Pause Time: max(1.6ms), avg(259μs)

G1GC metric - pause time

Pause Time: max(1.87ms), avg(223μs)

ZGC 적용된 애플리케이션 메트릭

아래 메트릭도 마찬가지로 ZGC를 적용한 이후에 임의의 2개 Pod에서 CPU, Heap Memory와 STW에 의한 Pause Time을 관측한 결과다. 그런데 이 메트릭은 Generational ZGC가 아닌 Single-generation ZGC가 적용되었을때의 결과다. 그래서 메트릭을 보면 JVM Heap Memory 영역이 ZHeap 하나만 표현된걸 볼 수 있다.

ZGC metric - memory

Heap used: max(998MB), avg(616MB) | CPU usage: system avg(0.153), max(0.351)

ZGC metric - memory

Heap used: max(1.37GB), avg(798MB) | CPU usage: system avg(0.137), max(0.263)

ZGC metric - pause time

Pause Time: max(66.7μs) avg(4.49μs)

ZGC metric - pause time

Pause Time: max(66.7μs) avg(5.51μs)

메트릭 변화(G1 → Non Gernerational ZGC):

Non Gernerational(Single-generational) ZGC를 적용하고 나서 CPU 사용량을 증가하고, Memory 사용량과 STW에 의한 Pause Time은 감소했습니다.

memory usage:

  • used 기준 약 1.68배 감소(1.16 GiB → 0.69 GiB)

cpu usage:

  • max 약 2배 증가 (0.137 → 0.351)
  • avg 약 1.3배 증가 (0.105 → 0.137)

pause time:

  • max 약 28배 감소(1.87ms → 66.7μs)
  • avg 약 47배 감소(259μs → 5.51μs)

다양한 요소를 고려해서 GC 알고리즘 선택하기

GC를 선택할 때 throughput, latency, 리소스 사용량 등 다양한 요소를 고려해야 합니다. 예를 들어, G1과 ZGC는 각각 throughput과 latency 기준으로 애플리케이션 영향도가 달라질 수 있어요.

  • G1은 대부분의 시나리오에서 균형 잡힌 성능을 제공하지만, 변동 폭이 큰 객체 할당이나 오래 실행되는 작업에 적합한 경우가 있고,
  • ZGC는 매우 짧은 pause time을 제공하지만 일부 시나리오에서는 G1보다 낮은 throuput을 보일 수 있습니다.

GC

위에서 ZGC를 적용한 애플리케이션은 제품 특성상 Low Latency를 보장해야 하고 STW로 인한 영향을 최소화해야 하기 때문에 CPU 리소스를 조금 희생하더라도 Memory와 Pause Time 감소라는 이점을 챙길 수 있었습니다. 따라서 애플리케이션의 특성과 요구 사항에 따라 적당한 GC를 선택하기를 권장드립니다. 이런 내용을 고려하지 않고 기본 설정을 변경하면 예상치 못한 장애를 맞을 수 있으니…

1.Latency: 애플리케이션의 빠른 응답이 중요한 경우, pause time을 최소화하는 GC 고려해보기

  • G1GC 또는 ZGC(Generational) 살펴보기
  • 힙 메모리 영역 조정해보기(ex. 객체 크기가 큰데 survivor 영역이 작으면 불필요하게 GC 자주 발생)

2. Pause Time: STW에 따른 pause time이 중요한 경우, 마찬가지로 pause time을 최소화하는 GC 고려해보기

  • G1GC 또는 ZGC(Generational) 살펴보기
  • 실시간 데이터 스트리밍, 대규모 트랜잭션 시스템 등

3. Heap Memory capacity: 애플리케이션의 메모리 사용량 및 힙 크기 중요한 경우

  • Parallel GC나 CMS GC 살펴보기
  • Generational ZGC 살펴보기

4. Throughput: 애플리케이션 처리량 최적화가 중요한 경우

  • Parallel GC 살펴보기

P.S.

ZGC를 적용할 최초 시점에는 -XX:+UseZGC 옵션만 사용해서 Single-generation ZGC를 사용했고, 메트릭 변화와 개선이 필요한 점들을 찾아보다가 Generational ZGC를 접하게 되어 지금은 Generational ZGC를 사용중입니다. Generational ZGC는 단일 세대에 비해 CPU 활용면에서 우수하기 때문에, JDK 21 버전 이상을 쓰고 있다면 (꼭) -XX:+ZGenerational 옵션을 추가해서 Generational ZGC를 활성화하시길 바랍니다.

Generational ZGC CPU usage

Generational 적용 이후 CPU Usage 변화

References