CS/JAVA

JVM 한눈에 보기 GC부터 ZGC까지: 큰 그림, 내부 구조, 구현체 비교, ZGC 심화

dding-shark 2026. 3. 12. 16:22
728x90

JVM 한눈에 보기 GC부터 ZGC까지: 큰 그림, 내부 구조, 구현체 비교, ZGC 심화

들어가며

JVM GC를 처음 공부할 때 가장 헷갈리는 지점은 두 가지가 섞여서 들어오기 때문이다.

첫 번째는 GC 공통 모델이다.

  • Reachability Analysis
  • GC Root
  • Young / Old Generation
  • Minor GC / Major GC / Full GC
  • Stop-The-World, Safepoint
  • Barrier

두 번째는 HotSpot GC 구현체다.

  • Serial GC
  • Parallel GC
  • CMS
  • G1
  • ZGC
  • Shenandoah

즉, GC를 이해하려면 먼저 공통 구조를 보고, 그 다음 각 구현체가 이 구조를 어떻게 다르게 구현하는지 봐야 한다.

이 글은 그 흐름을 한 파일 안에서 아래 순서로 정리한다.

  1. GC는 왜 필요한가
  2. Reachability Analysis와 GC Root는 무엇인가
  3. Young / Old 구조와 Minor GC는 어떻게 동작하는가
  4. STW, Safepoint, Barrier, Remembered Set은 왜 필요한가
  5. HotSpot GC 구현체는 어떻게 발전해 왔는가
  6. ZGC는 왜 기존 GC와 다른 설계를 택했는가

목차


1. GC는 왜 필요한가

C/C++처럼 개발자가 직접 메모리를 관리하는 환경에서는 항상 같은 문제가 따라온다.

  • 메모리를 해제하지 않아 생기는 Memory Leak
  • 해제된 메모리를 다시 참조하는 Dangling Pointer
  • 같은 메모리를 두 번 해제하는 Double Free

이 문제의 핵심은 하나다.

객체가 이제 정말로 필요 없는 상태인지 사람이 정확히 판단하기 어렵다.

Java는 이 지점을 JVM이 대신 맡는다.
개발자가 free()delete를 직접 호출하지 않고,
JVM이 실행 중 객체 그래프를 보고 더 이상 사용할 수 없는 객체를 찾아 회수한다.

즉 Java의 GC는 단순한 편의 기능이 아니라,
복잡한 객체 그래프를 자동으로 관리하기 위한 런타임 메모리 관리 시스템이다.


2. Reference Counting vs Reachability Analysis

GC를 설명할 때 가장 먼저 나오는 두 방식이 있다.

  • Reference Counting (참조 카운팅)
  • Reachability Analysis (도달성 분석)

2-1. Reference Counting

Reference Counting은 객체마다 "나를 몇 개의 참조가 가리키고 있는가"를 저장하는 방식이다.

  • 참조가 생기면 count 증가
  • 참조가 끊기면 count 감소
  • count가 0이면 회수

겉보기에는 단순하지만, 치명적인 문제가 있다.

2-2. Cycle Problem (순환 참조 문제)

아래 상황을 보자.

외부에서는 A와 B를 더 이상 참조하지 않더라도,
A와 B가 서로를 가리키고 있기 때문에 reference count는 0이 되지 않을 수 있다.

즉,

  • 실제로는 프로그램에서 접근할 수 없는데
  • 내부 참조만 남아 있어서
  • 회수되지 않는 객체가 생긴다

이것이 Reference Counting의 대표적인 한계다.

2-3. JVM은 왜 Reachability Analysis를 쓰는가

HotSpot JVM은 Reference Counting 대신 Reachability Analysis를 사용한다.
핵심 아이디어는 단순하다.

GC Root에서 시작해서 도달 가능한 객체는 살아 있는 객체로 본다.

즉 참조 개수를 세는 것이 아니라,
실제로 이 객체에 도달 가능한가를 기준으로 판단한다.

이 방식은 순환 참조가 있어도 해결된다.
왜냐하면 외부에서 접근할 수 없는 순환 구조는 GC Root에서 출발했을 때 닿지 않기 때문이다.


3. GC Root는 무엇인가

Reachability Analysis는 아무 곳에서나 시작하지 않는다.
항상 GC Root에서 출발한다.

대표적인 GC Root는 아래와 같다.

  • Stack Reference: 현재 실행 중인 메서드의 지역 변수
  • Static Field: 클래스의 static 필드
  • JNI Reference: native code가 잡고 있는 Java 객체 참조
  • Active Thread: 실행 중인 스레드 자체와 관련된 루트

아래처럼 생각하면 된다.

이 그림에서

  • A, B, C, D는 GC Root에서 도달 가능하므로 alive
  • E, F는 GC Root에서 도달할 수 없으므로 garbage

이다.

즉 GC의 핵심 질문은 사실 하나다.

이 객체가 Root에서 닿는가?


4. 왜 JVM은 Stop-The-World가 필요한가

GC가 Reachability Analysis를 하려면 객체 그래프를 안정적으로 봐야 한다.
그런데 애플리케이션 스레드가 동시에 실행되면 참조 그래프가 계속 바뀐다.

예를 들어 GC가 A -> B 관계를 보고 있는 도중,
다른 스레드가 A.ref = null 또는 A.ref = C를 수행하면
GC가 보고 있는 그래프와 실제 그래프가 달라진다.

그래서 JVM은 특정 단계에서 Stop-The-World (STW) 를 사용한다.
즉,

  • 모든 Java 스레드를 잠시 멈추고
  • 일관된 heap 상태를 확보한 뒤
  • GC 작업을 수행한다

다만 현대 GC는 모든 작업을 STW로만 수행하지 않는다.
CMS, G1, ZGC 같은 GC는 일부 단계를 애플리케이션과 동시에 진행한다.


5. 왜 Heap은 Young / Old로 나뉘는가

GC 연구에서 매우 중요한 경험 법칙이 하나 있다.

Weak Generational Hypothesis (약한 세대 가설)
대부분 객체는 아주 빨리 죽는다.

실제 서버 프로그램을 보면 아래 같은 임시 객체가 매우 많다.

  • 요청 처리용 DTO
  • StringBuilder
  • 중간 계산용 List, Map
  • JSON 직렬화 과정에서 생기는 임시 객체

이런 객체들은 대부분 메서드가 끝나거나 요청 처리가 끝나면 더 이상 필요 없어지는 경우가 많다.

그래서 JVM은 Heap을 크게 두 세대로 나눈다.

Young Generation

  • 새 객체가 대부분 여기서 생성된다
  • 대부분의 객체는 여기서 죽는다
  • 자주, 빠르게 GC한다

Old Generation

  • 여러 번 살아남은 객체가 올라온다
  • 오래 사는 객체가 주로 위치한다
  • Young보다 GC 비용이 비싸다

6. 객체 생명주기: Eden → Survivor → Old

객체는 보통 아래 흐름을 따른다.

즉,

  1. 새 객체는 Eden에 생성된다
  2. Minor GC가 발생했을 때 살아남으면 Survivor로 복사된다
  3. 여러 번 살아남으면 Old Generation으로 승격된다

이 과정을 Promotion 또는 Tenuring이라고 부른다.


7. Minor GC, Major GC, Full GC

Minor GC

Young Generation을 대상으로 수행되는 GC다.
보통 Eden + Survivor를 함께 본다.

특징:

  • 자주 발생한다
  • 대부분 객체가 금방 죽기 때문에 빠르다
  • Copying GC와 잘 어울린다

Major GC

Old Generation을 대상으로 수행되는 GC다.

특징:

  • Young보다 덜 자주 발생한다
  • 객체 수가 많고 살아 있는 비율이 높아 비용이 크다
  • Pause가 더 길어질 수 있다

Full GC

Heap 전체, 즉 Young + Old 전체를 대상으로 수행하는 GC다.
가장 비싼 GC이며 일반적으로 pause도 가장 길다.


8. Copying GC는 왜 Young에 적합한가

Young Generation은 대부분 객체가 빨리 죽는다는 가정 위에 설계되어 있다.
그래서 살아있는 객체만 다른 공간으로 복사하는 방식, 즉 Copying GC가 매우 잘 맞는다.

예를 들어 Eden에 객체가 아래처럼 있다고 하자.

[A][B][C][D][E][F][G][H]

이 중 살아있는 객체가 B, D 뿐이라면,
Minor GC는 B, D만 Survivor로 복사하고 나머지는 그냥 버린다.

이 방식의 장점은 아래와 같다.

  • 살아있는 객체 수가 적으면 매우 빠르다
  • 메모리 단편화가 거의 없다
  • 할당이 단순한 pointer bump로 가능하다

9. Minor GC는 왜 Young만 보면 될까

대부분 객체가 Young에서 죽는다는 가설이 있기 때문에,
JVM은 새 객체를 먼저 Young Generation에 몰아넣고 자주 수거한다.

핵심은 아래 두 가지다.

  • Young에는 금방 죽는 객체가 많다
  • 살아있는 객체만 복사하면 된다

이 때문에 Minor GC는 보통 Copying GC와 잘 어울린다.

9-1. Eden과 Survivor 두 개가 필요한 이유

Survivor 영역은 보통 두 개를 번갈아 사용한다.
흔히 S0, S1 또는 from, to라고 부른다.

이 구조가 필요한 이유는 in-place copy를 피하기 위해서다.
즉, 같은 공간 안에서 객체를 옮기며 덮어쓰기와 포인터 갱신 문제를 만들기보다,
항상 빈 쪽으로 살아있는 객체만 복사한 뒤 기존 공간을 통째로 비워 버리는 방식이 훨씬 단순하다.

9-2. Copying GC의 핵심

예를 들어 Eden과 Survivor에 아래 객체들이 있다고 하자.

Eden:      [A][B][C][D]
Survivor:  [E][F]

이 중 살아있는 객체가 B, D, F라면,
Minor GC는 이 객체들만 다른 Survivor 공간으로 복사하고 나머지는 버린다.

To Survivor: [B][D][F]

장점은 분명하다.

  • 살아있는 객체 수에 비례해 비용이 든다
  • 죽은 객체는 개별 해제 없이 통째로 버린다
  • 복사 후 메모리가 연속되어 단편화가 거의 없다

10. Safepoint는 왜 필요한가

그렇다고 JVM이 스레드를 아무 instruction에서나 강제로 멈출 수 있는 것은 아니다.
JIT 컴파일된 코드에서는 객체 참조가

  • stack slot에 있을 수도 있고
  • register에 있을 수도 있고
  • 최적화된 형태로 잠시 흩어져 있을 수도 있다

즉 GC가 "어떤 위치가 object reference인지"를 정확히 알아야 한다.

HotSpot은 이 정보를 OopMap 같은 형태로 특정 지점에만 갖고 있다.
그리고 이 지점이 바로 Safepoint다.

대표적인 safepoint 후보는 아래와 같다.

  • method call 경계
  • loop back edge
  • return 경계
  • 일부 예외 처리 경계

즉,

  • Safepoint는 안전하게 멈출 수 있는 지점이고
  • STW는 실제로 전체 스레드를 멈춘 상태다

Safepoint는 STW를 가능하게 만드는 실행 경계라고 이해하면 된다.


11. Old → Young 참조는 왜 별도 문제가 되는가

Minor GC는 Young Generation만 빠르게 수거하고 싶다.
그런데 아래 상황을 생각해보자.

이때 Minor GC가 Young만 보고 B를 unreachable이라고 판단하면,
실제로는 A가 B를 가리키는데도 B를 잘못 회수할 수 있다.

그렇다고 Minor GC가 매번 Old 전체를 스캔하면 Young GC의 장점이 사라진다.
Old는 너무 크고, 객체 수도 많다.

그래서 JVM은 이렇게 생각한다.

Old 전체를 보지 말고, Old에서 Young을 참조하는 위치만 기억하자.

이때 등장하는 것이

  • Write Barrier
  • Card Table
  • Remembered Set

이다.


12. Write Barrier, Card Table, Remembered Set

12-1. Write Barrier는 무엇인가

Write Barrier는 참조 쓰기 시점에 JVM이 끼어드는 작은 코드다.

예를 들어 애플리케이션이 아래 코드를 실행한다고 하자.

oldObj.ref = youngObj;

개념적으로는 아래 같은 흐름이 추가된다.

write_barrier(oldObj, youngObj)
oldObj.ref = youngObj

즉 JVM은 참조가 바뀌는 순간,
"이 쓰기가 GC 관점에서 중요한 쓰기인가"를 기록한다.

12-2. Card Table은 왜 필요한가

Old → Young 참조를 객체 단위로 전부 기록하면 비용이 너무 크다.
그래서 JVM은 heap을 작은 블록 단위로 쪼개 관리한다.
이 블록을 Card라고 부른다.

각 card에는 보통 dirty 여부 같은 간단한 상태가 표시된다.
즉,

  • 이 영역에서 참조 쓰기가 일어났는가
  • 나중에 GC가 이 영역을 다시 봐야 하는가

를 빠르게 확인할 수 있게 만든다.

Write Barrier는 보통 이 Card Table을 업데이트하는 역할을 한다.

12-3. Remembered Set은 무엇인가

Remembered Set은 특정 영역(혹은 region)을 참조하는 외부 영역 정보를 기억하는 자료구조다.

예를 들어 어떤 Young region을 GC할 때,
이 region을 가리키는 Old region 정보를 알고 있다면
Old 전체를 스캔하지 않고 필요한 위치만 확인할 수 있다.

즉 Remembered Set은 아래 목적을 가진다.

Minor GC 때 Old 전체를 스캔하지 않고도 cross-generation reference를 찾는다.

12-4. 흐름을 한 줄로 묶으면

즉,

  1. 애플리케이션이 참조를 쓴다
  2. Write Barrier가 이를 감지한다
  3. Card Table에 관련 카드가 dirty 처리된다
  4. Remembered Set이 유지된다
  5. Minor GC는 Old 전체를 보지 않고 필요한 참조만 빠르게 찾는다

13. Tri-color Marking과 Black → White 문제

Concurrent GC를 이해할 때 반드시 나오는 개념이 Tri-color Marking이다.
객체 상태를 다음 세 가지로 나눈다.

  • White: 아직 발견되지 않은 객체
  • Gray: 발견되었지만 아직 내부 참조를 다 보지 않은 객체
  • Black: 내부 참조까지 전부 본 객체

이 모델의 핵심 규칙은 하나다.

Black object가 White object를 직접 참조하면 안 된다.

왜냐하면 Black은 이미 "다 봤다"고 간주되는 객체인데,
그 Black에서 아직 발견되지 않은 White를 가리키면
GC가 그 White를 영영 못 볼 수 있기 때문이다.

이 문제를 막기 위해 concurrent collector는 barrier를 사용한다.

  • CMS / G1 → 주로 Write Barrier
  • ZGC → Load Barrier

즉 barrier는 단순한 최적화 장치가 아니라,
concurrent marking의 정확성을 유지하는 핵심 장치다.


14. HotSpot GC 구현체 발전 흐름

HotSpot GC의 발전 방향은 거의 항상 하나였다.

Pause Time을 줄이자.

대략적인 흐름은 아래처럼 볼 수 있다.

이 흐름을 문장으로 풀면 이렇다.

  • 처음에는 단순하게 전부 멈추고 수거했다
  • 그다음 GC를 여러 스레드로 돌려 throughput을 높였다
  • 그다음 concurrent phase를 도입해 pause를 줄이려 했다
  • 그다음 region 기반 구조와 compaction을 결합했다
  • 마지막으로 relocation 자체를 concurrent하게 만들기 시작했다

15. Serial GC

15-1. 핵심 철학

Serial GC는 가장 단순한 GC다.
GC 작업을 하나의 스레드가 수행한다.

  • Young: Copying GC
  • Old: Mark-Compact 계열
  • Pause: 길 수 있음
  • 장점: 구조 단순, 오버헤드 적음

15-2. 언제 잘 맞는가

  • 작은 Heap
  • 단일 코어 또는 단순 환경
  • GC pause보다 구현 단순성이 중요한 경우

요약하면,

Serial GC는 단순하고 직관적이지만 큰 서비스용으로는 pause 부담이 크다.


16. Parallel GC

16-1. 핵심 철학

Parallel GC는 Stop-The-World를 여전히 사용하지만,
GC 작업 자체를 여러 스레드로 병렬 처리한다.

즉 목표는 pause를 없애는 것이 아니라,
멈춘 동안 최대한 많이 처리해 throughput을 높이는 것이다.

  • Young: Parallel Copying
  • Old: Parallel Mark-Compact
  • 특징: 멈추긴 멈추지만, 더 빨리 끝내자

16-2. 언제 잘 맞는가

  • 배치 처리
  • 데이터 처리 작업
  • 응답 지연보다 전체 처리량이 중요한 서버

요약하면,

Parallel GC는 low latency보다 throughput 지향 GC다.


17. CMS

17-1. CMS가 나온 이유

Parallel GC는 throughput은 좋지만 여전히 STW pause가 크다.
특히 Old 영역 GC는 pause가 부담될 수 있다.

그래서 등장한 것이 CMS다.
핵심 목표는 한 가지였다.

가능한 많은 단계를 애플리케이션과 동시에 수행하자.

17-2. CMS 단계

  • Initial Mark: GC Root에서 직접 닿는 객체만 빠르게 표시
  • Concurrent Mark: 애플리케이션과 함께 전체 그래프 탐색
  • Remark: concurrent mark 중 발생한 참조 변경 보정
  • Concurrent Sweep: 죽은 객체를 정리

17-3. Remark는 왜 필요한가

Concurrent Mark 동안 애플리케이션은 계속 참조를 바꿀 수 있다.
즉 GC가 처음 본 그래프와 실제 그래프가 달라질 수 있다.

그래서 CMS는 마지막에 Remark 단계에서

  • concurrent mark 동안 발생한 참조 변경을 검사하고
  • 마킹 결과를 최종 보정한다

즉 Remark는 최종 정합성 맞추기 단계라고 보면 된다.

17-4. CMS의 장점

  • pause가 전통적 STW collector보다 짧다
  • Old GC를 더 낮은 지연으로 수행할 수 있다

17-5. CMS의 치명적인 약점

CMS는 이름 그대로 Mark-Sweep 중심이다.
즉 sweep은 하지만 compaction을 하지 않는다.
그래서 아래 문제가 생긴다.

[A][free][C][free][E]

이처럼 메모리 조각이 흩어지는 Memory Fragmentation이 발생한다.
총 free 공간은 충분해도 큰 연속 공간이 없을 수 있다.
이때 allocation 실패가 나면 Concurrent Mode Failure로 이어지고,
결국 긴 Full GC가 발생할 수 있다.

요약하면,

CMS는 pause를 줄였지만 단편화 문제를 해결하지 못했다.


18. G1

18-1. G1이 해결하려던 것

G1은 CMS의 두 문제를 동시에 겨냥했다.

  • 단편화 해결
  • pause 예측 가능성 향상

핵심 아이디어는 heap을 Young/Old 큰 덩어리로만 보지 않고,
작은 Region 단위로 쪼개 관리하는 것이다.

각 region은 상황에 따라

  • Eden region
  • Survivor region
  • Old region

역할을 가질 수 있다.

18-2. G1 단계

18-3. SATB는 무엇인가

G1은 concurrent marking에서 SATB (Snapshot At The Beginning) 전략을 사용한다.
핵심 아이디어는 아래와 같다.

GC 시작 시점에 reachable이었던 객체는 이번 GC에서는 살아있는 것으로 본다.

즉 concurrent mark 중에 참조가 끊겨도,
GC 시작 시점에서 닿았던 객체는 이번 사이클에서는 살려준다.
그 결과 Floating Garbage가 생길 수 있지만,
concurrent marking의 안정성을 얻는다.

18-4. G1의 핵심: Evacuation

G1은 가비지가 많은 region을 골라,
그 안의 살아있는 객체만 다른 region으로 복사한다.
그 후 기존 region 전체를 free로 만든다.

즉 G1은 사실상

  • Copying GC
  • Compaction
  • Selective Collection

을 region 단위로 묶은 구조다.

18-5. 왜 이름이 Garbage First인가

동시 마킹 후 region별 live ratio를 계산하고,
가비지가 많은 region부터 우선 수거하기 때문이다.

즉 G1은 매번 old 전체를 한 번에 치우려 하지 않고,
수거 효율이 높은 region부터 선택적으로 수거한다.

18-6. G1의 핵심 자료구조

  • SATB
  • Write Barrier
  • Card Table
  • Remembered Set

특히 Remembered Set 덕분에 region 기반 구조에서도
매번 full heap scan 없이 cross-region reference를 추적할 수 있다.

요약하면,

G1은 CMS처럼 concurrent marking을 하되, region + evacuation으로 단편화까지 해결한 collector다.


19. ZGC

19-1. ZGC의 목표

G1도 pause를 많이 줄였지만,
아주 큰 heap과 매우 낮은 latency가 필요한 환경에서는 여전히 한계가 있다.

ZGC의 목표는 훨씬 공격적이다.

pause를 heap 크기와 거의 무관하게 매우 짧게 유지하자.

19-2. 핵심 아이디어

ZGC는 객체 relocation 자체를 concurrent하게 수행한다.
즉 애플리케이션이 실행 중인 동안 객체를 옮긴다.

이를 위해 아래 기술을 사용한다.

  • Colored Pointer
  • Load Barrier
  • Concurrent Relocation

19-3. 왜 G1과 다르게 Load Barrier를 쓰는가

G1은 evacuation을 주로 STW 구간에서 수행한다.
그래서 포인터를 일괄 보정하기가 비교적 쉽다.

하지만 ZGC는 객체가 실행 중에 이동될 수 있다.
즉 기존 참조가 옛 주소를 가리키는 stale pointer가 남을 수 있다.

이 문제는 write 시점에만 잡아서는 해결되지 않는다.
왜냐하면 이미 존재하던 참조는 새 write를 발생시키지 않기 때문이다.

그래서 ZGC는 참조를 읽을 때 barrier를 걸어
필요하면 새 주소로 remap한다.

19-4. Colored Pointer가 왜 필요한가

Load Barrier가 제대로 동작하려면,
포인터를 읽는 순간 이 포인터가

  • 정상 상태인지
  • remap이 필요한 상태인지
  • GC 관련 메타데이터를 어떤 상태로 갖는지

알 수 있어야 한다.

그래서 ZGC는 pointer 안에 metadata 비트를 넣는다.
즉 Colored Pointer는 ZGC의 핵심 전제다.

요약하면,

ZGC는 G1보다 더 공격적으로 pause를 줄이기 위해, relocation까지 concurrent하게 만든 collector다.


20. Shenandoah

Shenandoah도 ZGC와 마찬가지로 low latency를 목표로 한다.
핵심 아이디어는 concurrent compaction / concurrent evacuation 쪽에 가깝다.

즉,

  • 애플리케이션을 오래 멈추지 않고
  • 객체 이동까지 가능한 한 concurrent하게 수행한다

Shenandoah는 구현 세부에서 ZGC와 다르지만,
설계 방향은 비슷하다.

긴 STW compact를 없애고, 이동 비용을 concurrent로 분산하자.


21. Collector별 핵심 비교

GC 주 목표 pause 특성 compaction 핵심 기술
Serial 단순성 길 수 있음 있음 single-thread GC
Parallel throughput 멈추지만 빨리 처리 있음 parallel STW GC
CMS pause 감소 비교적 짧음 거의 없음 concurrent mark/sweep
G1 예측 가능한 pause 짧고 제어 가능 있음 region, evacuation, SATB
ZGC ultra low latency 매우 짧음 있음 colored pointer, load barrier
Shenandoah ultra low latency 매우 짧음 있음 concurrent compaction

실무 감각으로 거칠게 보면

  • 작은 heap / 단순 환경 → Serial, Parallel
  • 범용 서버 / 기본 선택 → G1
  • 큰 heap / low latency 지향 → ZGC, Shenandoah

다만 실제 선택은 heap 크기 하나만으로 결정되지는 않는다.
아래 요소를 같이 본다.

  • tail latency 요구 수준
  • throughput 요구 수준
  • heap 크기
  • CPU 여유
  • allocation 패턴
  • pause tolerance

22. ZGC 깊게 보기

22-1. 기존 GC의 한계: 객체 이동은 비싸다

GC가 메모리 단편화를 해결하려면 결국 객체를 옮겨야 한다.
문제는 객체를 옮기는 순간, 그 객체를 참조하던 포인터를 전부 새 주소로 바꿔야 한다는 점이다.

예를 들어 아래 상황을 보자.


B를 새 주소 0x500으로 옮기면,
A, C, D가 가진 참조를 모두 새 주소로 보정해야 한다.

이 작업은 전통적으로

  • 참조를 모두 찾아야 하고
  • 보정 도중 일관성이 깨지면 안 되고
  • 보통 STW가 필요하다

그래서 큰 heap에서는 relocation / compaction이 pause의 큰 원인이 된다.

22-2. ZGC의 발상: 전부 한 번에 고치지 말자

ZGC는 여기서 발상을 바꾼다.

포인터를 즉시 전부 수정하지 말고, 필요할 때 안전하게 보정하자.

즉 객체가 이동하더라도,
옛 주소를 가리키는 포인터를 잠시 허용한다.
그리고 나중에 그 포인터를 실제로 읽을 때 올바른 주소로 remap한다.

이 설계를 가능하게 만드는 핵심이

  • Colored Pointer
  • Load Barrier

이다.

22-3. Colored Pointer는 무엇인가

Colored Pointer는 말 그대로 색이 칠해진 포인터라기보다,
포인터 안에 GC 상태 비트를 포함한 구조라고 이해하는 편이 정확하다.

개념적으로는 아래처럼 볼 수 있다.

[ metadata bits | object address ]

즉 포인터 하나만 봐도,
그 참조가 GC 관점에서 어떤 상태인지 일부를 알 수 있다.

예를 들어 개념적으로는 이런 정보가 포함될 수 있다.

  • mark 관련 상태
  • remap 필요 여부
  • relocation 관련 상태

중요한 점은 이것이다.

Load Barrier가 포인터를 읽는 순간, 이 포인터가 그냥 쓸 수 있는 포인터인지 판단할 단서가 필요하다.

그 단서를 Colored Pointer가 제공한다.

22-4. 왜 Load Barrier가 필요한가

G1 같은 collector는 주로 write barrier를 사용해 cross-region / cross-generation reference를 추적한다.
하지만 ZGC가 풀어야 하는 문제는 조금 다르다.

write barrier로는 안 잡히는 경우

예를 들어 A가 이미 B를 가리키고 있었다고 하자.

이후 GC가 B를 새 위치로 옮겼다.
그런데 A의 필드는 새로 write되지 않았다.
즉 아래 상황이 된다.

  • 객체는 옮겨졌다
  • 기존 참조는 여전히 old address를 들고 있다
  • 새로운 write가 없으니 write barrier는 동작하지 않는다

이 stale pointer를 잡아내려면,
참조를 읽는 시점에 개입해야 한다.

그래서 Load Barrier

ZGC는 참조를 읽을 때 barrier를 건다.

obj.field

를 개념적으로 보면 아래처럼 생각할 수 있다.

ptr = load(obj.field)
ptr = load_barrier(ptr)

이때 load barrier는

  • 포인터 상태를 본다
  • remap이 필요한지 판단한다
  • 필요하면 새 주소를 찾아 반환한다

즉 애플리케이션은 결국 항상 올바른 객체를 보게 된다.


23. ZGC의 기본 흐름

ZGC는 보통 아래 같은 큰 흐름으로 설명할 수 있다.

핵심은 이것이다.

  • mark는 대부분 concurrent하게 수행한다
  • relocation도 concurrent하게 수행한다
  • remap은 read path에서 분산해서 처리한다

즉 비싼 작업을 한 번에 몰아 pause로 내지 않고,
실행 중에 분산시킨다.


24. 왜 64bit JVM이 사실상 필수인가

Colored Pointer를 쓰려면 포인터 안에 address 외의 metadata 비트를 넣어야 한다.
이 지점에서 32bit address space는 너무 좁다.

24-1. 32bit의 한계

32bit 포인터는 사실상 32bit를 주소 표현에 거의 다 써야 한다.
즉,

  • 주소 공간 자체가 작고
  • 포인터 안에 상태 비트를 넣을 여유가 거의 없다

24-2. 64bit의 여유

64bit 환경에서는 실제 가상 주소 표현에 모든 64bit를 다 쓰지 않는 경우가 많다.
즉 구현 관점에서 metadata를 위한 여유 비트를 활용할 수 있다.

이 덕분에 ZGC는 포인터 안에 상태를 실을 수 있다.

요약하면,

ZGC는 Colored Pointer 설계 때문에 넓은 address space가 필요하고, 그 점에서 64bit 환경과 강하게 연결된다.


25. Fast Path / Slow Path는 왜 중요한가

ZGC를 처음 보면 이런 의문이 든다.

객체 read마다 load barrier를 타면 너무 느린 것 아닌가?

이 질문은 맞다.
그래서 ZGC는 barrier를 항상 무겁게 만들지 않는다.

Fast Path

대부분의 경우 포인터는 이미 정상 상태다.
이 경우 barrier는 매우 빠른 검사만 하고 바로 통과한다.

Slow Path

포인터 상태를 보니 remap이 필요하거나 relocation 상태라면,
그때만 더 비싼 처리를 수행한다.

즉 개념적으로는 아래처럼 볼 수 있다.

if (pointer is already good) {
    return pointer;   // fast path
} else {
    fix and remap;    // slow path
}

이 구조 덕분에 barrier는 항상 큰 비용이 아니라,
대부분은 매우 싼 분기 + 드문 보정으로 동작한다.


26. 전체 흐름 한 번에 정리

이 전체 흐름을 문장으로 요약하면 아래와 같다.

  1. JVM은 GC Root에서 시작한 도달성 분석으로 객체 생존 여부를 판단한다.
  2. 대부분 객체가 빨리 죽기 때문에 Heap을 Young / Old로 나눈다.
  3. Young은 Copying GC 기반으로 빠르게 수거하고, 오래 사는 객체만 Old로 승격한다.
  4. GC 정확성과 성능을 위해 STW, Safepoint, Barrier 같은 런타임 메커니즘이 필요하다.
  5. HotSpot collector들은 같은 문제를 풀지만, pause / throughput / compaction / concurrency를 다르게 설계한다.
  6. ZGC는 그중에서도 pointer model 자체를 바꿔 concurrent relocation을 가능하게 한 collector다.

마치며

GC를 공부할 때 중요한 것은 개별 용어를 따로 외우는 것이 아니다.

  • Reachability Analysis
  • GC Root
  • Minor GC
  • Safepoint
  • Write Barrier
  • CMS
  • G1
  • ZGC

이 각각을 별개의 조각처럼 보기보다,
메모리 관리 정확성과 pause time 사이의 균형을 풀기 위한 하나의 흐름으로 보는 것이 훨씬 중요하다.

정리하면,

  • Java는 수동 메모리 해제 대신 JVM이 메모리를 관리한다
  • JVM은 Reference Counting 대신 Reachability Analysis를 사용한다
  • 대부분 객체는 빨리 죽기 때문에 Young / Old 구조를 사용한다
  • Minor GC는 Copying GC로 빠르게 동작하고, Old는 더 비싸게 관리된다
  • STW와 Safepoint는 heap 일관성과 정확한 객체 참조 관찰을 위해 필요하다
  • Barrier, Card Table, Remembered Set은 generational / region 기반 GC 성능의 핵심이다
  • CMS는 concurrent mark/sweep를 도입했지만 단편화 문제가 있었다
  • G1은 region + evacuation으로 이를 개선했다
  • ZGC는 colored pointer + load barrier로 relocation까지 concurrent하게 만들었다

한 줄로 요약하면 아래와 같다.

JVM GC는 객체 그래프의 생존 여부를 판단하고, 대부분 빨리 죽는 객체는 빠르게 걸러내며, pause와 throughput 사이에서 다양한 collector가 서로 다른 방식으로 균형을 잡는 적응형 메모리 관리 시스템이다.

728x90