한 줄 정의
핵심 메시지
단일 GC 알고리즘으로 모든 워크로드를 만족시킬 수는 없기 때문에, 자바는 컬렉터를 플러그인 형태로 교체할 수 있도록 설계되었습니다.
짧은 일시 정지를 얻으려면 동시(concurrent) 실행이 필요하고, 동시 실행은 세이프포인트·삼색 마킹·포워딩 포인터 같은 메커니즘으로 변경자 스레드와 마커 스레드의 충돌을 해결합니다. 자바 9 이후의 기본 컬렉터인 G1은 이 모든 이론을 영역(region) 기반 세대별 구조로 통합해 큰 힙에서도 일시 정지 목표를 달성하도록 설계되었습니다.
셰넌도어와 ZGC는 여기서 한 발 더 나아가 압축까지 동시에 수행해 수 ms 이하 일시 정지를 목표로 하며, 이를 가능케 하는 핵심은 브룩스 포인터와 컬러 포인터/로드 장벽 같은 객체 헤더·참조 메타데이터 기법입니다.
쉽게 말하면
GC는 도로 청소와 같습니다. 가장 단순한 방법은 도로를 통째로 막고 청소하는 것(STW)이지만, 도시가 커질수록 막혀 있는 시간이 견디기 어려워집니다. 그래서 차들이 다니는 동안 한쪽 차선만 막고 청소하는 방식(동시 GC)이 등장합니다.
문제는 차가 지나가면서 쓰레기를 다시 흘리고 다닌다는 점입니다. 청소부(GC 스레드)가 “이 구역은 깨끗하다”고 표시해두면, 잠시 후 차(애플리케이션 스레드)가 새 쓰레기를 떨어뜨릴 수 있습니다. 세이프포인트는 모든 차를 잠시 신호등에 멈춰 세우는 횡단보도이고, 삼색 마킹은 청소 진행 상황을 색깔로 표시하는 시스템이며, 포워딩 포인터는 옮긴 가게의 “이전 안내문” 입니다.
G1은 도로를 작은 블록 단위로 쪼개서, “이번 사이클에는 쓰레기가 가장 많은 블록만 청소하자”라는 전략으로 동작합니다. 도시 전체를 한꺼번에 청소하지 않으니 일시 정지가 짧아집니다.
왜 중요한가?
자바 8까지 기본이던 ParallelGC는 처리량 최적화에는 좋지만, 힙 크기에 거의 비례하는 STW 시간이라는 치명적 약점이 있습니다. 8GB·16GB 힙을 쓰는 백엔드 서비스에서는 한 번의 전체 GC가 수 초에 달할 수 있고, 이는 곧바로 SLA 위반으로 이어집니다.
이 문제를 해결하려면 GC 작업의 일부를 애플리케이션 스레드와 동시에 수행해야 합니다. 하지만 동시 GC는 단순히 “스레드를 여러 개 띄우는” 문제가 아닙니다. 변경자 스레드가 객체 그래프를 바꾸는 동안 마커 스레드가 어떻게 정확성을 유지할 것인가 — 이 질문에 답하기 위한 컴퓨터 과학 이론(삼색 마킹, SATB, 쓰기 장벽)이 필요합니다.
G1은 이 모든 이론을 프로덕션 수준으로 구현한 결과물입니다. 백엔드 엔지니어 입장에서는 “G1을 그냥 쓰자”가 아니라, G1이 왜 그렇게 동작하는지를 이해해야 IHOP 조정·-XX:MaxGCPauseMillis 튜닝·조기 승격 진단이 가능해집니다.
그리고 한 발 더 나아가 수십~수백 GB 힙을 다뤄야 한다면 G1로도 부족합니다. 마킹은 동시화되어도 압축(이동)이 STW로 남아 있는 한 일시 정지는 힙 크기에 종속되기 때문입니다. 셰넌도어·ZGC가 등장한 이유는 이 마지막 STW 단계까지 동시화하기 위해서이며, 그 대가로 추가 CPU·메모리·구현 복잡성을 지불합니다.
핵심 내용
5.1 트레이드오프와 플러그그형 컬렉터
자바 플랫폼 스펙은 가비지 컬렉터를 어떻게 구현해야 하는지 규정하지 않습니다. 심지어 GC가 전혀 구현되지 않은 자바 환경(에필론, 레고 마인드스톰)도 존재합니다. GC 없는 자바는 모든 객체를 재사용해야 하고, 더 이상 필요 없는 객체가 자동으로 삭제되지 않으면 메모리 누수가 발생하기 때문에 프로그래밍이 매우 어렵습니다.
플러그인 가능한 서브시스템
오라클/OpenJDK 환경에서 GC 서브시스템은 플러그인 가능한 형태입니다. 동일한 자바 프로그램을 다른 GC로 바꾸어 실행해도 의미상 동일하게 동작하며, 단지 성능 특성만 달라집니다.
모든 GC를 만족시키는 단일 알고리즘은 없다
하나의 알고리즘이 모든 워크로드에 적합하지 않기 때문에, GC 알고리즘은 항상 타협이나 절충안을 제시합니다. 어떤 컬렉터가 다른 컬렉터보다 절대적으로 우월한 경우는 거의 없습니다.
GC 선택의 주요 고려 요소
| 요소 | 의미 |
|---|---|
| 애플리케이션 STW | 일시 중지 시간 또는 지속 시간 |
| 처리량 | 애플리케이션 실행 시간 대비 GC 실행 시간 비율 |
| 일시 중지 빈도 | 컬렉터가 애플리케이션을 중지해야 하는 빈도 |
| 회수 효율성 | 단일 GC 사이클이 수집할 수 있는 가비지의 양 |
| 일시 중지의 일관성 | 모든 일시 중지가 대부분 일정한 길이인지 여부 |
일시 중지 시간이 항상 정답은 아니다
많은 작업에서 일시 중지 시간은 성능을 평가하는 중요한 요소이지만, 반드시 유용한 특성이라고 할 수는 없습니다.
대규모 병렬 처리 애플리케이션이나 배치 작업에서는 몇 초의 일시 중지가 크게 문제되지 않습니다. 오히려 CPU 효율성을 최적화하는 GC가 더 적합할 수 있습니다. 일시 중지 시간만 줄이는 데 집중하면 오히려 애플리케이션 전체 처리량이 나빠질 수도 있습니다.
처리량의 함정
외부 요인으로 성능이 저하된 애플리케이션을 가정합니다. 작업량이 줄면 가비지 생성량도 감소하고, 가비지가 줄어들면 GC가 처리할 작업이 줄고, GC에 소요되는 시간 비율도 줄어듭니다. 하지만 애플리케이션 성능이 실제로 좋아진 것은 아닙니다. 처리량은 단순한 지표가 아닙니다.
압축의 가치
압축은 관련 객체들을 인접한 메모리 위치에 배치하는 방식으로 동작합니다. 캐시 라인에서 이미 적재 상태일 가능성이 더 높기 때문에 객체들이 가까이 있을수록 메모리 접근이 더 효율적입니다.
애플리케이션은 메모리를 할당하고 읽는 데 많은 시간을 소비합니다. GC에 좀 더 시간을 쓰더라도 메모리 접근 속도를 높이는 것이 바람직할 수 있습니다. 다만 추측이 아닌 측정으로 판단해야 합니다.
자바 21 기준 주요 컬렉터
자바 21부터 오라클/OpenJDK 기준으로 네 가지 주요 GC가 제공됩니다.
- 처리량 컬렉터(ParallelGC, 4장에서 다룬 컬렉터)
- G1 (기본 컬렉터)
- 셰넌도어(Shenandoah)
- ZGC
이들 중 일부는 모든 워크로드에 권장되지 않거나, 일부는 사용 중단(deprecated) 상태입니다. 무작정 새 컬렉터로 갈아타지 말고 워크로드 특성을 먼저 파악해야 합니다.
5.2 동시 가비지 컬렉션 이론
GC의 비결정성은 직접적인 메모리 할당 동작에서 발생하며, 자바가 사용되는 많은 시스템은 매우 가변적인 메모리 할당 특성을 보입니다. 더 큰 문제는, 범용으로 설계된 GC가 특정 사용 도메인에 대한 정보를 전혀 가지고 있지 않다는 점입니다.
비결정성의 출처
그래픽이나 애니메이션 디스플레이 시스템처럼 고정된 프레임 속도를 유지해야 하는 환경에서는 GC를 일정한 간격으로 수행할 기회가 제공됩니다. 하지만 자바 플랫폼의 철학은 GC를 관리하는 하위 시스템으로 유지하는 것이므로, 애플리케이션이 GC를 직접 제어할 수 있는 메커니즘을 제공하지 않습니다.
GC는 애플리케이션의 세부 사항을 알지 못하며, 단지 힙에 있는 실제 사용 중인 객체의 그래프만 추적할 뿐입니다. 이는 자바 GC에서 비결정성이 더욱 심화됩니다.
데이크스트라 외
이 방식의 단점은 적절한 계산이 지연된다는 점이며, 주요 문제는 이러한 GC 간섭의 예측 불가능성입니다.
현대 GC 이론의 출발점은 데이크스트라가 지적했듯 STW의 지속 시간과 빈도가 비결정적이라는 점이 주요 불편 요소라는 통찰에서 시작되었습니다.
동시 GC의 발상
해결책은 동시(또는 적어도 부분적/대부분 동시) 로 작동하는 컬렉터입니다. 애플리케이션 스레드가 실행되는 동안 일부 GC 작업을 수행함으로써 일시 정지 시간을 줄이려는 시도입니다.
다만 트레이드오프가 있습니다.
- 애플리케이션이 사용할 수 있는 처리 능력이 줄어듭니다.
- GC를 수행하는 코드의 복잡성이 증가합니다.
5.2.1 자바 가상 머신 세이프포인트
STW GC를 수행하려면 모든 애플리케이션 스레드를 중지해야 합니다. 자명해 보이지만, 자바 가상 머신이 이를 어떻게 구현하는지는 단순하지 않습니다.
JVM은 완전한 선점형 환경이 아니다
자바는 협력형이다
자바 가상 머신은 실제로 완전히 선점형(preemptive) 멀티스레딩 환경이 아닙니다.
운영 체제는 얼마든지 스레드를 선점(코어에서 제거)할 수 있지만, JVM 자체는 애플리케이션 스레드를 강제로 멈출 수 없습니다. 두 가지 원칙이 있습니다.
- JVM은 스레드를 강제로 세이프포인트 상태로 전환할 수 없습니다.
- JVM은 스레드가 세이프포인트 상태를 벗어나는 것을 막을 수 있습니다.
세이프포인트의 정의
각 애플리케이션 스레드가 세이프포인트(safepoint) 라고 불리는 특정 실행 지점을 가지도록 요구합니다. 이 지점에서 스레드의 내부 데이터 구조는 안정된 상태를 유지하며, 스레드는 조정된 작업을 위해 중단될 수 있습니다.
인터프리터 구현에서는 세이프포인트가 필요할 경우를 감지하고 양보하는 코드가 포함되어야 합니다. JIT 컴파일된 메서드에서는 생성된 기계어 코드에 해당 장벽이 삽입되어야 합니다.
세이프포인트 도달 절차
- JVM이 전역적으로 ‘세이프포인트로 진입할 시간’ 플래그를 설정합니다.
- 개별 애플리케이션 스레드가 이 플래그를 감지하고 설정된 상태인지 확인합니다.
- 스레드가 중단된 후 다시 깨울 때까지 대기합니다.
폴링 메커니즘
일반 애플리케이션 스레드는 폴링(polling) 메커니즘을 사용합니다.
- 인터프리터 모드에서는 두 바이트코드 실행 사이마다 플래그를 확인합니다.
- JIT 컴파일된 코드에서는 메서드 종료 시점과 루프 뒤 분기(루프 시작으로 돌아갈 때) 폴링 지점이 삽입됩니다.
graph TD JVM[JVM] -->|"① 세이프포인트 플래그 ON"| Threads subgraph Threads["애플리케이션 스레드들"] T1[스레드 A<br/>바이트코드 사이 체크] T2[스레드 B<br/>JIT 코드 폴링 지점] T3[스레드 C<br/>JNI 실행 중<br/>자동 세이프포인트] end T1 --> Latch[모든 스레드 멈출 때까지 대기<br/>CountDownLatch와 유사] T2 --> Latch T3 --> Latch Latch -->|"② STW 단계 시작"| GC[GC 작업 수행]
CountDownLatch 비유
모든 스레드가 완전히 멈춰야 STW 단계가 시작될 수 있다는 개념은
java.util.concurrent라이브러리의CountDownLatch가 구현하는 래치(latch)와 유사합니다.
세이프포인트 상태 정리
| 상황 | 분류 |
|---|---|
| 모니터에서 차단됨 | 자동으로 세이프포인트 진입 |
| JNI 코드 실행 중 | 자동으로 세이프포인트 진입 |
| 바이트코드를 인터프리터 모드로 실행 중 | 반드시 세이프포인트에 있는 것은 아님 |
| 운영 체제가 스레드를 인터럽트한 경우 | 반드시 세이프포인트에 있는 것은 아님 |
| JIT 컴파일된 코드의 명시적 세이프포인트가 아닌 지점 | 반드시 세이프포인트에 있는 것은 아님 |
스레드가 세이프포인트 도달이 느리다면 비정상이다
스레드가 세이프포인트로 진입하는 데 오랜 시간이 걸릴 수도 있지만, 이는 일반적으로 비정상적인 상황이며 의도적으로 유발되는 경우에 해당합니다. GC 로그에
Total time for safepoint operation이 매우 길게 찍힌다면 JNI 호출이나 대형 루프를 의심해야 합니다.
5.2.2 삼색 마킹
데이크스트라와 램포트의 1978년 논문은 동시 알고리즘의 정확성 증명과 GC 분야의 중요한 이정표입니다. 여기서 설명한 삼색 마킹(tri-color marking) 알고리즘은 여전히 GC 이론에서 핵심 역할을 합니다.
알고리즘의 동작
회색 노드 집합을 유지하면서 작동하며, 이는 발견되었지만 아직 완전히 처리되지 않은 노드를 의미합니다.
- GC 루트는 회색으로 표시됩니다.
- 다른 노드(객체)는 흰색으로 표시됩니다.
- 마킹 스레드는 무작위로 회색 노드를 선택합니다.
- 선택된 노드에 흰색 자식 노드가 없으면, 해당 노드는 검은색으로 변경됩니다.
- 흰색 자식 노드가 있다면, 마킹 스레드는 해당 자식 노드를 회색으로 변경한 후 탐색을 계속합니다.
- 이 과정을 회색 노드가 남지 않을 때까지 반복합니다.
| 색깔 | 의미 |
|---|---|
| 흰색 | 아직 발견되지 않음 (수집 대상) |
| 회색 | 발견됐지만 자식 노드를 아직 처리 못 함 |
| 검은색 | 자신과 직접 자식까지 모두 처리 완료 (살아 있음 확정) |
검은색 객체는 도달 가능한 것으로 판명되므로 수집되지 않습니다. 반대로 흰색 노드는 도달할 수 없는 객체로 간주되어 GC의 대상이 됩니다.
SATB (Snapshot At The Beginning)
동시 GC에서는 즉시 스냅샷(snapshot at the beginning, SATB) 이라는 기법을 자주 사용합니다. 컬렉션이 시작될 때 도달 가능한 객체나 컬렉션 도중 새로 생성된 객체를 ‘살아있는’ 객체로 간주합니다.
- 컬렉션 실행 중: 새로운 객체는 검은색 상태
- 컬렉션 진행 중이지 않을 때: 흰색 상태
변경자 스레드가 삼색 마킹을 무효화하는 상황
문제는 마킹 스레드가 삼색 마킹 알고리즘을 실행하는 동안 변경자(mutator) 스레드가 객체 그래프를 변경한다는 점입니다.
예를 들어, 마킹 스레드가 이미 객체를 검은색으로 표시한 후, 변경자 스레드가 해당 객체를 흰색 객체로 업데이트했다고 가정합니다. 만약 회색 객체에서 새 흰색 객체에 대한 모든 참조가 삭제된 경우, 해당 흰색 객체에 여전히 도달해야 하지만 알고리즘 규칙에 따라 찾을 수 없으므로 삭제됩니다.
삼색 불변성
동시 마킹 중에는 검은 객체가 흰색 객체를 참조할 수 없습니다. 이 불변성이 깨지면 살아 있는 객체를 가비지로 오인하게 됩니다.
해결책 1: 쓰기 장벽으로 다시 회색 만들기
검은색 객체를 다시 회색으로 바꾸고, 업데이트 과정에서 다시 처리해야 할 노드 집합에 추가하는 방법입니다. 업데이트에 쓰기 장벽을 사용하면 마킹 사이클 전체에서 삼색 불변성을 유지할 수 있습니다.
하지만 이 방법은 마킹 알고리즘이 종료된다는 단순한 증명에 필요한 ‘단조성(monotonicity)’ 속성을 파괴합니다. 검은색 → 회색으로 되돌리면 알고리즘이 끝나지 않을 가능성이 생깁니다.
해결책 2: 보정 단계 (G1이 채택)
불변성을 위반할 가능성이 있는 모든 변경 사항을 추적하는 큐를 유지하고, 주요 단계가 완료된 후 이를 수정하는 보정(fixup) 단계를 실행합니다. 이 보정 단계는 필연적으로 STW를 동반해야 하지만, 실제로는 매우 짧습니다.
G1은 두 번째 접근 방법(리마크 단계)을 사용합니다.
graph LR Start[동시 마킹 시작<br/>모든 노드 흰색] --> Mark[동시 마킹<br/>변경자와 함께 진행] Mark --> Queue[변경 사항을 큐에 기록<br/>SATB 버퍼] Queue --> Remark[STW 리마크 단계<br/>큐 처리하여 불변성 복원] Remark --> Sweep[수집]
5.2.3 포워딩 포인터
객체를 이동시키는 컬렉터는 “옮긴 객체를 가리키던 참조들을 어떻게 업데이트할 것인가”라는 문제에 부딪힙니다. 포워딩 포인터(forwarding pointer) 는 발명가 로드니 브룩스의 이름을 따 브룩스 포인터(Brooks Pointer) 라고도 불립니다.
기본 아이디어
각 객체에 추가적인 메모리 공간(워드)을 사용하여 해당 객체가 이전 GC 단계에서 재배치되었는지 여부를 나타내고, 객체의 새로운 위치를 가리키는 정보를 제공합니다.
표준 핫스팟 vs 셰넌도어 레이아웃
| 레이아웃 | 헤더 구성 |
|---|---|
| 표준 핫스팟 (64비트 정수) | mark word → klass word → 값 |
| 셰넌도어 초기 버전 (64비트 정수) | 브룩스 포인터 → mark word → klass word → 값 |
객체가 재배치되지 않은 경우, 브룩스 포인터는 객체 헤더의 시작을 가리킵니다(자기 자신 참조).
동시 마킹 중 객체 이동
동시 마킹 단계가 진행되는 동안, 컬렉터 스레드는 힙을 추적하며 살아있는 객체들을 마킹합니다. 객체 참조가 포워딩 포인터를 가진 객체 포인터를 가리킬 때, 참조는 새롭게 위치한 객체를 가리키도록 업데이트됩니다.
graph TD Ref[다른 객체의 참조<br/>0x...c310] -->|이동 전| OldObj[원본 위치<br/>브룩스 포인터: 자기 자신] OldObj -.->|객체 이동 후| NewObj[새 위치<br/>0x...ac49] OldObj2[원본 위치<br/>브룩스 포인터: 0x...ac49] -->|포워딩| NewObj2[새 위치<br/>실제 데이터] Ref2[참조] -->|업데이트| NewObj2
CAS 연산이 필수
브룩스 포인터 메커니즘은 하드웨어의 비교와 교환(compare-and-swap, CAS) 연산이 가능해야 포워딩 주소를 원자적 연산으로 업데이트할 수 있다는 원리에 기반합니다.
비용
객체당 하나의 메모리 단어가 추가로 필요합니다. 예를 들어 Integer 객체의 힙 공간이 20바이트에서 28바이트로 증가할 수 있으며, 이는 상당한 추가 오버헤드를 초래합니다. 셰넌도어처럼 고급 컬렉터에서 사용되며, 나중에 메모리 요구 사항을 줄이는 방법이 추가됩니다.
5.3 G1
G1은 처음 자바 6에서 매우 실험적이고 불안정한 형태로 도입되었으며, 자바 7 동안 광범위하게 다시 작성되었습니다. 이후 자바 8u40에서 안정적이고 프로덕션 환경에서 사용할 수 있는 상태가 되었습니다.
자바 8u40 이전 버전에서는 G1 사용 금지
어떤 종류의 작업 부하를 고려하든 자바 8u40 이전 버전에서는 G1 사용을 권장하지 않습니다.
G1의 설계 목표
원래 짧은 일시 정지 시간을 목표로 동시 마크 스윕(CMS) 컬렉터를 대체하기 위해 설계되었습니다(CMS는 더 이상 지원되지 않습니다).
- 동시 마크 스윕보다 튜닝이 훨씬 쉬움
- 조기 승격에 덜 취약함
- 큰 힙에서 더 나은 확장성(특히 일시 정지 시간)을 가짐
- 전체 STW 컬렉션으로 돌아갈 필요성을 크게 줄임
자바 9에서 기본 컬렉터가 되어 병렬 컬렉터를 대체했고, 자바 11·17·21에서 지속적으로 개선되었습니다.
일시 정지 목표
G1에서 가장 중요한 개념은 일시 정지 목표(-XX:MaxGCPauseMillis) 입니다. 기본값은 200ms입니다.
하지만 이는 목표일 뿐 보장이 아닙니다. 값을 너무 낮게 설정하면 GC 서브시스템은 목표를 달성할 수 없습니다.
GC는 할당에 의해 구동된다
가비지 컬렉션은 메모리 할당에 의해 구동되며, 이는 많은 자바 애플리케이션에서 매우 예측하기 어렵습니다. 이는 G1이 일시 정지 목표를 달성하는 능력을 제한하거나 파괴할 수 있습니다.
5.3.1 G1 힙 레이아웃과 영역
G1 힙은 고정 크기의 영역(region) 개념을 기반으로 하며, 이러한 영역이 모여 세대(generation)를 구성합니다.
영역 기반의 핵심 이점
- 비연속적인 세대(non-contiguous generation) 를 가능하게 함
- 각 실행 시 모든 가비지를 수집할 필요가 없는 단계적 수집(incremental collection) 을 가능하게 함
힙 자체는 연속적
전체 G1 힙은 여전히 메모리에서 연속적이지만, 각 세대를 구성하는 메모리가 더 이상 연속적일 필요는 없습니다.
영역과 세대의 종류
| 색상 | 의미 |
|---|---|
| 영구 세대 | 오래 살아남은 객체 |
| 서바이버 | 젊은 세대 GC를 한 번 이상 살아남은 객체 |
| 에덴 | 신규 객체 |
| 미사용 | 비어 있어 재사용 가능 |
| 거대한(humongous) | 영역 크기의 절반 이상인 큰 객체 |
영역 크기 계산
자바 21부터 G1의 알고리즘은 2의 거듭제곱 MB 크기의 영역을 허용합니다(1, 2, 4, … 최대 512MB).
기본적으로 G1은 힙에서 2,048에서 4,095개의 영역을 기대하며, 이를 달성하기 위해 영역 크기를 조정합니다.
<영역 크기> = round_down_to_power_of_2(<Heap size> / 2048)
<영역 수> = <Heap size> / <영역 크기>
거대한 객체
영역 크기의 절반보다 큰 객체를 거대한 객체(humongous object) 로 간주합니다. 이러한 객체들은 거대한 영역(humongous region) 에 직접 할당됩니다. 즉시 젊은 세대가 아닌 영구 세대의 일부가 되며, 일반적인 예는 거대 배열입니다(배열도 자바에서는 객체).
거대 객체가 만드는 단편화
거대 객체는 자유롭고 연속적인 영역을 차지하므로, 영구 세대 단편화의 주요 원인이 됩니다. 거대 객체 할당이 실패하면 전체 GC가 트리거됩니다. JSON 직렬화·이미지 처리 같은 워크로드에서 큰 byte 배열·char 배열이 거대 객체로 분류되는 경우가 흔합니다. 영역 크기가 32MB라면 16MB 이상의 객체가 모두 거대 객체입니다.
5.3.2 G1 컬렉션
G1 컬렉터에는 두 가지 유형의 컬렉션이 있습니다.
젊은 세대 가비지 컬렉션
수집 대상이 되는 영역은 젊은 세대 영역만 포함합니다. 가능한 한 빨리 힙을 회수하려고 하는 STW 컬렉션입니다. 이 과정에서 메모리 영역을 완전히 비워 즉시 재사용할 수 있도록 합니다.
워밍업 중에, 컬렉터는 GC 실행 당 얼마나 많은 ‘일반적인’ 영역을 수집할 수 있는지를 추적합니다. 이전 GC 이후 할당된 새로운 객체의 양을 상쇄할 만큼 충분한 메모리를 수집할 수 있다면, 컬렉터는 메모리 할당 속도에 뒤처지지 않으며 G1 New 컬렉션이 계속 진행될 것입니다.
혼합 컬렉션
컬렉션 집합에 영 영역과 오래된 영역이 모두 포함됩니다. 대부분 동시로 수행되며, 오래된 객체의 수가 충분히 증가하여 젊은 세대 GC만으로는 충분한 메모리를 회수할 수 없을 때 사용됩니다.
IHOP (초기 힙 점유율 비율)
혼합 컬렉션이 시작되는 시점을 초기 힙 점유율 비율(IHOP, initiating heap occupancy percent) 임계값이라고 합니다. G1은 이전 애플리케이션의 동작을 바탕으로 IHOP을 자동으로 결정합니다. 기본 초기 값은 **45%**이며, 애플리케이션은 적응적으로 이 값을 조정합니다.
G1 요약
- 영역 기반이며, 세대별 컬렉터
- 객체를 이동시키는 방식의 GC
- ‘통계적 압축(statistical compaction)’ 제공
- 혼합 컬렉션을 위해 동시 마킹 단계를 사용
5.3.3 혼합 컬렉션
G1의 자주 간과되는 강점은 혼합 컬렉션이 대부분 애플리케이션 스레드와 동시에 실행된다는 점입니다. 사용 가능한 코어 중 일부(최소 하나)는 G1의 동시 단계를 수행하고, 나머지 코어는 애플리케이션 코드를 계속해서 실행합니다.
ConcGCThreads 공식
동시 GC에 사용되는 코어 수
max(1, (ParallelGCThreads + 2) / 4)여기서
ParallelGCThreads는 보통 코어 수이며, 특히 작은 머신에서 그렇습니다.
두 가지 주요 결과
- 혼합 컬렉션이 실행되는 동안 애플리케이션 처리량이 감소합니다.
- 동시 컬렉션이 진행 중일 때 젊은 세대 GC가 수행되어야 할 수 있습니다.
혼합 컬렉션 도중 젊은 세대 GC가 실행되어야 하는 경우, 일반적으로 완료 시간이 더 길어집니다. 이는 젊은 세대 GC가 사용할 수 있는 코어 수가 줄어들기 때문입니다.
G1 Old의 네 단계
graph LR Init["1. 동시 시작 (STW G1 New 포함)"] --> Mark["2. 동시 마킹"] Mark --> Remark["3. 재마킹 (STW)"] Remark --> Cleanup["4. 클린업 (STW)"]
| 단계 | STW 여부 | 역할 |
|---|---|---|
| 동시 시작 | STW (G1 New 포함) | 각 영역 내 안정적인 시작 지점 제공, GC 루트 역할 |
| 동시 마킹 | 동시 | 힙에 대해 삼색 마킹 알고리즘 실행, 변경 사항 추적 |
| 재마킹 | STW | 동시 마킹 중 누락된 변경 보정, 약한 참조 처리 |
| 클린업 | STW (대부분) | 비워져 재사용 준비 완료된 영역(에덴) 식별 |
동시 마킹 단계는 다른 단계보다 훨씬 오래 걸립니다. G1 Old는 대부분의 실행 시간을 애플리케이션 스레드와 나란히 실행됩니다. 전반적인 효과는 ParallelOld와 비교했을 때 긴 STW 일시 정지 하나를 세 번의 짧은 STW 일시 정지로 대체하는 것입니다.
재마킹 단계의 성능 문제는 약한 참조에서 온다
2024년 8월 기준으로 재마킹 단계에서 성능 문제가 발생할 수 있는 주요 요인은 약한 참조를 처리하는 부분입니다. 약한 참조 처리 과정에서 작업량이 예측할 수 없을 정도로 많아질 수 있어 이 단계가 시간이 많이 걸릴 가능성이 있습니다. WeakReference·SoftReference를 대량으로 사용하는 캐시 라이브러리는 재마킹 STW를 늘릴 수 있습니다.
5.3.4 기억 집합
ParallelOld를 다룰 때 약한 세대 가설의 일부로 ‘오래된 객체에서 젊은 객체로의 참조가 적다’ 는 휴리스틱이 언급되었습니다. 핫스팟은 병렬 컬렉터와 동시 마크 스윕(CMS) 컬렉터에서 이 현상을 활용하기 위해 카드 테이블을 사용합니다.
기억 집합 (RSet)
G1은 영역 추적을 돕기 위해 이와 관련된 기능을 제공합니다. 기억 집합(remembered set, RSet) 은 영역별로 존재하며, 힙 영역으로 포인팅하는 외부 참조를 추적합니다.
이렇게 함으로써 G1은 영역으로 포인팅하는 참조를 찾기 위해 전체 힙을 추적하는 대신 기억 집합만 검사하고, 해당 영역을 스캔하여 참조를 찾으면 됩니다.
graph LR R1[영역 1] -->|외부 참조| Target[영역 2] R3[영역 3] -->|외부 참조| Target Target -.->|RSet 보관| RSet[영역 2의 RSet<br/>= 영역 1·영역 3에서 옴]
부유 가비지 (Floating Garbage)
기억 집합(과 병렬 컬렉터의 카드 테이블) 기법은 부유 가비지(floating garbage) 라는 GC 문제를 초래할 수 있습니다. 부유 가비지란 현재 수집 집합에 포함되지 않은 죽은 객체에서 오는 참조 때문에 실제로는 죽은 객체가 계속 살아남게 되는 문제입니다.
즉, 전역 마킹에서는 죽은 객체로 간주되지만, 더 제한적인 로컬 마킹(local marking)에서는 사용된 루트 집합에 따라 잘못 살아있는 것으로 보고될 수 있습니다.
세척 (Scrubbing)
혼합 컬렉션의 클린업 단계 동안 G1은 기억 집합 ‘세척(scrubbing)’ 작업을 수행합니다. 비워진 영역(evacuated region)으로 포인팅하던 영역의 기억 집합을 검사하고, 다른 영역으로 이동된 객체에 대한 참조를 업데이트하는 작업입니다.
5.3.5 전체 컬렉션
전체 컬렉션(full GC) 은 STW 방식으로 수행되며, 이는 병렬 컬렉터에서 다뤘던 전체 컬렉션과 유사합니다. 힙 전체(젊은 세대와 영구 세대 공간 모두)를 정리합니다. 또한 거대한 객체가 할당된 영역 내의 객체를 제자리에서 압축합니다. 거대한 객체는 복사 오버헤드를 줄이기 위해 일반적인 비우기 단계에 포함되지 않습니다.
전체 컬렉션의 발생 원인
| 원인 | 설명 |
|---|---|
| 거대 영역의 단편화 | 충분한 여유 메모리가 있어도 연속된 블록으로 공간이 확보되지 않아 거대한 객체 할당이 실패 |
| 동시 모드 실패(concurrent mode failure) | 혼합 컬렉션 중 동시 마킹 단계가 애플리케이션이 사용 가능한 힙 메모리를 모두 할당하기 전에 완료되지 못한 경우 |
동시 모드 실패는 G1의 최악 시나리오
G1이 동시로 작업을 처리하지 못하고 전체 STW 컬렉션으로 돌아가는 상황입니다. G1을 쓰는 이유 자체가 무력화되므로, 발생하면 즉시 튜닝 대상입니다.
해결 방향
일반적으로 IHOP 임계값은 이러한 상황이 발생하지 않도록 동적으로 설정됩니다. 하지만 애플리케이션의 할당 프로파일(allocation profile)이 변경되면, 과거의 동작을 기반으로 한 예측 값이 잘못될 수 있습니다.
전체 컬렉션을 피하려면 동시 마킹 단계를 더 일찍 시작하는 것이 명백한 해결책입니다. 보통 적응적으로 이루어지지만, 특정 작업 부하의 경우 IHOP 임계값을 수동으로 설정해야 할 수도 있습니다.
5.3.6 G1을 위한 자바 가상 머신 설정 플래그
G1 활성화
자바 8을 사용하고 있는 경우, G1을 활성화하려면 다음 스위치를 사용해야 합니다.
-XX:+UseG1GC
현대적인 자바 버전에서는 활성화 스위치가 필요하지 않습니다. 하지만 컨테이너에서 실행할 때 주의가 필요합니다. 컨테이너에 코어가 하나만 보이는 경우, G1은 동시 실행을 할 수 없으며(코어가 하나뿐이기 때문) 직렬 컬렉터로 대체됩니다.
일시 정지 목표
-XX:MaxGCPauseMillis=200
기본 일시 정지 시간 목표는 200ms입니다. 실제로 힙 크기가 한 자릿수 GB 정도라면, 컬렉터가 이 값에 근접하지 않을 가능성이 크며, STW 시간은 훨씬 짧을 것입니다.
IHOP 튜닝
동시 모드 실패가 너무 자주 발생한다면, IHOP 임계값을 조정할 수 있습니다.
-XX:G1ReservePercent=10
힙 점유율 비율의 적응적 계산을 비활성화하고 수동으로 설정하려면(대부분의 애플리케이션에는 권장되지 않음):
-XX:-G1UseAdaptiveIHOP
-XX:InitiatingHeapOccupancyPercent=45
적응적 동작은 유지하면서 초기 값만 변경하려면 -XX:InitiatingHeapOccupancyPercent=<n>만 설정합니다.
영역 크기 변경
-XX:G1HeapRegionSize=<n>m
<n>은 1에서 512 사이의 2의 거듭제곱이어야 하며, 단위는 MB입니다. m 접미사는 필수이며, 생략할 경우 예상치 못한 결과가 발생할 수 있습니다.
거대 객체 회피용 영역 크기 튜닝
거대 객체가 자주 만들어지는 워크로드에서는 영역 크기를 늘려 객체가 거대 객체로 분류되지 않도록 만들 수 있습니다. 예를 들어 8MB 객체가 자주 생성되면 기본 영역 크기 4MB에서는 거대 객체로 취급되지만,
-XX:G1HeapRegionSize=16m으로 늘리면 일반 객체로 분류됩니다.
5.4 셰넌도어
셰넌도어(Shenandoah) 는 G1의 대안으로 레드햇이 OpenJDK 프로젝트 내에서 개발한 컬렉터입니다. 자바 12에서 실험적으로 도입되어 자바 15에서 프로덕션용으로 승격되었고, 자바 17·21에서 완전히 지원됩니다.
레드햇의 backporting
레드햇은 자체 OpenJDK 배포판에 셰넌도어 GC를 자바 8 또는 11 버전으로 역이식(backporting) 했습니다. 오라클 OpenJDK 빌드에는 셰넌도어가 포함되지 않지만, 다른 거의 모든 OpenJDK 빌드(AdoptOpenJDK·Temurin·Corretto 등)에서는 사용할 수 있습니다.
무엇을 노렸는가
셰넌도어의 목표는 수십~수백 GB의 대용량 힙에서 일시 정지 시간을 줄이는 것입니다. 다만 이를 달성하기 위해 G1보다 더 많은 CPU 자원을 소비할 가능성이 큽니다. 핵심 접근 방식은 동시 압축(concurrent compaction) — 즉 객체를 이동시키는 작업까지 애플리케이션 스레드와 동시에 수행하는 것입니다.
수집 9단계
1. 초기 마킹 6. 초기 참조 업데이트
2. 동시 마킹 7. 동시 참조 업데이트
3. 최종 마킹 8. 최종 참조 업데이트
4. 동시 클린업 9. 동시 클린업
5. 동시 이동
겉으로는 G1의 단계와 유사해 보이고, 즉시 스냅샷(SATB) 같은 공통 기법도 사용됩니다. 하지만 근본적인 차이는 5번 동시 이동과 6~8번 참조 업데이트가 별도 단계로 존재한다는 점입니다. G1은 비우기(evacuation)를 STW로 처리하지만, 셰넌도어는 이것마저 동시 단계로 빼냅니다.
5.4.1 동시 이동
GC 스레드가 애플리케이션 스레드와 동시에 실행되면서 어떻게 비우기를 수행하는지가 셰넌도어의 핵심입니다. 초기 버전은 포워딩 포인터(브룩스 포인터)를 사용해 다음과 같이 구현했습니다.
- 객체를 스레드-로컬 할당 버퍼(TLAB) 에 추정적으로 복사합니다.
- 비교와 교환(CAS) 연산을 사용하여 포워딩 포인터가 추정 복사본을 가리키도록 업데이트합니다.
- CAS 성공 → 압축 스레드가 경쟁에서 이긴 것이며, 이후 이 객체에 대한 모든 접근은 브룩스 포인터를 통해 새 위치로 이뤄집니다.
- CAS 실패 → 압축 스레드가 졌으므로 추정 복사본을 되돌리고, 승리한 스레드가 남긴 브룩스 포인터를 따라갑니다.
graph TD Start[원본 객체<br/>브룩스 포인터: self] -->|GC: TLAB에 추정 복사| Speculative[복사본 생성] Speculative -->|CAS 시도| Decision{성공?} Decision -->|Yes| Win[GC 승리<br/>브룩스 포인터 → 복사본<br/>이후 모든 접근 redirect] Decision -->|No| Lose[GC 패배<br/>복사본 폐기<br/>승자 포인터 따라감]
동시 모드 실패 = 가비지 생성 속도 따라잡기 경주
셰넌도어는 동시 컬렉터이기 때문에, 수집 주기가 도는 동안 애플리케이션은 새 가비지를 계속 만들어냅니다. 수집이 이 할당 속도를 따라잡지 못하면 동시 모드 실패가 발생합니다. 이는 단순히 IHOP 튜닝의 문제가 아니라 워크로드의 할당률 자체가 컬렉터의 처리 능력을 초과한 상황입니다.
5.4.2 셰넌도어 활성화
-XX:+UseShenandoahGC
대부분의 사용자는 셰넌도어를 사용하기 위해 특별한 추가 설정이 필요하지 않을 가능성이 높습니다. G1·ParallelGC 대비 셰넌도어는 자기 조정 능력이 강하게 설계되었기 때문입니다.
원서 그림 5-7의 지연 시간 분포는 다음 메시지를 전달합니다.
| 컬렉터 | p99 이상 지연 시간 |
|---|---|
| ParallelOld | 99.9% 이상에서 급격히 750ms대까지 상승 |
| G1 | 99.9% 이상에서 500ms대까지 상승 |
| CMS | 600ms 부근에서 정체 |
| 셰넌도어 | 99.999%까지도 거의 평탄, 수십 ms 수준 유지 |
p99까지는 컬렉터 간 차이가 크지 않지만, p99.9 이상의 꼬리 지연(tail latency) 에서 셰넌도어가 압도적입니다. 백엔드 API에서 1,000건당 1건의 응답이 1초 가까이 튀는 것을 막아야 하는 워크로드라면 셰넌도어의 가치가 명확합니다.
5.4.3 셰넌도어의 발전
초기 셰넌도어는 객체 헤더 앞에 음수 오프셋의 추가 워드로 브룩스 포인터를 배치했습니다. 객체당 한 워드의 메모리 오버헤드가 항상 발생한다는 뜻입니다.
이후 추가 메모리 워드를 소비하지 않는 트릭이 도입됩니다. 포워딩 포인터의 사용은 두 가지 측면을 가집니다.
- 현재 객체 버전이 유효하지 않음을 나타냅니다.
- 유효한 객체 버전의 주소를 나타냅니다.
이 두 측면을 분리해 처리하면 마크 워드에서 이전에 사용되지 않던 비트 조합으로 “객체가 포워딩되었음”을 직접 표시할 수 있고, 객체 헤더의 나머지 공간을 포워딩 포인터 자체에 재활용할 수 있게 됩니다. 즉 객체 헤더의 추가 메모리 워드가 더 이상 필요 없어집니다.
이 트릭은 자바 13에서 핫스팟에 로드된 참조 장벽(loaded-reference barriers) 이 도입되면서 성능 균형이 맞춰져 가능해졌습니다. 이후 버전에서 다음과 같은 개선이 이어졌습니다.
| 버전 | 추가된 기능 |
|---|---|
| 자바 14 | 자체 수정 장벽(self-fixing barriers) |
| 자바 14 | 동시 클래스 언로드(concurrent class unloading) |
| 자바 15 | 동시 참조 처리(concurrent reference processing) |
| 자바 17 | 동시 스레드 스택 처리(concurrent thread-stack processing) |
결과적으로 자바 17의 셰넌도어는 프로덕션 준비가 완료된 상태입니다. 다만 셰넌도어는 범용 컬렉터로 간주되지 않으며 대용량 힙에서만 사용하도록 설계되었습니다.
작성 시점 기준 세대별이 아님
셰넌도어는 작성 시점 기준으로 세대별 컬렉터가 아닙니다. 이를 추가하기 위한 시도가 JEP 404 세대별 셰넌도어(실험적)로 진행 중이지만 아직 사용 가능하지 않습니다. 세대별 가설을 활용하지 못한다는 점은 셰넌도어의 잠재적 약점이며, 같은 시기 ZGC가 세대별로 진화한 것과 대비됩니다.
5.5 ZGC
오라클이 셰넌도어와 비슷한 목적으로 개발 중인 컬렉터가 ZGC입니다. 목표는 힙 크기나 메타스페이스 크기에 따라 확장되는 모든 GC 작업을 세이프포인트(STW 단계)에서 제거하고 동시 단계로 이동시키는 것입니다.
ZGC의 사용자 목표
힙 크기 최대 1TB(ZGC는 최대 16TB까지 처리 가능)에서 GC 세이프포인트에 머무는 시간이 1ms를 넘지 않도록 하는 것입니다. 백엔드 관점에서 이는 “어떤 크기의 힙이든 일시 정지는 상수 시간이어야 한다”는 의미입니다.
ZGC는 자바 11에 리눅스/x64용 실험적 컬렉터로 도입된 후 크게 발전했으며, 자바 17·21에서는 다양한 OS에서 완전히 지원됩니다.
이론적 특성
- 동시(concurrent)
- 영역 기반(region-based)
- 압축(compacting)
- 비균일 메모리 접근(NUMA) 인식
- 컬러 포인터(colored pointers) 사용
- 로드 장벽(load barriers) 사용
동시·영역 기반이라는 점에서는 G1·셰넌도어와 유사하며, 동시 참조 처리·동시 스레드 스택 처리 같은 기술은 ZGC 팀이 먼저 구현한 뒤 셰넌도어가 차용했습니다. 기억 집합의 변형도 사용해 영역 간 참조를 추적합니다.
컬러 포인터 — 브룩스 포인터를 대체하는 핵심 기법
ZGC의 중요한 구현 차별점은 브룩스 포인터 대신 컬러 포인터를 사용한다는 것입니다. 컬러 포인터는 객체 포인터(oop) 자체에 객체 수명 주기에 대한 메타데이터를 끼워 넣는 기법입니다.
이 메타데이터는 다음을 나타냅니다.
- 객체가 살아 있는지 여부
- 해당 주소가 정규 사본의 것인지(올바른지)
[ 미사용 16-bit ][ 색상 ][ 객체 주소 44-bit, 16TB ]
0x 00 03 f0 32 40
64비트 객체 포인터
객체 헤더에 추가 워드를 넣는 셰넌도어 초기 방식과 달리, ZGC는 64비트 포인터의 상위 비트를 메타데이터로 사용합니다. 따라서 별도의 헤더 오버헤드 없이 동일한 정보를 표현합니다.
로드 장벽이 하는 일
GC 스레드는 객체를 로드할 때마다 로드 장벽을 만나 컬러 포인터를 확인합니다. 색상이 “예상된 값”이면 그대로 진행하고, 그렇지 않으면 객체 상태를 재해석합니다(예: 포워딩된 객체 추적). 이는 셰넌도어의 브룩스 포인터 + CAS 흐름과 다른 길이지만, 참조를 따라가는 모든 순간에 GC가 개입할 수 있는 훅을 만들어둔다는 발상은 동일합니다.
압축된 oop가 없다
ZGC는 압축된 oop를 사용하지 않으며 컬러 포인터는 항상 64비트입니다. 이를 통해 44비트의 힙 주소 지정이 가능하며, 이는 최대 16TB 크기의 힙에 충분합니다. 32GB 이하 힙에서 핫스팟이 압축 oop로 절약하던 메모리는 ZGC에서는 포기해야 하는 비용입니다.
다중 매핑과 RSS 보고 문제
표준(비세대별) ZGC는 다중 매핑(multi-mapping) 을 사용해 메모리의 각 물리 페이지가 여러 가상 페이지로 참조됩니다. 이 때문에 운영 체제가 보고하는 RSS 값이 실제 힙 사용량을 최대 3배까지 과대 보고할 수 있습니다. 정확한 사용량을 보려면 RSS가 아닌 PSS(Proportional Set Size) 를 봐야 합니다.
컨테이너 메모리 모니터링 함정
쿠버네티스·Datadog·Prometheus의 컨테이너 메모리 메트릭은 대부분 RSS 기반입니다. ZGC를 켠 채로 모니터링하면 OOM 임박처럼 보이지만 실제로는 같은 물리 페이지가 3번 카운트되는 착시입니다. 세대별 ZGC는 이 문제를 해결합니다.
활성화
-XX:+UseZGC
자기 조정 설계
ZGC의 주요 목표 중 하나는 사용자가 컬렉터를 거의 조정할 필요가 없도록 하는 것입니다.
- G1과 같은 초기 힙 점유율 비율(IHOP)이 없으며, 비용 모델(cost model) 을 사용해 언제 컬렉션을 시작할지 결정합니다.
- GC 스레드 풀의 크기 또한 동적으로 변경 가능합니다(컬렉션 중에도 가변).
이는 “어떤 워크로드에서도 손대지 않아도 된다”는 철학의 산물이며, G1의 많은 -XX:G1* 플래그가 ZGC에서 사라진 이유이기도 합니다.
세대별 ZGC — 자바 21의 큰 변화
ZGC는 수년 동안 프로덕션에서 사용되어 왔지만 세대별 컬렉터가 아니라는 잠재적 문제가 있었습니다. 자바 21에서 세대별 ZGC(generational ZGC) 가 도입되어 이 약점을 해결합니다.
비세대별 ZGC는 빠른 젊은 세대 GC로 공간을 회수할 수 없어 할당 지연에 더 취약했습니다. 세대별 ZGC는 다음과 같이 동작합니다.
| 용어 | 대상 |
|---|---|
| 마이너 컬렉션 | 영(young) 객체만 |
| 메이저 컬렉션 | 전체 힙 |
이는 G1의 젊은 세대 GC·혼합 컬렉션 구조와 유사합니다.
세대별 ZGC는 다중 매핑 메모리 대신 메모리 장벽에서의 명시적 코드를 사용해 RSS 과다 보고 문제도 회피합니다. 또한 컬러 포인터 레이아웃이 달라져 12비트의 색상 비트를 사용합니다(비세대별의 4비트와 비교).
[ 미사용 2-bit ][ 객체 주소 46-bit ][ 4개 로드 색상 12-bit ][ 미사용 4-bit ]
0x 00 03 f0 32 40 RRRRMMmmFFrr
64비트 객체 포인터
JVM 스택에 저장된 참조는 무색 포인터로 구현되며, GC 알고리즘이 이를 컬러 포인터로 변환한 뒤 힙에서 사용합니다. 비세대별 ZGC에 비해 복잡성이 상당히 증가했지만, 새로운 최적화 가능성을 열어줍니다. 장기적으로 세대별 ZGC가 비세대별 ZGC를 완전히 대체하는 것이 목표입니다(JEP 439).
세대별 ZGC 활성화
-XX:+UseZGC -XX:+ZGenerational
현재 이전 버전의 ZGC를 사용 중이라면 새 세대별 ZGC로 전환할 것이 권장됩니다.
5.6 Balanced (이클립스 OpenJ9)
OpenJ9는 과거 IBM이 제작한 독점 자바 가상 머신이었으나 몇 년 전 오픈 소스로 전환되었고, 현재 이클립스 오픈 소스 재단이 유지 관리합니다. OpenJ9에는 병렬 컬렉터와 유사한 고처리량 컬렉터(high-throughput collector) 를 포함해 여러 컬렉터를 활성화할 수 있는데, 이 절에서는 Balanced 컬렉터를 다룹니다.
Balanced는 64비트 JVM에서 사용 가능한 영역 기반 컬렉터이며, 4 GB 이상의 힙에 적합하게 설계되었습니다.
주요 설계 목표
- 대규모 자바 힙에서 일시 정지 시간의 확장성 개선
- 최악의 경우 일시 정지 시간 최소화
- 비균일 메모리 접근(NUMA) 성능을 고려한 최적화 활용
영역과 세대 구조
힙을 여러 영역으로 분할해 독립적으로 관리·수집합니다. G1과 마찬가지로 최대 2,048개의 영역을 관리하려 하며, 영역 크기는 G1처럼 2의 거듭제곱이지만 Balanced는 최소 512 KB 크기의 영역도 허용합니다(G1보다 더 작은 영역 가능).
세대별 영역 기반 컬렉터답게 각 영역에는 연관된 연령이 있으며, 새 객체 할당은 연령 0(에덴) 영역에서 이루어집니다. 에덴이 가득 차면 부분 가비지 컬렉션(partial garbage collection, PGC) 이 수행됩니다.
부분 GC vs 전역 마크 vs 전역 GC
| 작업 유형 | STW 여부 | 역할 |
|---|---|---|
| 부분 GC (PGC) | STW | 모든 에덴 영역을 수집, 가치 있는 경우 더 높은 연령 영역도 추가 수집 (G1 혼합 컬렉션과 유사) |
| 전역 마크 단계 (GMP) | 부분 동시 | 자바 힙 전체를 스캔해 죽은 객체를 표시. 이후 PGC가 이 데이터로 작업 |
| 전역 컬렉션 | STW | 힙을 압축하는 전체 STW 작업. 핫스팟의 동시 모드 실패 시 전체 수집과 유사 |
부분 GC의 연령 증가
부분 GC가 완료되면 생존 객체를 포함하는 영역의 연령이 1 증가합니다. 이러한 영역을 가끔 세대별 영역이라고 부르기도 합니다.
부분 GC는 선택한 영역만 보기 때문에 부유 가비지 문제를 겪을 수 있습니다. 이를 해결하기 위해 Balanced는 자바 힙 전체를 스캔하는 GMP를 주기적으로 수행하며, 부유 가비지 양은 마지막 GMP 시작 이후 사라진 객체 수로 제한됩니다.
점진적 클래스 언로드
다른 OpenJ9 GC 정책과 비교했을 때 Balanced의 또 다른 이점은 클래스 언로드가 점진적으로 수행될 수 있다는 점입니다. 다른 OpenJ9 GC는 전역 컬렉션 중에만 클래스 로더를 수집할 수 있는 데 반해, Balanced는 부분 GC 중에도 현재 컬렉션 세트에 포함된 클래스 로더를 수집할 수 있습니다.
5.6.1 OpenJ9 객체 헤더
OpenJ9의 기본 객체 헤더는 클래스 슬롯(class slot) 이며, 크기는 64비트 또는 32비트입니다. 압축 참조(compressed reference)가 활성화된 경우 32비트로 줄어듭니다.
압축 참조는 57 GB 미만 힙에서 기본값
57 GB 미만의 힙에 대해 압축 참조가 기본적으로 사용되며, 핫스팟의 압축된 oop 기술과 유사합니다.
추가 슬롯
| 객체 종류 | 추가 슬롯 |
|---|---|
| 동기화된 객체 | 모니터 슬롯(monitor slot) |
| 내부 JVM 구조에 포함된 객체 | 해시 슬롯(hashed slot) |
모니터 슬롯과 해시 슬롯은 반드시 객체 헤더와 인접해 있을 필요가 없으며, 정렬로 낭비될 수 있는 공간을 활용해 객체 내 어디에나 저장될 수 있습니다.
핫스팟과의 레이아웃 비교
표준 핫스팟 (64비트):
[ 마크 0x...0266 ][ klass 0x...24a0 ][ 값 ]
OpenJ9 (64비트):
[ class pointer (24~56-bit) | flags (8-bit) ][ 값 ]
클래스 슬롯의 가장 높은 24비트(또는 56비트)는 클래스 구조체를 가리키는 포인터로, 자바의 메타스페이스와 유사하게 힙 외부에 존재합니다. 하위 8비트는 사용 중인 GC 정책에 따라 다양한 목적으로 사용되는 플래그입니다.
5.6.2 Balanced에서의 거대 배열
자바에서 거대 배열을 할당하는 것은 압축 컬렉션을 트리거하는 일반적인 원인입니다. 할당을 만족시키려면 충분한 연속 공간을 찾아야 하기 때문이며, G1에서 거대 객체가 단편화와 동시 모드 실패의 원인이 되는 것도 같은 맥락입니다.
어레이릿 (arraylet)
영역 기반 컬렉터의 경우 단일 영역의 크기를 초과하는 배열 객체를 할당해야 할 수 있습니다. Balanced는 이를 해결하기 위해 어레이릿(arraylet) 이라는 대체 표현 방식을 사용합니다. 이는 거대 배열을 불연속 조각으로 할당하는 기법이며, 힙 객체가 영역을 넘나드는 유일한 경우입니다.
어레이릿은 사용자 자바 코드에서는 보이지 않으며 JVM이 투명하게 처리합니다. 할당자는 거대 배열을 스파인(spine) 과 배열 리프(array leaves) 세트로 나타냅니다.
- 리프: 배열의 실제 항목을 포함
- 스파인: 각 리프를 가리키는 항목들의 집합
graph TD Spine["스파인 [10] [.] [.] [.]"] Spine --> Leaf1["리프1: i, g, d"] Spine --> Leaf2["리프2: x, d, q, a"] Spine --> Leaf3["리프3: b, x, h"]
각 항목을 읽을 때 단일 간접 참조의 추가 오버헤드만 발생합니다.
JNI 코드 포팅 시 주의
어레이릿은 자바 네이티브 인터페이스(JNI) API를 통해 잠재적으로 볼 수 있습니다(일반적인 자바에서는 볼 수 없음). 따라서 다른 자바 가상 머신에서 JNI 코드를 OpenJ9으로 포팅할 때 스파인과 리프 표현 방식을 고려해야 합니다.
트레이드오프
영역에서 부분 GC를 수행하면 평균 일시 정지 시간이 줄어들지만, 스파인·리프 정보를 유지하는 오버헤드 때문에 전체 GC 작업에 소요되는 시간은 더 길어질 수 있습니다. 중요한 점은, 힙이 가득 찼을 때 마지막 수단으로 전체 STW 수집이나 압축을 수행하는 경우를 제외하고는 최악의 경우 일시 정지 시간이 필요할 가능성이 크게 줄어든다는 것입니다.
Balanced는 전체 처리량보다 큰 일시 정지 시간을 피하는 것이 더 중요한 애플리케이션에 적합합니다.
5.6.3 비균일 메모리 접근과 Balanced
비균일 메모리 접근(NUMA) 은 멀티프로세서 시스템(주로 중대형 서버)에서 사용되는 메모리 아키텍처입니다. 메모리와 프로세서 사이의 거리 개념이 존재하며, 프로세서와 메모리가 노드 단위로 구성됩니다. 특정 노드의 프로세서는 어느 노드의 메모리에도 접근할 수 있지만, 로컬 메모리(같은 노드) 에 대한 접근 시간이 훨씬 빠릅니다.
graph TD subgraph Node1["NUMA 노드 1"] CPU1[CPU] Mem1[로컬 메모리<br/>빠른 접근] end subgraph Node2["NUMA 노드 2"] CPU2[CPU] Mem2[로컬 메모리] end CPU1 -.->|상호 연결<br/>느린 원격 접근| Mem2 CPU2 -.->|상호 연결<br/>느린 원격 접근| Mem1
여러 NUMA 노드에서 실행되는 JVM의 경우, Balanced 컬렉터는 자바 힙을 각 노드에 분할할 수 있습니다. 애플리케이션 스레드는 특정 노드에서 실행하도록 배열되며, 객체 할당은 해당 노드의 로컬 메모리 내 영역을 선호합니다.
또한 부분 GC는 객체와 이를 참조하는 스레드에 더 가까운 메모리로 객체를 이동시키려고 시도합니다. 이는 스레드가 참조하는 메모리가 로컬일 가능성을 높여 성능을 개선합니다. 이 과정은 애플리케이션에는 보이지 않게 처리됩니다.
5.7 니치 핫스팟 컬렉터
이전 버전의 핫스팟에서는 다양한 다른 컬렉터를 사용할 수 있었습니다. 대부분은 제거되었지만, 자바 21 기준으로 두 가지 특수 컬렉터가 여전히 존재합니다. 완전성을 위해 언급하지만, 어느 것도 실무 환경에서 사용하기를 권장하지 않습니다.
5.7.1 동시 마크 스윕 (CMS)
동시 마크 스윕(CMS) 컬렉터는 매우 짧은 일시 정지 시간을 목표로 영구 세대 공간만 수집하도록 설계된 컬렉터였습니다. 젊은 세대 수집을 위해서는 병렬 컬렉터 대신 ParNew라는 약간 수정된 병렬 컬렉터와 함께 사용되었습니다.
버전별 상태
| 자바 버전 | 상태 |
|---|---|
| 자바 8 | 존재, 특정 워크로드에서 G1보다 뛰어남 |
| 자바 9 | 사용 중단(deprecated) |
| 자바 11 | 존재하나 G1이 크게 개선되어 CMS가 최선인 경우 드묾 |
| 자바 14+ | 더 이상 사용 불가 |
활성화 플래그는 -XX:+UseConcMarkSweepGC였습니다.
CMS의 약한 고리 — 동시 마크 스윕 중 에덴이 가득 차면?
CMS는 G1의 혼합 수집과 대체로 유사한 단계 구조를 가집니다. 따라서 중요한 질문은 이것입니다 — 동시 마크 스윕이 실행 중일 때 에덴이 가득 차면 어떻게 될까요?
답은 예상대로입니다. 애플리케이션 스레드가 계속 진행할 수 없기 때문에 일시 정지 상태가 되고, 동시 마크 스윕이 실행 중인 동안 젊은 세대 GC가 실행됩니다. 이 경우 병렬 컬렉터에서보다 젊은 세대 GC가 더 오래 걸리는 경우가 많습니다. 젊은 세대 GC가 사용할 수 있는 코어가 절반밖에 없기 때문입니다(나머지 절반은 동시 마크 스윕을 실행 중).
최악의 시나리오: 조기 승격과 동시 모드 실패
정상적인 상황에서는 젊은 세대 GC가 영구 세대로 소량의 객체만 승격하고, 동시 마크 스윕 영구 세대 수집이 정상적으로 완료되어 공간을 확보합니다. 이후 애플리케이션은 모든 코어가 다시 애플리케이션 스레드에 할당되며 정상 처리를 재개합니다.
하지만 매우 높은 할당률이 있는 경우 조기 승격이 발생할 수 있습니다. 젊은 세대 GC에서 승격할 객체가 너무 많아 영구 세대의 가용 공간을 초과하는 상황입니다.
이는 동시 모드 실패의 한 형태로, 이 시점에서 JVM은 전적으로 STW 방식으로 작동하는 ParallelOld 컬렉션을 실행할 수밖에 없습니다. 실질적으로는 할당 압력이 매우 높아 동시 마크 스윕이 영구 세대 처리를 완료하기 전에 새롭게 승격된 객체를 수용하기 위한 ‘여유 공간(headroom)’ 이 모두 소진된 상황입니다.
잦은 동시 모드 실패를 피하려면 동시 마크 스윕은 영구 세대가 완전히 채워지기 전에 컬렉션 주기를 시작해야 합니다. 이는 G1의 초기 힙 점유율 비율(IHOP) 동작과 유사한 원리입니다.
5.7.2 엡실론 (Epsilon)
엡실론 컬렉터는 레거시 컬렉터가 아닙니다. 하지만 어떤 경우에도 실무 환경에서 사용해서는 안 되기 때문에 여기서 언급됩니다.
엡실론은 실험용 컬렉터로 테스트 목적으로만 설계되었습니다. 이는 제로-이펙트 컬렉터(zero-effort collector) 입니다. 즉 실제로 가비지를 수집하려는 시도조차 하지 않습니다. 엡실론에서 실행되는 동안 할당된 모든 힙 메모리 바이트는 효과적으로 메모리 누수로 간주됩니다. 이 메모리는 회수될 수 없으며, 결국 매우 빠르게 자바 가상 머신이 메모리 부족으로 충돌하게 될 가능성이 높습니다.
JEP 318: 엡실론 - 작업이 없는(No-Op) 가비지 컬렉터
메모리 할당만 처리하고 실제 메모리 회수 메커니즘은 구현하지 않는 가비지 컬렉터를 개발합니다. 자바 힙 메모리가 모두 소진되면, 자바 가상 머신을 정리된 방식으로 종료합니다.
그래서 어디에 쓰는가?
- 성능 테스트 또는 마이크로벤치마크 (특히 JMH(java microbenchmark harness)) — GC 이벤트가 성능 수치를 방해하지 않도록 보장
- 저하 테스트 — 메모리 할당 동작이 코드 변경 이후에도 크게 변하지 않았는지 확인
- 낮은/제로 할당의 자바 애플리케이션 또는 라이브러리 코드 테스트 — 엡실론 구성으로 할당 횟수가 제한된 테스트를 작성하고, 힙 고갈로 추가 할당이 실패하도록 만들 수 있음
- 제안된 VM-GC 인터페이스의 최소 테스트 케이스 — 인터페이스 자체를 테스트하는 데 활용
벤치마크 환경에서 “GC 노이즈를 완전히 제거한 상태”의 측정값이 필요할 때 가치를 발휘하는 도구이지, 운영 환경의 옵션이 아닙니다.
5.8 요약
자바 GC 생태계는 풍부하지만 각 컬렉터의 절충점이 문서화되지 않아 선택이 어렵습니다. 5장의 메시지는 결국 두 가지입니다.
- 모든 워크로드를 만족시키는 단일 컬렉터는 없다. 일시 정지·처리량·메모리는 서로 트레이드오프 관계입니다.
- 동시화의 핵심은 메타데이터. 세이프포인트·삼색 마킹·SATB·브룩스 포인터·컬러 포인터는 모두 “변경자와 마커의 충돌을 어떻게 해결할 것인가”라는 같은 질문에 대한 다른 답입니다.
이 이론을 실제로 적용하려면 로그·모니터링·도구가 필요하고, 이는 다음 챕터로 이어집니다.
비교 / 트레이드오프
핫스팟 컬렉터 진화
graph LR Serial[Serial<br/>단일 코어 STW] --> Parallel[ParallelGC<br/>멀티 코어 STW] Parallel --> CMS[CMS<br/>자바 14 제거] CMS --> G1[G1<br/>자바 9 이후 기본] G1 --> Modern[Shenandoah / ZGC<br/>수 ms 이하 일시 정지]
컬렉터별 한눈 비교
| 컬렉터 | 일시 정지 | 처리량 | 메모리 | 적합한 곳 |
|---|---|---|---|---|
| ParallelGC | 힙 크기 비례 | 가장 높음 | 가장 적음 | 배치 / 대규모 병렬 |
| G1 | 목표 200ms | 중간 | RSet 오버헤드 | 일반 백엔드 (기본값) |
| Shenandoah | 수 ms 이하 | 다소 낮음 | 브룩스 포인터 | 대용량 힙·꼬리 지연 |
| ZGC | 1ms 목표 | 다소 낮음 | 컬러 포인터(압축 oop 없음) | 1TB 힙까지 |
| Balanced (OpenJ9) | 짧음 | 중간 | 어레이릿 메타데이터 | NUMA 서버 |
셰넌도어 vs ZGC — 같은 목적, 다른 길
메타데이터를 어디에 두느냐가 두 컬렉터를 가르는 본질입니다.
| 항목 | 셰넌도어 | ZGC |
|---|---|---|
| 포워딩 정보 위치 | 객체 헤더 (브룩스 포인터 → 마크 워드 통합) | 포인터 상위 비트 (컬러 포인터) |
| 압축 oop | 사용 가능 | 사용 불가 (항상 64비트) |
| 세대별 | 미지원 (JEP 404 진행) | 자바 21 세대별 ZGC 도입 |
내 생각
-
G1이 기본이 된 것은 필연. 8~16GB 힙 API 서버에서 ParallelGC의 전체 STW는 SLA를 즉시 깨므로, 자바 11 이후 백엔드에서 ParallelGC를 일부러 선택할 이유는 거의 없습니다.
-
거대 객체는 백엔드의 숨은 함정. 응답 JSON을 통째로 byte 배열로 만들거나 큰 파일을 한 번에 읽는 코드는 영구 세대 단편화의 원인이 됩니다. G1 환경에서 전체 GC가 갑자기 늘었다면 거대 객체 할당부터 의심합니다.
-
MaxGCPauseMillis는 “목표”이지 마법이 아님. 200ms를 50ms로 낮춘다고 STW가 짧아지지 않으며, 젊은 세대만 작아져 GC 빈도가 폭증합니다. 진짜 짧은 일시 정지가 필요하면 G1 튜닝이 아니라 ZGC/Shenandoah로 갈아탑니다.
-
셰넌도어 vs ZGC = 메타데이터를 어디에 두느냐. 셰넌도어는 객체 헤더(압축 oop 호환), ZGC는 포인터 비트(압축 oop 포기, 헤더 깨끗). 32GB 이하 힙에서는 ZGC의 압축 oop 손실 비용이 작지 않습니다.
-
ZGC 켜고 RSS가 3배가 되어도 놀라지 말 것. 비세대별 ZGC의 다중 매핑 때문이며 실제 사용량이 아닙니다. 쿠버네티스 메모리 한계를 RSS 기준으로 잡으면 OOMKilled가 빈발하므로 PSS를 보거나 세대별 ZGC로 전환합니다.
-
엡실론은 JMH의 비밀 무기. 핫 루프를 엡실론에서 돌려 OOM이 안 나면 진짜 할당-프리 코드입니다. 의도치 않은 박싱·iterator·문자열 연결을 잡아내는 가장 정직한 방법입니다.
더 알아볼 것
-
-Xlog:gc*=debug,safepoint=info로 G1 4단계의 STW 시간 직접 측정 -
-XX:G1HeapRegionSize변경이 거대 객체 비율에 미치는 영향 실험 - 셰넌도어 활성화 후 p99.9·p99.99 지연 시간 직접 측정 (k6/wrk2 + GC 로그)
- ZGC의 다중 매핑이 RSS와 PSS에 미치는 차이를
/proc/[pid]/smaps_rollup으로 확인 - 자바 21 세대별 ZGC 전환 시 마이너/메이저 컬렉션 빈도 비교
- 엡실론(
-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC) 위에서 JMH로 할당-프리 검증
관련 개념
출처
- 벤 에반스, 제임스 고프, 크리스 뉴랜드. 『자바 최적화 2판』. 한빛미디어. Ch05 고급 가비지 컬렉션 (5.1 ~ 5.8).
- JEP 318: Epsilon - A No-Op Garbage Collector
- JEP 404: Generational Shenandoah (Experimental)
- JEP 439: Generational ZGC
- JDK 13: 셰넌도어 가비지 컬렉션 Part 1
- JDK 13: 셰넌도어 가비지 컬렉션 Part 2