bedaily.me
Java2026년 6월 23일25분 읽기

자바 가비지 컬렉터, 구조와 진화

자바 GC는 하나가 아니라 여러 개가 공존합니다. 먼저 mark·sweep·compact, 세대, barrier 같은 핵심 개념을 그림으로 잡고, Serial부터 ZGC·Shenandoah까지 각 GC의 힙 구조와 수거 방식을 구조도와 함께 정리합니다.

JavaGCJVMZGCG1

왜 GC가 여러 개인가

가비지 컬렉션은 더 이상 쓰이지 않는 객체를 자동으로 찾아 메모리를 회수하는 일이다. 그런데 애플리케이션마다 무엇이 중요한지가 다르다. 어떤 곳은 총 처리량(throughput), 어떤 곳은 응답 지연(latency), 어떤 곳은 메모리 사용량(footprint)이 가장 중요하다. JVM은 각 지표에 특화된 여러 GC를 두고, 지정하지 않으면 균형형 기본값을 골라준다.

heap0x…FFFFalloc → mark → relocate
힙 메모리는 객체로 채워지고(alloc) → 살아있는 것을 표시하고(mark) → 옮겨 정리한다(relocate)

아래 색은 이 글의 모든 다이어그램에서 같은 의미로 쓰인다.

Eden / Young (신규 할당)SurvivorOld (장기 생존)HumongousGarbage (죽은 객체)STW (앱 정지)Concurrent (동시 수거)
색상 범례 — 모든 도식 공통

먼저, 핵심 개념

개별 GC로 들어가기 전에 모든 GC가 공유하는 기본 동작과 용어를 그림으로 잡는다. 여기 나오는 mark·sweep·compact, 세대(generation), barrier 세 가지만 알면 뒤의 구조도가 전부 읽힌다.

① GC가 하는 일 — mark, sweep, compact

GC의 기본 동작은 세 단계다. 살아있는 객체를 표시하고(mark), 표시되지 않은 죽은 객체의 자리를 회수하고(sweep), 살아있는 객체를 한쪽으로 모아 빈틈을 없앤다(compact). compact를 생략하면 빈 공간이 잘게 흩어지는 단편화(fragmentation)가 생긴다.

① mark · 살아있는 객체(파랑)를 표시, 나머지는 죽은 것(빗금)② sweep · 죽은 객체 회수 → 빈 공간이 흩어짐(단편화)빈틈빈틈빈틈③ compact · 살아있는 객체를 모아 빈 공간을 하나로(큰 객체도 들어감)연속된 빈 공간
GC가 일하는 동안 앱을 멈추면 STW, 멈추지 않고 같이 돌면 concurrent 방식이다

② 세대 구분 — 대부분의 객체는 금방 죽는다

경험적으로 객체 대부분은 생성 직후 곧 쓸모없어진다(weak generational hypothesis). 그래서 갓 만든 객체는 Young, 오래 살아남은 객체는 Old로 나누고, 쓰레기가 몰려 있는 Young만 자주 수거한다. 적은 비용으로 많은 쓰레기를 치우는 전략이다. Young은 다시 Eden(새 객체가 처음 놓이는 곳)과 Survivor(한 번 살아남아 잠시 머무는 곳)로 나뉜다.

객체 수명 분포 — 왼쪽(갓 생성)에 죽음이 몰림생성 직후 대부분 사망오래 생존(소수)EdenS0S1Young (자주 수거)OldOld (가끔 수거)
수명 분포의 왼쪽(갓 생성)에 죽음이 몰린다 → 그쪽(Young)을 집중 수거

③ barrier — 객체를 옮기는 중에도 길을 안내하는 검문소

저지연 GC는 앱을 멈추지 않고 살아있는 객체를 다른 자리로 복사·이동한다(evacuation/relocation). 문제는 그 사이 앱이 옛 주소를 읽을 수 있다는 점이다. barrier는 이를 막기 위해 JVM이 참조를 읽는 곳(load)과 쓰는 곳(store)마다 끼워넣는 아주 작은 검사 코드다. 일종의 자동 통행 검문소로, 객체가 이미 옮겨졌으면 새 위치로 자동 보정해 준다. ZGC는 여기에 더해 포인터(주소)의 안 쓰는 비트에 GC 상태를 색처럼 새기는 colored pointer를 써서, 주소를 읽는 순간 처리가 필요한지 바로 판단한다.

앱 코드obj.field 읽기barrier위치 확인옛 위치 (이동됨)새 위치 (정답)앱이 옛 주소를 읽어도 barrier가 새 위치로 보정
앱이 옛 주소를 읽어도 barrier가 새 위치로 보정 → 앱 정지 없이 객체 이동 가능

자주 나오는 용어

용어
STW (Stop-The-World)GC가 일하는 동안 앱 스레드를 전부 멈추는 것. 이 시간이 곧 사용자 응답 지연이 된다.
throughput / latency / footprint처리량 / 응답 지연 / 메모리 사용량. GC마다 무엇을 우선하는지가 다르다.
region힙을 같은 크기의 작은 칸으로 쪼갠 단위(G1·ZGC·Shenandoah). 칸 단위로 골라 수거해 유연하다.
compaction / 단편화(fragmentation)살아있는 객체를 모아 빈틈을 없애는 것이 compaction. 안 하면 빈 공간이 흩어져 큰 객체를 못 넣는다.
concurrent앱을 멈추지 않고 GC가 동시에 도는 방식. STW를 크게 줄인다.
evacuation / relocation살아있는 객체를 다른 region으로 복사해 옮기는 것. 옮긴 칸은 통째로 비운다.
colored pointer포인터(주소)의 안 쓰는 비트에 GC 상태를 색처럼 새겨, 주소를 읽을 때 처리 필요 여부를 즉시 판단하는 ZGC 기법.
allocation stall앱이 객체를 만드는 속도가 GC 회수 속도보다 빨라, 메모리가 빌 때까지 앱이 잠깐 멈추는 현상.
compressed oops64비트 JVM에서 참조를 32비트로 압축해 메모리를 아끼는 기법. Shenandoah는 지원, ZGC는 큰 힙에서 미지원.
generational힙을 Young/Old로 나눠 수거하는 방식. 쓰레기가 몰린 Young을 자주 치워 효율을 높인다.

출현과 정식화 이력

JDK 버전을 따라 각 GC가 언제 등장(experimental)하고 언제 정식(production)이 됐는지의 흐름이다. 주황 점은 실험 도입, 초록 점은 정식/기본값, 보라 점은 세대화 단계, 빨강 점은 deprecated·제거를 뜻한다.

1.45–8911121521232425Serial처음부터 정식 · 전 버전 존재ParallelJDK 5–8 기본CMS9 deprecated14 제거G19 기본 GC (서버급)ZGC11 exp15 정식21 gen23 gen 기본24 non-gen 제거Shenandoah12 exp15 정식24 gen exp25 gen 정식
GC 출현·정식화 타임라인

GC별 구조도

각 GC의 위쪽 그림은 힙 배치, 아래쪽 그림은 수거 동작 타임라인이다. 타임라인에서 가로축은 시간, 빨간 영역은 앱이 멈추는 STW, 초록 막대는 앱과 동시에 도는 GC 작업이다. 위에서 아래로 내려갈수록 빨간 영역이 줄어드는 게 GC 발전의 핵심이다.

Serial GC — 단일 스레드, 전부 STW

가장 단순한 GC다. 수거할 때 앱을 전부 멈추고(STW) GC 스레드 하나가 mark·sweep·compact를 차례로 처리한다. 구조가 단순해 오버헤드가 작은 대신, 힙이 커질수록 멈추는 시간이 그대로 길어진다.

힙 구조 — 세대 분리(Young + Old)EdenS0S1OldYoung Generation수거 동작 — 앱 전부 정지 + GC 스레드 1개STW (전부 멈춤)GC
앱이 전부 멈춘 사이 단 하나의 GC 스레드가 수거를 끝내고 다시 앱을 깨운다
  • 동작 — Young/Old 세대 구조에서 단일 스레드가 mark·copy·compact를 모두 STW로 처리한다.
  • 강점 — 오버헤드가 작아 단일 CPU·소규모 힙·짧은 수명 프로세스(예: CLI 도구)에 적합하다.
  • 약점 — 힙이 커지면 STW가 길어져 서버 워크로드엔 부적합하다.

Parallel GC — 멀티 스레드 STW (Throughput Collector)

Serial과 같은 세대 구조지만, 멈춘 동안 GC 스레드를 여러 개 돌려 수거를 빨리 끝낸다. 멈추는 건 똑같으나 한 번 멈추는 시간이 짧아져 총 처리량이 높다. 그래서 "처리량 컬렉터"라 불린다. JDK 5~8의 기본 GC였다.

힙 구조 — Serial과 동일(세대 분리)EdenS0S1Old수거 동작 — 앱 정지 + GC 스레드 여러 개 병렬STW (짧아짐)GC
STW는 같지만 GC 스레드를 여러 개 돌려 멈춤 시간을 줄인다
  • 동작 — Serial과 같은 세대 구조에 GC 스레드를 병렬화해 STW 시간을 단축한다.
  • 강점 — 총 처리량이 높아 4~8시간 배치처럼 throughput만 중요한 작업에 최적이다.
  • 약점 — 여전히 STW 기반이라 응답 지연이 중요한 서비스엔 부적합하다.

CMS — Concurrent Mark Sweep (1세대 저지연 GC)

처음으로 "앱을 멈추지 않고 마킹"을 시도한 저지연 GC다. 꼭 필요한 두 순간만 짧게 멈추고, 나머지 마킹·회수는 앱과 동시에 돈다. 다만 compact(모으기)를 하지 않아 빈 공간이 흩어지고(단편화), 결국 긴 full GC로 떨어지는 약점이 있었다.

sweep 후 Old 영역 — compact 없음 → 빈틈(빗금)이 흩어짐수거 동작 — 짧은 STW 2번 + 동시 마킹·스윕initremarkGCconcurrent marksweep
init mark·remark만 짧게 멈추고 마킹·스윕은 앱과 동시에 — 단 compact가 없어 단편화가 쌓인다
  • 동작 — 동시 마킹으로 pause를 줄인 최초의 저지연 GC. 핵심 단계(init mark / remark)만 짧게 STW로 처리한다.
  • 한계 — compact를 하지 않아 단편화가 누적되고 결국 긴 full GC로 떨어진다.
  • 퇴장 — 유지보수 비용까지 겹쳐 JDK 9에서 deprecated, JDK 14에서 제거됐다.

G1 GC — Garbage-First, region 기반 (현재 기본값)

힙을 같은 크기의 작은 칸(region)으로 쪼개고, 쓰레기가 가장 많은 칸부터 골라 수거한다("가비지 우선"). 전체를 한꺼번에 멈추지 않고 이득이 큰 칸만 짧게 STW로 처리하므로, 멈춤 시간을 어느 정도 예측·목표 설정할 수 있다. latency와 throughput의 균형이 좋아 현재 기본 GC다.

힙을 균등 region으로 분할 · 가비지 많은 region부터 수거EGOEHOSGOEGSOEGO초록 점선 = 이번 수거 대상(가비지 우선) · E Eden / S Survivor / O Old / H Humongous(대형) / G GarbageGCSTW지만 짧고 점진적(incremental)
힙을 작은 칸으로 나눠 이득이 큰 칸만 골라 짧게 STW 수거 — pause 예측성과 처리량의 균형
  • 동작 — 힙을 균등 region으로 나누고 가비지가 많은 region부터 evacuation한다. STW지만 점진적이라 수십~수백 ms 범위로 제어된다.
  • 위치 — JDK 9부터 기본 GC(서버급)다. 제약 환경의 Serial 폴백까지 없애 모든 환경의 보편 기본값으로 만드는 제안(JEP 523)은 JDK 27 타깃으로, 아직 출시 전이다.
  • 강점 — latency·throughput·footprint의 균형이 좋아 범용 기본값으로 적합하다.

Epsilon GC — No-op, 회수하지 않는 GC

이름은 GC지만 실제로는 메모리를 회수하지 않는다. 할당만 처리하다 힙이 다 차면 종료된다. GC 오버헤드를 0으로 만든 상태에서 순수 성능이나 메모리 한계를 측정하는 테스트용이다.

할당만 누적 · 회수 없음 → 결국 OutOfMemory→ → → 할당 진행 (수거 단계 없음)
회수를 하지 않으므로 수거 타임라인 자체가 없다
  • 동작 — 할당만 처리하고 회수는 전혀 하지 않는다. 힙이 차면 종료/OOM이다.
  • 용도 — GC 오버헤드를 0으로 둔 성능·메모리 압박 테스트, 매우 짧은 작업용이다. 실서비스용이 아니다.
  • 위치 — JDK 11에 experimental로 추가됐다.

ZGC — colored pointer + barrier (sub-millisecond pause)

"힙이 아무리 커도 멈춤은 1ms 미만"을 목표로 한 저지연 GC다. 마킹뿐 아니라 객체를 옮기는 일(relocation)까지 앱과 동시에 한다. 비결은 colored pointer(주소에 GC 상태를 색처럼 새김)와 load barrier(주소를 읽을 때마다 그 색을 검사해 옮긴 객체면 새 위치로 보정)다. 덕분에 앱을 멈추지 않고도 객체를 안전하게 이동·압축할 수 있다.

힙 구조 — generational + region 기반 (최대 16TB)Young (자주 수거)Old (가끔 수거)colored pointer — 주소의 빈 비트에 GC 상태를 색으로 새김colorobject addressload barrier가 color 검사 후 새 위치로 재매핑STW 1ms 미만 (가는 띠)GCconcurrent markconcurrent relocate (이동)
마킹도 이동도 앱과 동시에. 멈추는 건 1ms 미만의 가는 띠뿐이라 힙 크기가 커져도 멈춤이 길어지지 않는다
  • 목표 — 힙 크기와 무관하게 pause를 sub-millisecond로 유지한다(수백 MB~16TB).
  • 비결 — colored pointer + load barrier로 객체 이동(relocation)·compaction까지 앱 정지 없이 동시 수행한다.
  • 세대화 — JDK 11 exp → 15 정식 → 21 generational 추가(JEP 439) → 23 generational 기본(JEP 474) → 24 non-generational 제거(JEP 490). 현재는 사실상 generational 단일이다.
  • 비용 — 보통 25~35% 메모리 헤드룸이 필요해 작은 컨테이너엔 부담이 된다. 큰 힙에선 compressed oops를 쓰지 못한다.

Shenandoah GC — 동시 evacuation, load/store barrier

ZGC와 목표가 같은 저지연 GC다. 살아있는 객체를 앱이 도는 중에 새 칸으로 복사·압축(evacuation)하고, 그동안 참조를 읽고(load) 쓰는(store) 모든 지점에 끼워둔 barrier가 항상 최신 위치를 가리키게 만든다. Red Hat이 만들었고, compressed oops를 지원해 ZGC보다 메모리 오버헤드가 낮은 게 특징이다.

힙 구조 — region 기반 · 동시 evacuation(살아있는 객체를 새 칸으로 복사)livedeadlivefrom-region (옮기기 전)copyto-region (압축 완료)load/store barrier로 참조 보정수거 동작 — 마킹·이동 전부 앱과 동시STW 수 ms 이하 (가는 띠)GCconcurrent markconcurrent evacuation (이동)
살아있는 객체를 앱이 도는 중에 새 칸으로 복사·압축하고, barrier가 이동 중 참조를 새 위치로 따라가게 만든다
  • 목표 — 힙 크기와 무관하게 pause를 보통 수 ms 이하로 유지한다.
  • 비결 — load/store barrier 기반 동시 evacuation. compressed oops를 지원해 ZGC보다 메모리 오버헤드가 낮다.
  • 출신 — Red Hat 개발. JDK 24 generational 실험(JEP 404) → JDK 25에서 정식(JEP 521).

왜 ZGC·Shenandoah가 나왔나

기존 GC들은 큰 힙에서 멈춤 시간(pause)이 힙 크기에 비례해 커지는 근본 문제가 있었다. mark와 compact가 STW로 묶여 있기 때문이다. ZGC·Shenandoah는 그 작업까지 barrier로 보호하며 앱과 동시에 돌려, 멈춤을 힙 크기와 분리했다. 아래는 성격을 보여주기 위한 개념적 비교다(정확한 수치 아님).

Serial~수백 ms+Parallel~수백 msCMS중간 + 단편화G1수십~수백 msShenandoah~수 ms 이하ZGC1 ms 미만
개념적 pause 비교 (실제 수치 아님) — 저지연 GC는 힙이 커져도 pause가 거의 일정

ZGC와 Shenandoah, 왜 둘 다 있나

"ZGC가 먼저 있는데 Shenandoah가 왜 나왔나"라는 순서는 사실 반대에 가깝다. 한쪽이 다른 쪽의 후발주자가 아니라, 서로 다른 회사가 같은 목표(긴 STW를 없애는 동시성 GC)를 두고 비슷한 시기에 따로 개발한 경쟁작이다. Shenandoah의 설계는 오히려 더 일찍 시작됐고, 메인라인 OpenJDK 등록만 ZGC가 한 발 빨랐다.

  • ~2014 — Shenandoah 개발 시작 (Red Hat)
  • JDK 11 — ZGC experimental (JEP 333, Oracle)
  • JDK 12 — Shenandoah experimental (JEP 189)
  • JDK 15 — 둘 다 정식(production): ZGC JEP 377 / Shenandoah JEP 379
항목ZGCShenandoah
개발 주도OracleRed Hat
메인라인 등장JDK 11 (experimental)JDK 12 (experimental)
정식(production)JDK 15 (JEP 377)JDK 15 (JEP 379)
객체 이동 보호colored pointer + load barrier초기 forwarding(Brooks) pointer + read barrier → 이후 load reference barrier로 개선
compressed oops큰 힙에선 미사용(주소 비트를 메타데이터로 사용)지원 → 메모리 오버헤드 낮음
설계 강조점초대형 힙 + 극저지연을 강하게 목표다양한 힙 크기에서 낮은 pause
배포·환경Oracle JDK 기본 포함Oracle JDK엔 미포함, Temurin·Corretto 등 OpenJDK 배포판에서 사용

정확히 짚으면: 둘은 JDK 11/12에서 정식이 된 게 아니라 그때는 experimental이었고, 정식 전환은 둘 다 JDK 15다. 힙 크기 구분(중대형 vs 초대형)도 절대 기준이 아니라 과거 설계 강조점에 가깝다 — 지금은 양쪽 다 넓은 범위를 지원한다.

정리하면 Shenandoah와 ZGC는 같은 문제(긴 GC Stop-The-World)를 해결하려 서로 다른 진영에서 개발된 동시성 GC다. 객체 이동 방식과 barrier 설계 철학에서 차이를 보이며, 둘 중 하나가 다른 하나의 대체재라기보다 JVM 배포판·메모리 여유·힙 크기 같은 요구사항에 따라 선택하는 관계에 가깝다.

JDK 기본값과 G1

  • 기본값은 여전히 G1 — GC를 지정하지 않으면 G1이 선택된다. JDK 9 이후 서버급 환경의 기본값으로 자리 잡았고, JDK 25에서도 그대로다. java만 실행하면 G1이고, ZGC·Shenandoah는 명시적으로 켜야 한다.
  • JEP 523의 위치 — 제약 환경에서 Serial로 폴백하던 동작까지 없애 G1을 "모든 환경의 보편 기본값"으로 만드는 제안이 JEP 523이다. 다만 이는 JDK 27 타깃으로 아직 출시 전이다(JDK 25에 들어간 기능이 아니다).
  • 방향은 탈G1이 아니라 G1 단일화 — G1을 끌어내리려는 공식 논의는 없다. 오히려 G1을 모든 지표(throughput·latency·footprint)에서 개선해 작은 힙에서도 Serial과 경쟁 가능하게 만들고, Serial 폴백을 G1로 통일하려는 흐름이다.
  • 저지연 GC는 opt-in 유지 — ZGC·Shenandoah는 메모리·자원 요구가 커서 "모두를 위한 안전한 기본값"엔 부적합하다.

실제 사용 사례

저지연 GC의 도입 동기는 처리량보다 tail latency(P99/P999, 가장 느린 일부 요청의 지연) 안정화다. 가끔 튀는 긴 pause가 타임아웃·재시도·연쇄 장애로 증폭되는 걸 막는 게 목적이다. 아래는 공개적으로 보고된 사례다.

  • Netflix — G1 → Generational ZGC — JDK 21 이상에서 기본 GC를 Generational ZGC로 전환했다고 보고했다. 일부 서비스에서 STW가 1초를 넘겨 타임아웃·재시도를 유발하고 트래픽을 부풀린 것이 배경이었고, 전환 후 피크 에러율이 크게 줄었다고 한다. 다만 일부 배치(precompute) 작업은 오히려 Parallel GC가 유리했다는 점도 함께 언급된다.
  • Apache Cassandra — Shenandoah / ZGC — G1의 50500ms pause가 P99 1050ms급 SLA에 맞지 않아 Shenandoah로 pause를 한 자릿수 ms로 줄였다는 보고가 있다(예: JDK 17 + Shenandoah로 P99 읽기 지연 40~60% 감소). Generational ZGC는 단일 세대 ZGC에서 동시 클라이언트가 늘 때 생기던 allocation stall을 세대 분리로 완화하는 방향으로 기대된다.

(수치는 각 사례에서 보고된 예시이며 워크로드에 따라 크게 달라진다.)

선택 가이드

상황선택
일반 웹 서비스 · 균형이 필요할 때G1 GC (지정 안 하면 기본값)
총 처리량만 중요한 배치(4~8h)Parallel GC -XX:+UseParallelGC
32GB+ 큰 힙에서 sub-1ms 응답 + 메모리 헤드룸 25%+Generational ZGC -XX:+UseZGC
8~32GB 힙, sub-10ms이지만 ZGC 오버헤드가 부담 / non-Oracle JDKShenandoah -XX:+UseShenandoahGC
코어·메모리가 매우 작은 컨테이너G1 또는 Serial (ZGC 무리한 적용 금지)

마무리

어느 GC도 절대적으로 우월하지 않다. 각자 처리량·응답 지연·메모리 사용량을 다르게 트레이드오프할 뿐이다. Serial·Parallel은 STW를 감수하고 단순함·처리량을 취하고, G1은 region 단위로 균형을 잡으며, ZGC·Shenandoah는 mark·relocate까지 barrier로 보호하며 동시에 돌려 pause를 힙 크기와 분리한다.

실제 전환 판단은 스테이징에서 JFR로 P99/P999 지연, GC CPU 시간, RSS 메모리를 나란히 비교해 자기 트래픽 패턴에서 검증하는 게 가장 확실하다.

주간 기술 뉴스레터

Backend · AI · Java 핵심 내용을 매주 이메일로 보내드립니다.