G1 GC는 어떻게 멈춤 시간을 줄일까
G1 GC의 region, remembered set, SATB, evacuation, mixed GC 흐름을 하나의 글로 정리합니다.
G1 GC의 이름은 Garbage First에서 왔다.
힙 전체를 한 덩어리로 바라보지 않고, 작은 region들로 나눈 뒤, 가비지가 많은 곳부터 먼저 비운다는 뜻이다.
이 생각은 소박하지만 효과적이다. 커피가 식기 전에 테이블 위 작은 조각들을 먼저 정리하듯, G1은 JVM 힙을 작게 나누고 지금 가장 회수 가치가 큰 부분을 고른다. 목표는 하나다. 애플리케이션을 오래 멈추지 않으면서도, 힙을 계속 사용할 수 있는 모양으로 유지하는 것.
왜 G1이 필요했나
전통적인 처리량 중심 GC는 애플리케이션을 멈춘 뒤 한 번에 많은 일을 처리한다. 힙이 작고 처리량이 중요할 때는 괜찮지만, 힙이 커질수록 정지 시간이 길어진다.
CMS는 동시 마킹으로 이 문제를 줄였지만, Old 영역을 압축하지 않아 단편화가 쌓일 수 있었다. 결국 큰 객체를 넣을 연속 공간이 부족해지면 긴 Full GC로 떨어질 위험이 있었다.
G1은 이 문제를 region 기반으로 풀었다. 힙을 같은 크기의 작은 region들로 나누고, 각 region에 Eden, Survivor, Old, Humongous, Free 같은 역할을 동적으로 부여한다.
[ E ][ E ][ S ][ O ][ F ][ O ][ H ][ E ][ O ][ F ]
Eden Survivor Old Free Humongous
region 크기는 보통 힙 크기에 따라 1MB에서 32MB 사이로 정해진다. 중요한 점은 Young과 Old가 메모리상에서 고정된 연속 구간이 아니라는 것이다. G1에게 힙은 큰 방 하나가 아니라, 역할이 바뀔 수 있는 작은 방들의 모음이다.
Young GC: 살아있는 것만 옮긴다
Eden region이 차면 G1은 Young GC를 실행한다. 이 단계는 Stop-The-World다. 애플리케이션 스레드를 잠깐 멈추고, GC 스레드들이 살아있는 객체를 새 region으로 복사한다.
흐름은 이렇다.
1. 모든 Eden과 Survivor region을 collection set에 넣는다.
2. GC Root를 스캔한다.
3. collection set 바깥에서 안으로 들어오는 참조를 RSet으로 찾는다.
4. 살아있는 객체를 Survivor 또는 Old region으로 복사한다.
5. 원래 region은 통째로 비운다.
이 방식을 evacuation이라고 부른다. 죽은 객체를 하나씩 지우는 것이 아니라, 살아있는 객체만 새 공간으로 옮긴다. 그래서 비워진 region은 통째로 재사용할 수 있고, 자연스럽게 compaction 효과가 생긴다.
비용은 죽은 객체 수보다 살아있는 객체 수에 더 크게 좌우된다. 가비지가 많은 region일수록 G1에게는 좋은 수집 대상이 된다.
RSet: 모든 힙을 뒤지지 않기 위한 기억
Young GC만 생각해도 문제가 하나 생긴다. Old 객체가 Young 객체를 가리키고 있을 수 있다.
Old Region A
object a.ref ─────▶ object b in Young Region B
Young만 보고 GC하면 object b를 죽은 객체로 오해할 수 있다. 그렇다고 매번 Old 전체를 훑으면 pause가 길어진다.
G1은 이를 Card Table과 Remembered Set으로 해결한다.
Card Table은 힙을 보통 512바이트 단위 card로 나누고, 참조가 쓰인 구간을 dirty로 표시한다. 애플리케이션이 참조 필드를 쓸 때 post-write barrier가 실행되어 dirty card queue에 변경 사실을 남긴다. 이후 refinement 스레드가 그 card를 스캔해서, 어느 region을 가리키는 참조가 있는지 분석하고 대상 region의 RSet을 갱신한다.
G1의 RSet은 target-side 성격을 가진다.
Young Region B의 RSet
"Old Region A의 card X를 스캔하면
B를 가리키는 참조가 있을 수 있다"
여기서 중요한 점은 RSet이 정확한 필드 주소를 하나하나 기억하는 구조가 아니라는 것이다. G1은 card 단위로 대략적인 후보 위치를 기억한다. 그래서 GC 때 해당 card를 다시 스캔해 진짜로 collection set 안을 가리키는 참조를 찾는다.
이 방식은 약간의 false positive를 허용한다. 대신 메타데이터 크기와 barrier 비용을 줄일 수 있다. G1이 균형형 GC인 이유가 여기에 있다.
Concurrent Marking: Old 영역의 지도를 그린다
Old region 사용량이 임계치에 다가가면 G1은 concurrent marking cycle을 시작한다. 목적은 Old region 전체를 당장 비우는 것이 아니라, 어느 region에 살아있는 객체가 얼마나 남아 있는지 알아내는 것이다.
큰 흐름은 다음과 같다.
- Initial MarkSTW, Young GC에 편승
- Root Region ScanSurvivor에서 시작점 추적
- Concurrent MarkOld live 비율 계산
- RemarkSATB queue 정리
- Cleanup회수 후보 정리
- Mixed GC이후 가비지 많은 Old를 함께 evacuation
Initial Mark STW, 보통 Young GC에 편승
Root Region Scan Concurrent
Concurrent Mark Concurrent
Remark STW
Cleanup STW 또는 Concurrent 정리
가장 긴 단계인 Concurrent Mark는 애플리케이션과 동시에 진행된다. 문제는 애플리케이션이 실행되는 동안 객체 참조가 계속 바뀐다는 점이다.
G1은 이를 SATB, 즉 Snapshot-At-The-Beginning 방식으로 다룬다.
SATB의 관점은 단순하다.
마킹이 시작된 시점에 살아 있던 객체는 이번 cycle에서는 살아 있는 것으로 본다.
참조 필드가 덮어써지기 직전에 pre-write barrier가 이전 참조값을 SATB queue에 남긴다. 나중에 Remark 단계에서 이 queue를 처리하면, 마킹 중에 끊겨 버린 옛 참조도 놓치지 않는다.
대신 floating garbage가 생길 수 있다. GC 도중 죽은 객체가 이번 cycle에서는 살아 있는 것처럼 남는 것이다. 하지만 이것은 정확성을 위한 비용이고, 다음 cycle에서 회수된다.
Mixed GC: Garbage First가 실제로 일어나는 순간
concurrent marking이 끝나면 G1은 각 Old region의 live 비율을 안다. 이제 Young GC에 Old region 일부를 섞어 수집할 수 있다. 이것이 Mixed GC다.
G1은 가비지가 많은 Old region부터 후보로 올린다. 다만 무조건 많이 고르지는 않는다. -XX:MaxGCPauseMillis 같은 pause 목표 안에서 처리할 수 있을 만큼만 collection set에 넣는다.
pause budget = 200ms
Region A: garbage 80%, 예상 비용 22ms → 선택
Region B: garbage 20%, 예상 비용 18ms → 보류
Region C: garbage 72%, 예상 비용 35ms → 선택
Region D: garbage 66%, 예상 비용 90ms → 예산에 따라 선택 여부 결정
G1은 과거 GC 통계를 바탕으로 region 하나를 처리하는 비용, RSet 스캔 비용, 살아있는 객체 복사 비용을 추정한다. 그래서 예측 가능한 pause time이 설계 목표가 된다.
pause 목표를 너무 짧게 잡으면 한 번에 처리하는 region이 줄어든다. 그러면 Old를 비우는 속도가 promotion 속도를 못 따라갈 수 있다. G1 튜닝에서 pause 목표는 욕심껏 낮출 숫자가 아니라, 처리량과 지연 시간 사이의 약속에 가깝다.
객체를 옮긴 뒤 참조는 어떻게 바뀌나
G1은 ZGC처럼 모든 객체 읽기마다 load barrier를 타지 않는다. 객체 이동은 STW evacuation 안에서 처리한다.
객체 B가 from-region에서 to-region으로 복사되면, G1은 이동 전 객체의 mark word에 임시 forwarding pointer를 남긴다.
B_old address
└─ mark word: forward -> B_new
B_new address
└─ copied object body
GC worker가 어떤 참조 slot을 처리하다가 B_old를 만나면 forwarding pointer를 따라가 B_new로 slot을 갱신한다.
evacuation 전
A.child ─────▶ B_old
evacuation 중
B_old.mark word = forward -> B_new
evacuation 후
A.child ─────▶ B_new
pause가 끝나고 애플리케이션이 다시 실행될 때는, 살아있는 객체를 가리키는 application-visible 참조가 새 주소를 보도록 정리되어 있어야 한다. from-region은 회수될 수 있기 때문이다. 그래서 G1의 forwarding pointer는 긴 시간 유지되는 자료구조가 아니라, pause 안에서 참조를 고치기 위한 임시 표식에 가깝다.
여러 GC worker가 같은 객체를 동시에 발견하면 CAS 같은 원자 연산으로 forwarding pointer 설치를 경쟁한다. 먼저 성공한 worker의 복사본이 공식 새 객체가 되고, 나머지 worker는 이미 설치된 forwarding pointer를 읽어 같은 새 주소를 사용한다.
Full GC는 마지막 경고등이다
G1도 실패할 수 있다. Old가 너무 빠르게 차거나, humongous 객체가 많이 생기거나, evacuation할 to-space가 부족하면 Full GC로 떨어질 수 있다.
이 경우 긴 STW가 발생한다. 최신 JDK에서는 예전보다 나아졌지만, 운영 관점에서는 여전히 피해야 할 신호다.
Full GC가 보이면 보통 다음을 의심한다.
- marking이 너무 늦게 시작된다.
- pause 목표가 너무 짧아 Mixed GC가 충분히 회수하지 못한다.
- humongous 객체가 자주 생긴다.
- allocation rate가 GC 처리 속도를 지속적으로 앞선다.
G1을 한 문장으로 정리하면
G1은 힙을 region으로 나누고, RSet으로 외부 참조를 기억하고, SATB로 동시 마킹의 정확성을 지킨 뒤, pause 예산 안에서 가비지가 많은 region부터 evacuation으로 회수한다.
대부분의 일반적인 서버 애플리케이션에서 G1은 좋은 기본값이다. 지연 시간을 어느 정도 예측하고 싶고, 처리량도 크게 잃고 싶지 않을 때 잘 맞는다.
다만 pause time을 10ms 이하로 강하게 요구하거나, 힙이 매우 크고 tail latency가 비즈니스 문제로 이어진다면 ZGC 같은 더 낮은 지연 시간의 collector를 검토할 만하다.