Skip to main content

Command Palette

Search for a command to run...

Java GC의 진화 — Serial에서 Generational ZGC까지

Published
9 min read

Java가 약속한 것 중 하나는 "메모리는 내가 관리할게"였다.

C/C++ 개발자들이 mallocfree로 메모리와 씨름하던 시절, Java는 Garbage Collector(GC)라는 자동 메모리 관리자를 들고 나왔다. 개발자는 객체를 만들기만 하면 되고, 치우는 건 GC가 알아서 한다.

하지만 "알아서"라는 말에는 대가가 있었다. GC가 동작하는 동안 애플리케이션이 멈추는 것이다. 이 멈춤을 Stop-The-World(STW) 일시 정지라고 부른다. Java GC의 역사는 이 일시 정지를 줄이기 위한 끊임없는 도전의 기록이다.

이 글은 Java GC의 진화를 세 시대로 나눠 살펴본다. G1GC 이전, G1GC, 그리고 G1GC 이후. 각 시대의 GC가 어떤 문제를 해결하려 했고, 어떤 한계를 남겼는지를 따라가 보자.


1부. G1GC 이전 — 단순함의 한계

Serial GC — 모든 것의 시작

Java 초기부터 존재해 온 가장 단순한 GC다. 이름 그대로, 단일 스레드로 동작한다.

App Threads:  ──────────┃ STW ┃──────────
GC Thread:              ┃█████┃

GC가 시작되면 모든 애플리케이션 스레드가 멈추고, GC 스레드 하나가 힙 전체를 정리한 뒤에야 애플리케이션이 다시 동작한다.

알고리즘은 두 가지를 조합한다:

  • Young Generation: Mark-Copy — 살아있는 객체를 식별하고 새로운 영역에 복사
  • Old Generation: Mark-Sweep-Compact — 살아있는 객체를 식별하고, 죽은 객체를 제거하고, 남은 객체를 압축

힙이 작고 CPU가 하나뿐인 환경에서는 이것으로 충분했다. 하지만 서버 환경에서 힙이 커지면서, GC 동안 애플리케이션이 수 초간 멈추는 것은 용납할 수 없었다.

-XX:+UseSerialGC

Parallel GC — 스레드를 늘려 처리량을 높이다

Serial GC의 해법은 단순했다. 스레드를 더 쓰자.

App Threads:  ──────────┃  STW  ┃──────────
GC Thread 1:            ┃██████ ┃
GC Thread 2:            ┃██████ ┃
GC Thread 3:            ┃██████ ┃
GC Thread 4:            ┃██████ ┃

여러 GC 스레드가 동시에 힙을 정리하므로, 같은 힙 크기에서 GC 시간이 크게 단축된다. 이름에 걸맞게 Throughput Collector라고도 불린다. 처리량(단위 시간당 처리하는 작업량)을 극대화하는 것이 목표다.

JDK 8까지 서버 환경의 기본 GC였다. -XX:GCTimeRatio=99로 GC 시간을 전체의 1% 이하로 유지하는 것이 기본 목표였고, -XX:+UseAdaptiveSizePolicy로 힙 크기를 자동 조절하는 기능도 갖추고 있었다.

하지만 근본적인 한계는 그대로였다. GC 동안 여전히 애플리케이션이 멈춘다. 스레드를 늘려서 STW 시간을 줄였을 뿐, STW 자체를 없앤 것이 아니다. 힙이 수 GB로 커지면 일시 정지가 수백 밀리초에서 수 초까지 늘어났다.

-XX:+UseParallelGC

CMS — 동시 수집의 첫 시도

Concurrent Mark Sweep(CMS)는 발상의 전환을 했다. GC 작업의 대부분을 애플리케이션과 동시에 수행하자는 것이다.

App Threads:  ─┃STW┃───────────────┃STW┃──
GC Threads:    ┃███┃▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒┃███┃
               Init  Concurrent     Remark
               Mark  Mark/Sweep

CMS는 4단계로 동작한다:

  1. Initial Mark (STW) — GC Root에서 직접 참조하는 객체만 마킹. 짧은 일시 정지.
  2. Concurrent Mark — 애플리케이션과 동시에 실행하며 참조 그래프를 순회. STW 없음.
  3. Remark (STW) — Concurrent Mark 도중 변경된 참조를 보정. 짧은 일시 정지.
  4. Concurrent Sweep — 죽은 객체를 제거. STW 없음.

핵심은 가장 시간이 오래 걸리는 Mark와 Sweep을 동시(Concurrent)에 처리한다는 것이다. STW는 Initial Mark와 Remark에서만 발생하고, 이 두 단계는 상대적으로 짧다.

혁신적이었지만, CMS에는 치명적인 약점이 있었다:

  • 메모리 단편화: Sweep 후 Compact(압축)를 하지 않으므로, 힙에 빈 공간이 흩어진다. 큰 객체를 할당할 연속 공간이 없으면 Full GC가 발생한다.
  • Concurrent Mode Failure: GC가 끝나기 전에 Old Generation이 가득 차면, Serial GC로 폴백하여 긴 STW가 발생한다.
  • 복잡한 튜닝: CMS 전용 옵션만 72개. 일반 GC 옵션 50개까지 합치면 120개 이상의 매개변수를 다뤄야 했다.

결국 CMS는 JDK 9에서 deprecated, JDK 14에서 완전 제거되었다. JEP 363은 제거 사유로 "유지보수를 맡을 기여자가 없다"고 밝혔다. 72개의 튜닝 옵션이 만든 복잡성의 당연한 귀결이었다.

G1GC 이전 시대의 교훈

세 GC를 관통하는 딜레마가 있다. 처리량(Throughput)과 지연 시간(Latency)은 트레이드오프라는 것이다.

GC목표STW 방식한계
Serial단순함전체 STW, 단일 스레드대규모 힙에서 긴 일시 정지
Parallel처리량전체 STW, 멀티 스레드STW 자체는 제거 불가
CMS낮은 지연부분 STW + 동시 수집단편화, 복잡한 튜닝

이 트레이드오프를 근본적으로 해결하려면, 힙을 관리하는 방식 자체를 바꿔야 했다. 그것이 G1GC다.


2부. G1GC — 힙을 쪼개다

Region이라는 발상

G1GC 이전의 모든 GC는 힙을 연속된 두 영역(Young, Old)으로 나눴다. G1GC는 이 구조를 완전히 바꿨다.

힙을 동일한 크기의 Region(1~32MB)으로 쪼갠다. 각 Region은 Eden, Survivor, Old, Humongous(대형 객체용) 중 하나의 역할을 동적으로 맡는다. 고정된 영역 경계가 없다.

이 구조가 혁명적인 이유는, 전체 힙을 수집할 필요가 없어졌기 때문이다.

Garbage-First — 쓰레기가 많은 곳부터

G1GC의 이름인 Garbage-First는 동작 방식 그 자체다. 모든 Region의 "쓰레기 비율"을 추적하고, 쓰레기가 가장 많은 Region부터 수집한다.

Garbage ratio per Region:

Region A: ████████░░  80% garbage  ← collect first
Region B: ██████░░░░  60% garbage  ← next
Region C: ██░░░░░░░░  20% garbage  ← later
Region D: █░░░░░░░░░  10% garbage  ← if time permits

핵심은 예측 가능한 일시 정지 시간이다. -XX:MaxGCPauseMillis=200 (기본값)으로 목표 일시 정지 시간을 설정하면, G1GC는 그 시간 안에 수집할 수 있는 만큼만 Region을 선택한다. 200ms 안에 모든 Region을 수집하지 못해도 괜찮다. 쓰레기가 많은 곳부터 했으니, 제한된 시간 안에 최대한의 공간을 확보한 셈이다.

G1GC의 동작 사이클

Young GC (STW)
    │
    ▼
Concurrent Mark (Mostly Concurrent)
    │   ├── Initial Mark (STW, piggybacks on Young GC)
    │   ├── Root Region Scan
    │   ├── Concurrent Mark
    │   ├── Remark (STW)
    │   └── Cleanup (STW + Concurrent)
    ▼
Mixed GC (STW)  ← Young + high-garbage Old Regions

G1GC는 세 가지 모드로 동작한다:

  1. Young GC: Eden이 가득 차면 발생. 살아남은 객체를 Survivor 또는 Old Region으로 이동.
  2. Concurrent Mark: Old 영역의 사용량이 임계치(-XX:InitiatingHeapOccupancyPercent, 기본 45%)를 넘으면 시작. 어떤 Region에 쓰레기가 많은지 파악.
  3. Mixed GC: Concurrent Mark 결과를 바탕으로, Young Region과 쓰레기가 많은 Old Region을 함께 수집.

CMS와 비교하면:

항목CMSG1GC
힙 구조Young/Old 연속 영역Region 기반
압축하지 않음 (단편화)Region 단위로 압축
목표최소 일시 정지예측 가능한 일시 정지
튜닝 복잡도72개 전용 옵션핵심 옵션 소수
Full GC 위험Concurrent Mode Failure드물지만 발생 가능

G1GC의 위상

  • JDK 6u14: 실험적 도입
  • JDK 7u4: 정식 지원
  • JDK 9: 기본 GC로 채택 (Parallel GC를 대체)

G1GC는 현재까지도 가장 널리 사용되는 GC다. 대부분의 서버 애플리케이션에서 별도 튜닝 없이도 양호한 성능을 제공한다. 하지만 G1GC도 완벽하지 않다. 일시 정지 시간의 목표를 설정할 수는 있지만, 보장하지는 못한다. 힙이 수십 GB로 커지면, 일시 정지가 수백 밀리초에 이를 수 있다.

금융 거래 시스템, 실시간 데이터 처리, 대규모 인메모리 데이터베이스 — 이런 워크로드는 밀리초 단위의 일시 정지도 허용할 수 없다. 그래서 다음 세대가 필요했다.


3부. G1GC 이후 — 일시 정지를 밀리초 미만으로

ZGC — 힙 크기와 무관한 일시 정지

Z Garbage Collector(ZGC)는 JDK 11에서 실험적으로 등장하고, JDK 15에서 정식 지원된 초저지연 GC다. 설계 목표는 명확하다:

힙 크기에 관계없이 일시 정지 시간을 1ms 미만으로 유지한다.

8MB 힙이든 16TB 힙이든, 일시 정지 시간이 동일하다. 어떻게 가능할까?

ZGC의 핵심 기술은 Colored PointersLoad Barriers다.

Colored Pointers: ZGC는 객체 참조(포인터)의 상위 비트에 GC 메타데이터를 저장한다. 포인터 자체에 "이 객체가 이동되었는지", "마킹되었는지" 같은 정보가 담겨 있다. 별도의 마킹 비트맵을 참조할 필요가 없으므로, GC 상태 확인이 매우 빠르다.

Load Barriers: 애플리케이션이 객체 참조를 읽을 때 가로채서, 해당 참조가 최신 상태인지 확인한다. 객체가 이동되었다면 새 주소로 투명하게 갱신한다. 이 덕분에 ZGC는 객체 이동(Compaction)을 애플리케이션과 동시에 수행할 수 있다.

G1GC pause:

  ──────┃████████████████████┃──────
        ← tens~hundreds ms →

ZGC pause:

  ──────┃█┃───────────────────┃█┃──
        ←→                    ←→
       ~0.05ms              ~0.05ms

실제 벤치마크에서 ZGC의 평균 일시 정지 시간은 약 50μs(마이크로초), 최대 일시 정지 시간은 약 500μs로 측정되었다. G1GC가 20ms 이상의 일시 정지를 보이는 것과 비교하면, 평균 기준으로 400배 이상의 차이다.

-XX:+UseZGC

Generational ZGC — 세대를 되찾다

초기 ZGC에는 한 가지 약점이 있었다. 세대 구분이 없었다. 매번 힙 전체를 대상으로 GC를 수행해야 했고, 이는 두 가지 문제를 만들었다:

  1. 처리량 손실: 수명이 짧은 객체(Young)도 긴 수명 객체(Old)와 같은 비용으로 수집
  2. 할당 지연(Allocation Stall): GC가 메모리를 회수하는 속도보다 애플리케이션이 메모리를 할당하는 속도가 빠르면, 애플리케이션이 GC 완료를 기다려야

Netflix의 기술 블로그에 따르면, 동시 클라이언트 75개를 넘어서면 단일 세대 ZGC에서 할당 지연이 급격히 발생했다.

JDK 21에서 도입된 Generational ZGC(JEP 439)는 이 문제를 해결했다. "대부분의 객체는 금방 죽는다"는 약한 세대 가설(Weak Generational Hypothesis)을 ZGC에도 적용한 것이다.

Single-Generation ZGC:

  ┌───────────────────────────────────────┐
  │  Entire heap collected every cycle    │
  └───────────────────────────────────────┘

Generational ZGC:

  ┌──────────────┐  ┌────────────────────┐
  │    Young     │  │       Old          │
  │  frequent    │  │   infrequent       │
  └──────────────┘  └────────────────────┘

Young 객체를 자주, 빠르게 수집하고, Old 객체는 필요할 때만 수집한다. 결과는:

항목Single-Gen ZGCGenerational ZGC개선
처리량기준+10%세대별 수집으로 효율 증가
P99 일시 정지기준-20~30μs이미 낮았지만 더 개선
할당 지연75 클라이언트 초과 시 발생275 클라이언트까지 안정3.6배 더 많은 동시 처리

Generational ZGC는 JDK 21에서 정식 기능으로 도입되었고(단, 명시적 활성화 필요), JDK 23부터 기본 ZGC 모드가 되었다.

# JDK 21~22
-XX:+UseZGC -XX:+ZGenerational

# JDK 23+
-XX:+UseZGC  (Generational이 기본)

전체 타임라인

JDKGC 관련 변화
1.0Serial GC 도입
1.4.1Parallel GC, CMS 도입
6u14G1GC 실험적 도입
7u4G1GC 정식 지원
8Parallel GC가 기본
9G1GC가 기본, CMS deprecated
11ZGC 실험적 도입, Epsilon GC 도입
12Shenandoah 실험적 도입
14CMS 완전 제거
15ZGC 정식 지원
21Generational ZGC 도입 (명시적 활성화 필요)
23Generational ZGC가 기본 ZGC 모드
25Generational Shenandoah 정식 지원

어떤 GC를 선택할 것인가

우선순위추천 GC적합한 워크로드
처리량 최대화Parallel GC배치 처리, 과학 계산, 데이터 파이프라인
균형G1GC (기본)대부분의 웹 애플리케이션, 마이크로서비스
초저지연ZGC금융 거래, 실시간 처리, 대규모 힙(16TB까지)

확신이 없다면? G1GC를 쓰면 된다. JDK 9부터 기본 GC이고, 대부분의 워크로드에서 튜닝 없이도 충분한 성능을 제공한다. 일시 정지가 문제가 된다면 그때 ZGC를 고려하면 된다.


마무리

Java GC의 역사를 한 줄로 요약하면 이렇다:

"애플리케이션을 멈추지 않으면서, 어떻게 메모리를 회수할 것인가?"

Serial GC는 모든 것을 멈추고 청소했다. Parallel GC는 여러 명이 함께 청소해서 시간을 줄였다. CMS는 청소하면서 동시에 일도 했지만, 정리 정돈은 포기했다. G1GC는 구역을 나눠 효율적으로 관리했다. ZGC는 거의 멈추지 않는 경지에 이르렀다.

각 세대의 GC는 이전 세대의 한계를 넘기 위해 탄생했다. 그리고 이 진화는 계속되고 있다. Generational ZGC, Generational Shenandoah — 아직 끝나지 않았다.

중요한 것은 "최신 GC가 최고"가 아니라는 점이다. 배치 처리에 ZGC를 쓸 이유는 없고, 실시간 거래 시스템에 Serial GC를 쓸 이유도 없다. 자신의 워크로드를 이해하고, 그에 맞는 GC를 선택하는 것. 그것이 GC의 역사가 우리에게 남긴 교훈이다.


참고 자료

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ

Java GC의 진화 — Serial에서 Generational ZGC까지