자바 가비지 컬렉터, 구조와 진화
자바 GC는 하나가 아니라 여러 개가 공존합니다. 먼저 mark·sweep·compact, 세대, barrier 같은 핵심 개념을 그림으로 잡고, Serial부터 ZGC·Shenandoah까지 각 GC의 힙 구조와 수거 방식을 구조도와 함께 정리합니다.
왜 GC가 여러 개인가
가비지 컬렉션은 더 이상 쓰이지 않는 객체를 자동으로 찾아 메모리를 회수하는 일이다. 그런데 애플리케이션마다 무엇이 중요한지가 다르다. 어떤 곳은 총 처리량(throughput), 어떤 곳은 응답 지연(latency), 어떤 곳은 메모리 사용량(footprint)이 가장 중요하다. JVM은 각 지표에 특화된 여러 GC를 두고, 지정하지 않으면 균형형 기본값을 골라준다.
아래 색은 이 글의 모든 다이어그램에서 같은 의미로 쓰인다.
먼저, 핵심 개념
개별 GC로 들어가기 전에 모든 GC가 공유하는 기본 동작과 용어를 그림으로 잡는다. 여기 나오는 mark·sweep·compact, 세대(generation), barrier 세 가지만 알면 뒤의 구조도가 전부 읽힌다.
① GC가 하는 일 — mark, sweep, compact
GC의 기본 동작은 세 단계다. 살아있는 객체를 표시하고(mark), 표시되지 않은 죽은 객체의 자리를 회수하고(sweep), 살아있는 객체를 한쪽으로 모아 빈틈을 없앤다(compact). compact를 생략하면 빈 공간이 잘게 흩어지는 단편화(fragmentation)가 생긴다.
② 세대 구분 — 대부분의 객체는 금방 죽는다
경험적으로 객체 대부분은 생성 직후 곧 쓸모없어진다(weak generational hypothesis). 그래서 갓 만든 객체는 Young, 오래 살아남은 객체는 Old로 나누고, 쓰레기가 몰려 있는 Young만 자주 수거한다. 적은 비용으로 많은 쓰레기를 치우는 전략이다. Young은 다시 Eden(새 객체가 처음 놓이는 곳)과 Survivor(한 번 살아남아 잠시 머무는 곳)로 나뉜다.
③ barrier — 객체를 옮기는 중에도 길을 안내하는 검문소
저지연 GC는 앱을 멈추지 않고 살아있는 객체를 다른 자리로 복사·이동한다(evacuation/relocation). 문제는 그 사이 앱이 옛 주소를 읽을 수 있다는 점이다. barrier는 이를 막기 위해 JVM이 참조를 읽는 곳(load)과 쓰는 곳(store)마다 끼워넣는 아주 작은 검사 코드다. 일종의 자동 통행 검문소로, 객체가 이미 옮겨졌으면 새 위치로 자동 보정해 준다. ZGC는 여기에 더해 포인터(주소)의 안 쓰는 비트에 GC 상태를 색처럼 새기는 colored pointer를 써서, 주소를 읽는 순간 처리가 필요한지 바로 판단한다.
자주 나오는 용어
| 용어 | 뜻 |
|---|---|
| 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 oops | 64비트 JVM에서 참조를 32비트로 압축해 메모리를 아끼는 기법. Shenandoah는 지원, ZGC는 큰 힙에서 미지원. |
| generational | 힙을 Young/Old로 나눠 수거하는 방식. 쓰레기가 몰린 Young을 자주 치워 효율을 높인다. |
출현과 정식화 이력
JDK 버전을 따라 각 GC가 언제 등장(experimental)하고 언제 정식(production)이 됐는지의 흐름이다. 주황 점은 실험 도입, 초록 점은 정식/기본값, 보라 점은 세대화 단계, 빨강 점은 deprecated·제거를 뜻한다.
GC별 구조도
각 GC의 위쪽 그림은 힙 배치, 아래쪽 그림은 수거 동작 타임라인이다. 타임라인에서 가로축은 시간, 빨간 영역은 앱이 멈추는 STW, 초록 막대는 앱과 동시에 도는 GC 작업이다. 위에서 아래로 내려갈수록 빨간 영역이 줄어드는 게 GC 발전의 핵심이다.
Serial GC — 단일 스레드, 전부 STW
가장 단순한 GC다. 수거할 때 앱을 전부 멈추고(STW) GC 스레드 하나가 mark·sweep·compact를 차례로 처리한다. 구조가 단순해 오버헤드가 작은 대신, 힙이 커질수록 멈추는 시간이 그대로 길어진다.
- 동작 — Young/Old 세대 구조에서 단일 스레드가 mark·copy·compact를 모두 STW로 처리한다.
- 강점 — 오버헤드가 작아 단일 CPU·소규모 힙·짧은 수명 프로세스(예: CLI 도구)에 적합하다.
- 약점 — 힙이 커지면 STW가 길어져 서버 워크로드엔 부적합하다.
Parallel GC — 멀티 스레드 STW (Throughput Collector)
Serial과 같은 세대 구조지만, 멈춘 동안 GC 스레드를 여러 개 돌려 수거를 빨리 끝낸다. 멈추는 건 똑같으나 한 번 멈추는 시간이 짧아져 총 처리량이 높다. 그래서 "처리량 컬렉터"라 불린다. JDK 5~8의 기본 GC였다.
- 동작 — Serial과 같은 세대 구조에 GC 스레드를 병렬화해 STW 시간을 단축한다.
- 강점 — 총 처리량이 높아 4~8시간 배치처럼 throughput만 중요한 작업에 최적이다.
- 약점 — 여전히 STW 기반이라 응답 지연이 중요한 서비스엔 부적합하다.
CMS — Concurrent Mark Sweep (1세대 저지연 GC)
처음으로 "앱을 멈추지 않고 마킹"을 시도한 저지연 GC다. 꼭 필요한 두 순간만 짧게 멈추고, 나머지 마킹·회수는 앱과 동시에 돈다. 다만 compact(모으기)를 하지 않아 빈 공간이 흩어지고(단편화), 결국 긴 full GC로 떨어지는 약점이 있었다.
- 동작 — 동시 마킹으로 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부터 evacuation한다. STW지만 점진적이라 수십~수백 ms 범위로 제어된다.
- 위치 — JDK 9부터 기본 GC(서버급)다. 제약 환경의 Serial 폴백까지 없애 모든 환경의 보편 기본값으로 만드는 제안(JEP 523)은 JDK 27 타깃으로, 아직 출시 전이다.
- 강점 — latency·throughput·footprint의 균형이 좋아 범용 기본값으로 적합하다.
Epsilon GC — No-op, 회수하지 않는 GC
이름은 GC지만 실제로는 메모리를 회수하지 않는다. 할당만 처리하다 힙이 다 차면 종료된다. GC 오버헤드를 0으로 만든 상태에서 순수 성능이나 메모리 한계를 측정하는 테스트용이다.
- 동작 — 할당만 처리하고 회수는 전혀 하지 않는다. 힙이 차면 종료/OOM이다.
- 용도 — GC 오버헤드를 0으로 둔 성능·메모리 압박 테스트, 매우 짧은 작업용이다. 실서비스용이 아니다.
- 위치 — JDK 11에 experimental로 추가됐다.
ZGC — colored pointer + barrier (sub-millisecond pause)
"힙이 아무리 커도 멈춤은 1ms 미만"을 목표로 한 저지연 GC다. 마킹뿐 아니라 객체를 옮기는 일(relocation)까지 앱과 동시에 한다. 비결은 colored pointer(주소에 GC 상태를 색처럼 새김)와 load barrier(주소를 읽을 때마다 그 색을 검사해 옮긴 객체면 새 위치로 보정)다. 덕분에 앱을 멈추지 않고도 객체를 안전하게 이동·압축할 수 있다.
- 목표 — 힙 크기와 무관하게 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보다 메모리 오버헤드가 낮은 게 특징이다.
- 목표 — 힙 크기와 무관하게 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로 보호하며 앱과 동시에 돌려, 멈춤을 힙 크기와 분리했다. 아래는 성격을 보여주기 위한 개념적 비교다(정확한 수치 아님).
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
| 항목 | ZGC | Shenandoah |
|---|---|---|
| 개발 주도 | Oracle | Red 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의 50
500ms 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 JDK | Shenandoah -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 핵심 내용을 매주 이메일로 보내드립니다.