서론: 왜 Low-Latency GC가 필요해졌는가
하드웨어 성장이 만든 역설
1990년대부터 2000년대 초반까지 JVM 힙 메모리는 수백 MB 수준이었습니다. 그러나 메모리 가격 하락과 64비트 아키텍처의 보편화로 상황이 완전히 달라졌습니다. 이제는 수십에서 수백 GB 힙을 사용하는 애플리케이션이 흔합니다.
문제는 Parallel GC 같은 전통적인 처리량 중심 컬렉터에서 발생했습니다. 이들 컬렉터는 힙 크기에 비례해서 Stop-The-World(STW) 시간이 늘어납니다. 4GB 힙에서 100ms였던 Full GC가 64GB 힙에서는 수 초가 걸릴 수 있습니다. 하드웨어가 좋아졌는데 오히려 멈춤 시간은 더 길어지는 역설적인 상황이 된 것입니다.
애플리케이션 특성의 변화
현대 애플리케이션은 지연 시간에 훨씬 민감해졌습니다.
금융 거래, 게임 서버, 스트리밍 같은 실시간 서비스, MSA 환경에서 타임아웃에 민감한 서비스 간 통신, SLA(Service Level Agreement, 서비스 수준 협약)로 99.9 퍼센타일 응답 시간을 보장해야 하는 상황 등이 대표적입니다.
수 초짜리 STW는 단순히 "느린 응답"이 아니라 서비스 장애로 이어질 수 있습니다.
코어 수 증가가 만든 기회
CPU 코어 수가 크게 늘어나면서 GC 작업을 애플리케이션과 동시에 실행할 여유가 생겼습니다. 과거에는 코어가 부족해서 STW가 불가피한 측면이 있었지만, 이제는 일부 코어를 GC 전담으로 써도 애플리케이션 성능에 큰 영향을 주지 않습니다.
이런 배경에서 Shenandoah와 ZGC 같은 Low-Latency GC가 등장했습니다.
Shenandoah의 목표와 G1 대비 개선점
Shenandoah는 Red Hat에서 개발한 Low-Latency 가비지 컬렉터입니다. JDK 12에서 실험적 기능으로 도입되었고 JDK 15에서 정식 기능이 되었습니다. 다만 Oracle JDK에서는 지원하지 않으므로 사용하려면 OpenJDK 빌드(Amazon Corretto, Azul Zulu, Red Hat OpenJDK 등)를 사용해야 합니다.
G1과 마찬가지로 힙을 동일한 크기의 Region으로 나누어 관리합니다. 그러나 핵심 목표는 다릅니다. G1이 "예측 가능한 STW 시간"을 목표로 한다면, Shenandoah는 "STW 시간을 힙 크기와 무관하게 일정하게 유지"하는 것을 목표로 합니다.
동시 이주 (Concurrent Compaction)
G1의 가장 큰 한계는 Evacuation 단계에서 반드시 STW가 필요하다는 점입니다. 객체를 복사하는 동안 애플리케이션이 해당 객체에 접근하면 "원본을 볼지, 복사본을 볼지" 일관성 문제가 생깁니다. 가장 간단한 해결책은 애플리케이션을 멈추고 복사하는 것이었고, 그래서 기존 컬렉터들은 Evacuation 단계에서 STW를 선택했습니다.
Shenandoah는 이 문제를 해결했습니다. 애플리케이션이 실행되는 중에도 객체를 안전하게 복사할 수 있습니다. 어떻게 가능한지는 뒤에서 포워딩 포인터를 설명할 때 다루겠습니다.
연결 행렬 (Connection Matrix)
G1은 Region 간 참조를 추적하기 위해 Remembered Set을 사용합니다. 각 Region마다 "어떤 객체가 나를 참조하는가"를 상세히 기록하는 방식입니다. 이 방식은 정확하지만, 힙의 1~20%를 차지할 정도로 메모리 오버헤드가 큽니다.
Shenandoah는 연결 행렬이라는 훨씬 단순한 구조를 사용합니다. "Region A가 Region B를 참조하는가"만 비트 하나로 기록합니다. 개별 객체 수준이 아니라 Region 수준의 참조 관계만 추적하는 것입니다.
메모리 오버헤드는 크게 줄어듭니다. 대신 GC 시 스캔 범위가 넓어질 수 있다는 트레이드오프가 있습니다. Region A가 Region B를 참조한다는 정보만 있고, 정확히 어떤 객체가 참조하는지는 모르기 때문에 Region A 전체를 스캔해야 합니다.
세대 구분에 대하여
초기 Shenandoah는 의도적으로 세대 구분을 하지 않았습니다. 이는 구현 복잡도를 낮추고 일관된 동시 처리에 집중하기 위한 선택이었습니다.
그러나 세대 구분이 없으면 모든 객체를 매번 스캔해야 해서 처리량이 떨어질 수 있습니다. 그래서 JDK 21부터는 Generational 모드가 추가되었습니다. "개선"이라기보다는 "단순화를 위해 포기했던 것을 다시 가져온" 것에 가깝습니다.
동작 방식: 9단계 GC 사이클
Shenandoah의 GC 사이클은 9단계로 구성됩니다. STW가 필요한 단계는 매우 짧게 유지됩니다.
1단계: 초기 표시(Initial Mark) - STW
GC 루트(스택, 정적 변수 등)에서 직접 참조하는 객체들을 마킹합니다. 루트에서 직접 닿는 객체만 처리하므로 매우 짧은 STW로 끝납니다.
2단계: 동시 표시(Concurrent Mark)
애플리케이션 실행과 동시에 객체 그래프를 순회하며 살아있는 객체를 마킹합니다. 힙 크기에 비례해서 시간이 걸리지만, 애플리케이션은 계속 실행됩니다.
3단계: 최종 표시(Final Mark) - STW
동시 마킹 중에 변경된 참조를 처리하고 마킹을 완료합니다. 회수할 Region(Collection Set)을 선정합니다. 짧은 STW가 필요합니다.
4단계: 동시 청소(Concurrent Cleanup)
살아있는 객체가 하나도 없는 Region을 즉시 회수합니다. 별도의 복사 과정 없이 Region 전체를 재사용 가능 상태로 만듭니다.
5단계: 동시 이주(Concurrent Evacuation)
이 단계가 Shenandoah의 핵심입니다. Collection Set에 포함된 Region의 살아있는 객체들을 다른 Region으로 복사합니다. 애플리케이션이 계속 실행되는 중에 이 작업이 진행됩니다.
"실행 중인 애플리케이션이 접근하는 객체를 어떻게 복사할 수 있는가?" 이 질문에 대한 답이 바로 포워딩 포인터입니다.
6단계: 초기 참조 갱신(Init Update Refs) - STW
참조 갱신을 준비하는 단계입니다. 매우 짧은 STW가 필요합니다.
7단계: 동시 참조 갱신(Concurrent Update References)
힙 전체를 스캔하면서 이전 위치를 가리키는 참조들을 새 위치로 갱신합니다. 이 작업도 애플리케이션과 동시에 진행됩니다.
8단계: 최종 참조 갱신(Final Update Refs) - STW
GC 루트가 가진 참조들을 새 위치로 갱신합니다. 짧은 STW가 필요합니다.
9단계: 동시 청소(Concurrent Cleanup)
이제 완전히 비어있는 이전 Region들을 회수합니다.
이 9단계 중에서 동시 표시(Concurrent Mark), 동시 이주(Concurrent Evacuation), 동시 참조 갱신(Concurrent Update References)이 핵심입니다. 이 세 단계가 애플리케이션 실행과 동시에 진행되기 때문에 STW 시간을 힙 크기와 무관하게 일정하게 유지할 수 있습니다.
포워딩 포인터 (Brooks Pointer)
5단계 Concurrent Evacuation에서 제기한 질문으로 돌아가겠습니다. 애플리케이션이 실행 중인데 객체를 어떻게 안전하게 복사할 수 있을까요?
기본 아이디어
Shenandoah는 모든 객체의 헤더에 포워딩 포인터를 추가합니다. 이 포인터는 평상시에는 자기 자신을 가리킵니다.
[일반 상태]
객체 A의 포워딩 포인터 → 객체 A 자신
GC가 객체를 새 위치로 복사하면, 원본 객체의 포워딩 포인터만 새 위치로 변경합니다.
[복사 후]
원본 객체 A의 포워딩 포인터 → 새 객체 A'
새 객체 A'의 포워딩 포인터 → 새 객체 A' 자신
어떻게 동시성 문제를 해결하는가
애플리케이션이 객체에 접근할 때 항상 포워딩 포인터를 통합니다. 이 방식의 핵심은 포인터 갱신이 원자적(atomic) 연산이라는 점입니다.
GC 스레드가 객체를 복사하고 포워딩 포인터를 갱신하는 순간, 그 이후의 모든 접근은 자동으로 새 위치로 향합니다. 애플리케이션 스레드와 GC 스레드가 동시에 동작해도 항상 유효한 객체에 접근하게 됩니다.
트레이드오프
초기 Shenandoah에서 포워딩 포인터는 객체당 1 word(64비트 시스템에서 8바이트)의 메모리 오버헤드를 발생시켰습니다. 또한 객체 접근마다 포워딩 포인터를 따라가는 간접 참조(indirection)가 추가됩니다.
이 오버헤드를 줄이기 위한 개선이 이어졌고, 이는 다음 섹션에서 다루겠습니다.
배리어의 진화
Shenandoah가 동시 이주를 수행하려면, 애플리케이션의 객체 접근을 가로채서 올바른 위치로 안내하는 배리어(Barrier)가 필요합니다. 이 배리어 구현은 여러 차례 개선을 거쳤습니다.
초기: Brooks Pointer + Read/Write Barrier
초기 구현에서는 객체를 읽거나 쓸 때마다 포워딩 포인터를 체크했습니다.
// 의사 코드
Object read(Reference ref) {
Object obj = ref.forwardingPointer; // 항상 포워딩 포인터를 통해 접근
return obj;
}
void write(Reference ref, Object value) {
Object obj = ref.forwardingPointer;
obj.field = value;
}
모든 읽기와 쓰기에서 간접 참조가 발생하므로 오버헤드가 컸습니다.
로드 참조 장벽 (Load Reference Barrier) 도입
JDK 13에서 배리어 방식이 개선되었습니다. 핵심은 객체 참조(Reference) 타입을 로드할 때만 배리어가 동작한다는 점입니다.
// 배리어 발생 O - 객체 참조 로드
Object obj = someObject.field; // field가 객체 타입
String name = person.getName(); // 참조 반환
// 배리어 발생 X - 원시 타입
int value = someObject.count; // int는 참조가 아님
long id = someObject.id; // long도 참조가 아님
boolean flag = someObject.active; // boolean도 참조가 아님
기존 방식은 객체에 접근할 때마다 포워딩 포인터를 체크했지만, 로드 참조 장벽은 참조 타입 필드를 읽을 때만 체크합니다. 원시 타입 필드 접근에는 배리어가 전혀 개입하지 않으므로 배리어 호출 횟수가 크게 줄어들었습니다.
포워딩 포인터 통합
JDK 14부터는 별도의 포워딩 포인터 슬롯을 제거했습니다. 대신 기존 객체 헤더의 Mark Word에 포워딩 정보를 통합했습니다.
기존 Mark Word는 락 상태, 해시코드, GC 나이 등을 저장하는데, 객체가 복사 중일 때는 이 공간을 포워딩 주소로 사용합니다. 어차피 객체가 복사 중이면 락을 잡을 일이 없기 때문에 충돌하지 않습니다.
이 개선으로 얻는 이점은 다음과 같습니다.
메모리 효율성: 객체당 1 word 오버헤드가 사라져서 같은 힙 공간에 더 많은 객체를 담을 수 있습니다. 결과적으로 GC 수행 횟수가 줄어듭니다.
캐시 적중률 향상: 객체 크기가 작아지면서 CPU 캐시에 더 많은 객체가 들어가고, 캐시 적중률이 높아집니다.
구현 단순화: 다른 가비지 컬렉터들과 객체 할당 코드를 공유할 수 있게 되어 HotSpot 내부 구현이 단순해졌습니다.
스택 워터마크 (Stack Watermark)
JDK 17에서는 스택 스캔 방식이 개선되었습니다. 기존에는 GC가 모든 스레드의 스택을 스캔하려면 모든 스레드를 멈춰야 했습니다.
스택 워터마크 방식에서는 스택 전체를 STW로 스캔하는 대신, 워터마크를 기준으로 작업을 분담합니다.
[스레드 스택]
┌─────────────┐
│ 최신 프레임 │ ← 사용자 스레드가 직접 처리
├─────────────┤
│ 워터마크 │ ← 경계선
├─────────────┤
│ 오래된 프레임│ ← GC 스레드가 concurrent하게 처리
└─────────────┘
동작 방식은 다음과 같습니다.
Safepoint에서 깨어날 때: 각 Java 스레드가 자신의 스택에서 실행을 계속하기 위해 필요한 최소한의 프레임(워터마크 위쪽)을 직접 처리합니다.
Concurrent 단계: GC 스레드가 워터마크 아래쪽의 나머지 프레임들을 애플리케이션 실행과 동시에 스캔합니다.
리턴 시 체크: 만약 Java 스레드가 메서드를 리턴하면서 GC가 아직 처리하지 않은 프레임으로 돌아가야 한다면, 그때 해당 프레임을 직접 처리합니다.
결국 스택 전체를 스캔하는 건 동일하지만, STW에서 처리하는 양이 최소화되고 나머지는 concurrent하게 처리되어 STW 시간이 크게 줄어듭니다.
결론
Shenandoah가 적합한 상황
수십 GB 이상의 대용량 힙을 사용하는 애플리케이션, 일관된 저지연이 중요한 서비스(응답 시간 SLA, 실시간 처리), 간헐적인 긴 STW가 서비스 품질에 큰 영향을 미치는 환경에서 Shenandoah가 적합합니다.
트레이드오프
Shenandoah는 STW 시간을 극단적으로 줄이는 대신 몇 가지를 희생합니다. 배리어 오버헤드로 인한 처리량 감소(G1 대비 약간 낮을 수 있음), 동시 작업을 위한 추가 CPU 사용, 연결 행렬의 정밀도 부족으로 인한 스캔 범위 증가 가능성이 그것입니다.
다음 포스트 예고
다음 포스트에서는 ZGC를 다루겠습니다. ZGC는 Shenandoah와 같은 목표(동시 이주, 저지연)를 추구하지만, 완전히 다른 접근 방식을 사용합니다. 포워딩 포인터 대신 Colored Pointer와 Multi-Mapping이라는 기법을 사용하는데, 이것이 어떤 차이를 만드는지 살펴보겠습니다.
'CS > JVM' 카테고리의 다른 글
| [JVM] 내 Java 애플리케이션, 혹시 Serial GC로 동작하고 있진 않을까? (0) | 2026.02.23 |
|---|---|
| [JVM 밑바닥까지 파헤치기] JVM 가비지 컬렉터의 역사 (6) - ZGC와 Generational ZGC: 서브밀리초 GC의 모든 것 (0) | 2026.02.22 |
| [JVM 밑바닥까지 파헤치기] JVM 가비지 컬렉터의 역사 (4) - G1: 새로운 패러다임 (1) | 2026.01.25 |
| [JVM 밑바닥까지 파헤치기] JVM 가비지 컬렉터의 역사 (3) - 동시성 컬렉터: CMS (0) | 2026.01.25 |
| [JVM 밑바닥까지 파헤치기] JVM 가비지 컬렉터의 역사 (2) - 병렬 컬렉터: ParNew, Parallel Scavenge, Parallel Old (0) | 2026.01.25 |