포스트

GC 알고리즘 비교 - G1, ZGC, Shenandoah가 각자 무엇을 포기하는가

GC를 고르는 일은 “가장 좋은 것”을 고르는 일이 아니다. 세 가지 목표(처리량, 지연, 메모리 사용량) 중 무엇을 포기할지 정하는 일이고, 알고리즘마다 포기하는 것이 다르다. 각자가 무엇을 내주는지 적어 두면 선택이 단순해진다.

모든 GC가 푸는 같은 문제

살아 있는 객체를 찾아 표시하고(mark), 죽은 객체의 공간을 회수하고(sweep), 단편화를 줄이기 위해 살아 있는 것을 모은다(compact). 이 중 객체를 옮기는 순간이 가장 어렵다. 애플리케이션 스레드가 옛 주소를 들고 있기 때문이다.

전통적인 해법은 멈추는 것이다(stop-the-world). 모든 애플리케이션 스레드를 세우고 옮긴 뒤 참조를 갱신한다. 정지 시간이 곧 p99에 들어간다.

세대별 가설(generational hypothesis)이 이 비용을 줄인다. 대부분의 객체는 금방 죽으므로, 젊은 영역만 자주 수집하면 적은 정지로 많은 공간을 회수할 수 있다. 문제는 오래 살아남은 객체가 쌓인 영역을 정리할 때다. 그 정지가 길다.

셋이 갈리는 지점

 G1ZGCShenandoah
목표정지 시간 목표치 안에서 처리량정지 시간 상한(수 ms)정지 시간 상한
힙 크기수 GB ~ 수십 GB수십 GB ~ TB비슷
이동 중 정지있음(evacuation pause)거의 없음거의 없음
핵심 기법리전 + 회수 가치 순 수집컬러 포인터 + 로드 배리어로드 참조 배리어
대가정지가 힙에 비례해 늘어남처리량 약간 손해, 메모리 더 씀비슷

G1(Java 9부터 기본)은 힙을 같은 크기의 리전으로 나누고, “회수할 공간 대비 비용”이 좋은 리전부터 수집한다. -XX:MaxGCPauseMillis로 목표를 주면 그에 맞춰 수집 범위를 조절한다. 목표이지 보장이 아니다. 힙이 커지고 살아 있는 객체가 많아지면 목표를 못 지킨다.

ZGC와 Shenandoah는 다른 방향이다. 객체를 옮기는 동안에도 애플리케이션을 돌린다. ZGC는 포인터의 사용하지 않는 비트에 상태를 심고(컬러 포인터), 애플리케이션이 참조를 읽을 때마다 배리어가 개입해 필요하면 새 주소로 고친다. 그래서 정지가 힙 크기와 거의 무관하다.

대가는 로드 배리어의 비용이다. 모든 참조 읽기에 검사가 붙으므로 처리량이 약간 줄고, 메모리 오버헤드도 G1보다 크다. 지연을 사기 위해 처리량과 메모리를 내주는 거래다.

(ZGC는 Java 21에서 세대별(generational) 모드가 추가돼 젊은 객체를 따로 다루면서 처리량 손해를 줄였다. Java 23부터는 그쪽이 기본이다.)

고르는 순서

  1. 기본은 G1이다. 대부분의 서비스에서 충분하고, 튜닝 자료도 가장 많다.
  2. p99 지연이 SLO를 넘고 그 원인이 GC로 확인되면 ZGC를 검토한다. “확인되면”이 중요하다. GC 로그와 지연 스파이크의 시각을 대조하지 않은 채 바꾸면 원인을 못 찾은 채 변수만 는다.
  3. 힙이 수십 GB 이상이면 G1의 정지가 힙에 비례해 늘어나므로 ZGC의 이점이 커진다.
  4. 배치나 처리량 중심 작업이면 오히려 Parallel GC가 나을 수 있다. 정지를 감당할 수 있으면 총 처리량이 가장 높다.

튜닝보다 먼저 볼 것

GC 옵션을 바꾸기 전에 확인할 것이 있다.

할당률(allocation rate). 초당 얼마나 많은 객체를 만드는가. 이 값이 높으면 어떤 GC를 써도 자주 돈다. 불필요한 객체 생성을 줄이는 것이 알고리즘 교체보다 효과가 크다.

누수인지 압박인지. Full GC 후에도 힙이 안 줄면 누수다. GC 튜닝이 아니라 heap dump의 영역이다(Heap Dump가 가리킨 곳).

컨테이너 설정. JVM은 컨테이너 메모리 제한을 읽어 힙 크기를 정한다(MaxRAMPercentage). 제한이 없거나 잘못 잡히면 힙이 예상과 달라진다. CPU 제한도 GC 스레드 수에 영향을 준다(컨테이너 CPU 상한).

이 설명이 깨지는 곳

  • “GC 때문에 느리다”는 대개 검증되지 않은 가설이다. Tomcat 스레드 고갈 실험에서 본 것처럼, p99가 튀는 흔한 원인은 GC가 아니라 자원 대기다.
  • 정지 시간만 보면 안 된다. ZGC의 정지는 짧지만 배리어 비용이 처리 시간 전반에 퍼진다. 총 지연으로 비교해야 한다.
  • 벤치마크의 GC 수치는 워크로드에 크게 의존한다. 객체 수명 분포가 다르면 결과가 뒤집힌다. 자기 애플리케이션으로 재야 한다.
  • Full GC가 사라진 것은 아니다. ZGC도 할당이 회수를 앞지르면 결국 멈춘다.

무엇을 재면 확인되는가

  1. GC 로그(-Xlog:gc*)를 켜고 정지 시각과 지연 스파이크의 시각을 대조한다. 겹치지 않으면 원인은 다른 곳이다.
  2. 같은 부하에서 G1과 ZGC를 번갈아 돌려 p50·p99·처리량을 함께 본다. 지연만 보면 처리량 손해를 놓친다.
  3. 할당률을 인위로 올려 가며(/api/alloc 같은 엔드포인트) 어느 지점에서 정지가 목표를 넘는지 본다.

3번이 spring-ops-lab의 T3으로 계획에 있다. S3(꼬리 지연) 하니스에 할당 주입 엔드포인트가 이미 설계돼 있어 그 위에서 잰다.

실무와의 접점

Heap Dump가 가리킨 곳은 데이터가 아니라 영속성 컨텍스트였다에서, 배치의 메모리 문제를 GC 튜닝이 아니라 처리 구조 변경(Tasklet → Chunk)으로 풀었다. 그 경험의 교훈이 이 글의 결론과 같다. GC는 애플리케이션이 만든 쓰레기를 치우는 쪽이지, 덜 만들게 하는 쪽이 아니다. 알고리즘 선택은 마지막 손잡이이고, 그 앞에 할당률과 객체 수명이 있다.

정리

  • 모든 GC의 어려운 부분은 객체를 옮기는 순간이다. 전통적으로는 멈춰서 옮긴다.
  • G1은 정지 시간 목표를 주고 그 안에서 수집 범위를 조절한다. 목표이지 보장이 아니다.
  • ZGC와 Shenandoah는 배리어로 이동 중에도 애플리케이션을 돌린다. 정지가 힙 크기와 거의 무관하다.
  • 대가는 처리량과 메모리다. 지연을 사기 위해 내주는 것이다.
  • 기본은 G1, p99가 SLO를 넘고 원인이 GC로 확인되면 ZGC, 처리량 중심이면 Parallel도 후보다.
  • 알고리즘을 바꾸기 전에 할당률, 누수 여부, 컨테이너 설정을 본다.

참고

Spring과 JVM 백엔드 장애 대응과 관측
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다