개요 챕터에서 "GC는 STW를 줄이는 역사"라고 배웠죠. 이번 딥다이브는 그 안쪽으로 들어갑니다. G1의 region·RSet·SATB·Mixed GC·정지시간 예측, ZGC의 컬러드 포인터·load barrier, 그리고 GC 로그를 읽고 실전 튜닝하는 법 — 장애 대응과 성능 공학의 핵심입니다.
🎯 이 장을 끝내면
G1의 region 구조(Eden/Survivor/Old/Humongous)와 Remembered Set·카드 테이블·write barrier가 왜 필요한지 설명해요.
SATB 동시 마킹, evacuation(대피) pause, Mixed GC, 정지시간 예측 모델의 동작을 이해해요.
ZGC의 컬러드 포인터·load barrier·동시 재배치가 어떻게 밀리초 이하 STW를 만드는지 알아요.
전통 GC는 힙을 Young / Old로 물리적으로 크게 나눴어요. G1은 다릅니다. 힙을 균등한 크기의 region(보통 1~32MB, 2의 거듭제곱)으로 잘게 쪼개고, 각 region에 역할을 동적으로 부여해요. 같은 region이 이번엔 Eden, 회수 후엔 Old가 될 수 있죠.
🥚
Eden region
새 객체 할당
🐣
Survivor region
young 생존자
🦉
Old region
승격된 객체
🐘
Humongous
region 50%↑ 대형 객체
🐘 Humongous 객체 — region 크기의 50%를 넘으면
한 객체가 region 크기의 50%를 초과하면 G1은 이를 Humongous 객체로 취급하고, 연속된 여러 Old region에 걸쳐 Humongous region으로 통째로 할당해요. 예: region이 2MB면 1MB 초과 배열·객체가 여기 해당됩니다.
Humongous는 일반 young 경로를 타지 않고 곧장 Old 쪽에 잡히며, 회수도 까다로워요(연속 region 필요 → 단편화 유발). 거대한 byte[]·문자열을 남발하면 Humongous allocation이 늘고, 이게 잦으면 -XX:G1HeapRegionSize를 키워 "50% 기준"을 올려 완화하기도 합니다.
💡
Region size는 기본 자동. G1은 -Xmx 기준 힙을 약 2048개 region으로 나누도록 크기를 자동 결정해요(1~32MB로 clamp). 필요하면 -XX:G1HeapRegionSize=16m처럼 명시합니다. Humongous가 많을 때 region을 키우는 게 대표적 튜닝 포인트예요.
🖼️ 그림으로 보기 — G1 region 힙 (역할은 동적)
🔗
G1 내부 ②
RSet·카드 테이블·SATB — 부분 수집의 비밀
"일부 region만" 수집하려면 바깥에서 들어오는 참조를 알아야 해요.
G1은 힙 전체가 아니라 선택한 region 집합(collection set)만 회수해요. 그런데 회수 대상 region의 객체가 다른 region에서 참조되고 있으면 살려야 하죠. 이 "바깥에서 들어오는 참조(incoming references)"를 추적하는 자료구조가 Remembered Set(RSet)입니다.
구성요소
역할
핵심 포인트
Remembered Set (RSet)
region마다 하나. "어떤 다른 region이 나를 가리키는가"(incoming) 기록
덕분에 그 region만 수집할 때 전체 힙 스캔 없이 루트를 찾음
카드 테이블(Card Table)
힙을 512B 카드로 나눠, 참조가 바뀐 카드를 dirty로 표시
RSet 갱신 대상을 카드 단위로 좁혀줌
Write barrier
참조 필드 쓰기(a.f = b)마다 삽입되는 짧은 코드. 카드를 dirty로 찍고 RSet 갱신 큐에 넣음
세대/region 간 참조를 실시간 포착. 공짜가 아님(약간의 오버헤드)
🧭
왜 "outgoing"이 아니라 "incoming"을 기록할까? region X를 수집할 때 필요한 건 "X 바깥에서 X를 가리키는 참조"예요. 그래야 그것들을 살아있는 루트로 보고 X의 객체를 살릴지 판단하죠. 그래서 RSet은 points-into(들어오는 참조)를 저장합니다. Young region은 항상 수집 대상이라 RSet을 유지하지 않고, Old→Young 및 Old→Old 참조가 주 관리 대상이에요.
📸 SATB — Snapshot-At-The-Beginning 동시 마킹
G1의 동시 마킹은 SATB 방식이에요. 마킹 시작 시점의 객체 그래프를 논리적 스냅샷으로 고정하고, 그 스냅샷 기준으로 "그때 살아있던" 객체를 모두 살아있다고 봅니다.
마킹 도중 애플리케이션이 참조를 덮어쓰면(a.f = null 등), SATB write barrier가 덮어쓰기 직전의 옛 참조값을 마킹 큐에 넣어둬요. 그래서 "마킹 시작 땐 살아있었는데 중간에 끊긴" 객체를 놓치지 않죠. 대가로 이번 사이클에 죽은 객체 일부가 살아남는(floating garbage) 보수적 오차가 생기지만, 다음 사이클에 회수됩니다. (ZGC·Shenandoah 계열은 참조를 읽을 때 개입하는 read/load barrier를 쓰는 것과 대비돼요.)
⚠️
RSet도 공짜가 아니에요. region이 많고 region 간 참조가 촘촘하면 RSet 유지·스캔 비용(메모리·CPU)이 커져요. Old→Old 참조가 많은 워크로드에서 pause가 튀면, GC 로그의 "Scan RS"·"Update RS" 시간을 확인하세요. 이게 병목이면 region size·힙 크기 조정으로 참조 밀도를 낮추는 방향을 검토합니다.
🎯
G1 내부 ③
Evacuation·Mixed GC·정지시간 예측
MaxGCPauseMillis는 "목표"지 "보장"이 아니에요.
G1의 회수는 evacuation(대피) 방식이에요. 수집 대상 region에서 살아있는 객체만 빈 region으로 복사하고, 원래 region은 통째로 비웁니다. 이 복사 단계에서 STW pause(evacuation pause)가 발생해요 — 다만 대상이 "일부 region"이라 짧죠.
🌱
Young-only GC
Eden·Survivor만 대피
→
🔄
동시 마킹 사이클
IHOP 초과 시 SATB 마킹
→
♻️
Mixed GC
young + 쓰레기 많은 old 일부
♻️ Mixed GC — young과 old를 "섞어서" 수집
동시 마킹으로 "어떤 Old region이 쓰레기가 많은지" 파악되면, G1은 Mixed GC를 돌려요. 이름대로 모든 young region + 회수 효율이 높은 old region 일부를 함께 대피시킵니다.
핵심은 "old를 한 번에 다 하지 않는다"예요. 정지시간 목표를 지키려고 여러 번의 Mixed GC에 나눠서 old를 조금씩 회수하죠. 그래서 CMS 같은 별도의 무거운 "old 전용 대형 수집"이 없고, Full GC로 떨어지지 않는 한 pause가 완만하게 유지돼요.
💡
정지시간 예측 모델(pause prediction). G1은 과거 GC들의 region당 대피 비용·스캔 시간 등을 통계로 학습해요. 그리고 다음 GC에서 -XX:MaxGCPauseMillis(기본 200ms) 목표 안에 들어올 만큼만 collection set에 region을 담습니다. 즉 "목표 시간에 맞춰 이번에 몇 개 region을 회수할지"를 역산하는 구조예요.
⚠️
MaxGCPauseMillis는 하드 리밋이 아니에요. 이건 목표(soft target)일 뿐, JVM이 반드시 지킨다고 보장하지 않아요. 너무 작게(예: 10ms) 주면 GC가 매번 아주 적은 region만 회수해 빈도가 급증하고, allocation을 못 따라가면 오히려 Full GC로 붕괴할 수 있어요. 처리량과 지연의 트레이드오프를 로그로 확인하며 조정해야 합니다.
🚨 IHOP — 동시 마킹을 언제 시작할까-XX:InitiatingHeapOccupancyPercent(IHOP, 기본 45%)는 Old 점유율이 전체 힙의 이 비율을 넘으면 동시 마킹 사이클을 시작하라는 임계값이에요. 마킹→Mixed GC가 제때 돌아 old를 비워야 하죠.
Java 9+에서는 기본이 Adaptive IHOP라, 런타임 통계로 이 값을 자동 조정해요. 마킹이 너무 늦게 시작돼 old가 가득 차버리면 → 대피할 빈 region이 없어 → evacuation failure → 최악엔 Full GC. 반대로 너무 일찍 시작하면 마킹 오버헤드가 늘어요. -XX:-G1UseAdaptiveIHOP로 고정값을 강제할 수도 있습니다.
🧭
G1에서 Full GC = 실패 신호. G1의 정상 경로는 young/mixed의 동시·부분 수집이에요. Full GC(전체 STW 압축)가 발생했다면 그건 정상 동작이 아니라 "동시 수집이 allocation을 못 따라가 무너졌다"는 경보예요. 원인은 대개 힙 부족·IHOP 지각·Humongous 폭주·과도한 promotion. Full GC 발생 자체를 지표로 삼아 튜닝의 출발점으로 쓰세요. (Java 10부터 Full GC도 병렬화됐지만, "일어났다는 사실"이 문제인 건 여전해요.)
🎨
ZGC 내부
컬러드 포인터와 Load Barrier
"객체를 옮기면서도 앱을 멈추지 않는" 마법의 정체.
ZGC의 목표는 힙 크기와 무관하게 밀리초 이하 STW예요. 비결은 마킹·재배치 같은 무거운 작업을 거의 전부 애플리케이션과 동시에 수행하는 것. 그 핵심 장치가 컬러드 포인터(colored pointers)와 load barrier입니다.
🎨 컬러드 포인터 — 주소 안에 메타데이터를 심는다
64비트 포인터의 사용되지 않는 상위 비트에 색(color) 태그(Marked0/Marked1/Remapped 등)를 넣어요. 즉 객체가 아니라 참조(포인터) 자체에 "이 참조는 마킹됐나? 재배치됐나?" 상태를 기록합니다.
덕분에 GC 상태 정보를 객체 헤더에 기록할 필요가 없고, 포인터만 봐도 처리 여부를 O(1)로 판단해요. (초기 ZGC는 다중 가상 매핑을 썼고, 최신 세대별 ZGC는 이 아이디어를 발전시킨 형태입니다.)
🚧 Load Barrier — 참조를 "읽을 때" 고쳐준다
애플리케이션이 힙에서 참조를 로드(읽기)할 때마다 짧은 load barrier 코드가 실행돼요. 포인터의 색을 검사해서, 만약 이 객체가 이미 다른 곳으로 재배치(relocate)됐다면 → 그 자리에서 새 주소로 포인터를 고쳐(remap) 반환하고, 마킹이 필요하면 마킹합니다.
이게 동시 재배치(concurrent relocation)의 핵심이에요. GC가 객체를 옮기는 도중에 앱이 옛 주소를 읽어도, load barrier가 실시간으로 최신 주소로 교정하니 "멈추지 않고 옮기기"가 가능하죠. G1의 쓰기 시점 barrier(SATB)와 달리, ZGC는 읽기 시점 barrier라는 점이 대조적이에요.
항목
ZGC
STW 목표
대개 1ms 미만, 힙 크기·라이브셋과 거의 무관(STW는 루트 스캔 등 상수 작업만)
region(ZPage)
Small / Medium / Large 3종. Large는 단일 대형 객체 전용
barrier
Load barrier(읽기 시점) 기반 동시 마킹·재배치
세대별 ZGC
Java 21에서 도입(-XX:+UseZGC -XX:+ZGenerational). young/old를 분리해 처리량·효율 개선
선택 플래그
-XX:+UseZGC. Java 15부터 정식(production ready)
💡
왜 세대별 ZGC(Java 21)가 나왔나. 초기 non-generational ZGC는 지연은 훌륭했지만 매번 힙 전체를 마킹해 CPU·메모리 오버헤드가 컸어요. 세대별 ZGC는 "대부분 금방 죽는다"는 세대 가설을 다시 도입해 young을 자주·싸게 돌리니, 같은 지연을 유지하면서 처리량과 힙 여유(allocation stall 감소)가 좋아졌습니다. Java 23부터는 세대별이 기본이고 non-generational은 폐기 수순이에요.
📋
GC 로그 읽기
로그가 곧 진실 — 원인·비율을 읽어내기
Java 9+ 통합 로깅 -Xlog로 무엇을 보나.
Java 9부터 GC 로그는 통합 로깅(-Xlog)으로 바뀌었어요. 가장 흔한 설정은 이렇게 파일·태그·데코레이터를 지정합니다.
# 상세 GC 로그를 시간·업타임과 함께 파일로 (로테이션 포함)
-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=10m
# 간단 버전
-Xlog:gc # 사이클 요약만
-Xlog:gc* # 세부 단계(phase)까지 (* = 하위 태그 전부)
G1 로그의 한 줄에는 왜 이 GC가 발생했는지(cause)가 대괄호로 찍혀요. 이 원인 분류가 진단의 출발점이에요.
로그에 보이는 원인
의미 / 해석
Pause Young (Normal) (G1 Evacuation Pause)
Eden이 차서 도는 정상적인 young 대피. 가장 흔함
Pause Young (Concurrent Start) (...)
IHOP 초과 → 동시 마킹 사이클 시작과 함께 도는 young GC
Pause Young (Mixed) (G1 Evacuation Pause)
Mixed GC — young + old 일부 회수 중
Pause Young (... ) (G1 Humongous Allocation)
Humongous 할당이 GC를 유발. 잦으면 대형 객체·region size 점검
Pause Full (G1 Evacuation Pause / Allocation Failure)
Full GC — 실패 신호. 힙·IHOP·humongous·promotion 조사
📈 두 가지 핵심 "비율"을 계산하라Allocation rate(할당 속도) — 단위 시간당 Eden에 새로 할당되는 양(MB/s). 연속된 young GC 사이 Eden 사용량 ÷ 간격 시간으로 추정해요. 높을수록 young GC가 자주 돕니다. Promotion rate(승격 속도) — 단위 시간당 Old로 승격되는 양. young GC 전후 Old 사용량 증가분으로 봐요. 높으면 Old가 빨리 차 Mixed/Full GC 압박·evacuation failure 위험이 커집니다.
진단 공식: 지연 스파이크 = pause 시간을 보고, 근본 원인 = allocation/promotion rate를 본다. pause를 줄이려다 실패하면, 대개 진짜 문제는 "너무 빠른 할당·승격"이에요. GCeasy·GCViewer 같은 도구가 이 비율을 자동 산출해줍니다.
🧭
"To-space exhausted" / "Evacuation Failure" 문구가 로그에 보이면 위험 신호예요. 대피시킬 빈 region이 부족하다는 뜻으로, promotion이 과하거나 힙이 작을 때 나타나며 Full GC의 전조입니다. GC 로그에서 이 문자열을 grep 하는 습관을 들이세요.
🔧
튜닝 실전
추측하지 말고, 로그로 조정하라
목표를 먼저 정하고 → 지표를 보고 → 하나씩 바꾼다.
① 목표를 먼저 못 박는다 — 처리량 vs 지연처리량(throughput) 우선: 배치·데이터 파이프라인처럼 총 처리량이 중요하고 가끔의 긴 pause가 허용되면 Parallel GC도 여전히 유효해요. 지연(latency) 우선: API 서버처럼 p99 응답이 생명이면 G1(범용) 또는 ZGC(수십GB↑·초저지연)를 씁니다.
둘 다 100%는 불가능해요. "우리 서비스의 SLO(예: p99 < 100ms)"를 숫자로 정해야 튜닝 성공/실패를 판정할 수 있어요.
레버
플래그 / 방법
언제·왜
힙 사이징
-Xms = -Xmx
운영은 둘을 같게. 런타임 힙 리사이즈로 인한 변동·지연 제거
정지 목표
-XX:MaxGCPauseMillis
지연 SLO에 맞춰. 너무 낮추면 빈도↑·Full GC 위험 — 과욕 금지
마킹 시작 시점
-XX:InitiatingHeapOccupancyPercent(IHOP)
evacuation failure·늦은 마킹이면 낮춰서 마킹을 앞당김
region 크기
-XX:G1HeapRegionSize
Humongous가 잦으면 키워서 "50% 기준"을 올림
컬렉터 교체
-XX:+UseZGC 등
G1로 p99가 안 잡히고 힙이 크면 ZGC 검토
💡
Allocation rate·Humongous를 "줄이는" 코드 튜닝이 플래그보다 강력할 때가 많아요. 불필요한 임시 객체·오토박싱·거대 배열 재할당을 줄이면 young GC 빈도와 Humongous allocation이 함께 내려가요. GC 플래그로 증상을 누르기 전에, 할당 프로파일(async-profiler의 alloc 모드 등)로 "누가 많이 할당하나"를 먼저 보세요.
⚠️
튜닝 안티패턴. ① 근거 없이 여러 플래그를 한꺼번에 바꾸기(무엇이 효과였는지 알 수 없음) → 한 번에 하나씩. ② MaxGCPauseMillis를 극단적으로 낮게 잡기 → Full GC 유발. ③ -XX:NewRatio·SurvivorRatio로 G1의 young을 수동 고정 → G1의 적응형 크기 조정을 방해(대개 G1에선 만지지 않는 게 정석). ④ 로그 없이 "감"으로 조정. 측정 → 가설 → 한 변수 변경 → 재측정이 유일한 정도(正道)예요.
🧪 실전 워크플로 요약1) SLO를 숫자로 정한다(p99, 허용 pause, 처리량). 2)-Xlog:gc*로 로그를 켜고 대표 부하를 재현한다. 3) pause 분포·원인·allocation/promotion rate·Full GC 유무를 읽는다. 4) 가설을 세워 한 변수만 바꾸고 재측정한다. 5) 코드 레벨(할당 감소)과 플래그 레벨을 함께 고려한다.
"추측 말고 GC 로그로" — 이 장 전체를 한 문장으로 줄이면 이겁니다.
🧠 이 장 핵심 요약
G1은 힙을 균등 region으로 쪼개고 역할(Eden/Survivor/Old/Humongous)을 동적 배정. Humongous = region 50% 초과 객체.
RSet·카드 테이블·write barrier로 region 간(들어오는) 참조를 추적 → 부분 수집이 가능. 동시 마킹은 SATB.
회수는 evacuation(대피 복사=압축). Mixed GC로 old를 여러 번 나눠 회수. MaxGCPauseMillis는 예측 모델의 목표일 뿐 보장 아님. IHOP는 동시 마킹 시작 임계값. Full GC = 실패 신호.
ZGC는 컬러드 포인터 + load barrier(읽기 시점)로 동시 재배치 → 밀리초 이하 STW. Java 21 세대별 ZGC.
로그(-Xlog:gc*)에서 pause 원인 + allocation/promotion rate를 읽는다. Evacuation failure는 위험 신호.
튜닝: 목표(SLO)를 먼저 → -Xms=-Xmx, IHOP·region size 조정, allocation·humongous 줄이기, 한 번에 하나씩, 추측 말고 로그로.
📝
문제풀이 · 점검
시니어 관점으로 점검
면접·장애대응에서 실제로 묻는 결의 문제예요. 틀려도 바로 해설이 나옵니다.
🧪
개념을 "안다"와 "설명할 수 있다"는 달라요. 네 유형(객관식 · O/X · 빈칸 · 코드결과)으로 점검해보세요.