GC를 수행하기 위해 애플리케이션 스레드를 전부 멈추는 시간(STW).
왜 멈춰야 하나
GC가 객체 그래프를 순회하는 도중에 애플리케이션이 참조를 바꾸면 잘못된 판단을 하게 된다.
GC: A를 검사 → "살아 있음" 표시
B를 검사 중...
앱: 그 사이 A가 참조하던 C를 끊고, D가 C를 참조하게 함
GC: C를 "쓰레기"로 판단하고 회수 ← 실제로는 D가 쓰고 있다 ✗
살아 있는 객체를 회수하면 프로그램이 깨진다. 그래서 최소한 일부 구간에서는 세상을 멈춰야 한다.
안전 지점(safepoint)
아무 때나 멈출 수는 없다. 스레드가 일관된 상태여야 스택을 정확히 읽을 수 있다.
- JVM이 "멈춰라" 플래그를 세운다
- 각 스레드는 안전 지점(메서드 호출, 루프 뒤쪽 분기 등)에 도달하면 멈춘다
- 전원이 멈추면 GC 시작
함정 — 안전 지점이 없는 긴 루프(int 카운터의 단순 루프는 JIT가
안전 지점 검사를 제거하기도 한다)가 있으면, 다른 스레드는 다 멈췄는데
그 하나 때문에 전체가 대기한다. 이것을 "safepoint 도달 지연"이라 하며
GC 시간이 아닌데도 정지가 길어지는 원인이 된다.
무엇이 문제인가
STW 100ms → 그 동안 모든 요청이 응답하지 않는다
→ p99 지연이 100ms 이상 튄다
→ 헬스 체크가 실패해 로드밸런서에서 제외될 수도
→ 분산 락 TTL이 만료돼 정합성이 깨질 수도 ★
★ GC 정지 중에 분산 락이 만료되는 시나리오는 Redlock 논쟁의 핵심이기도 하다. 자기는 락을 갖고 있다고 믿는데 실제로는 만료된 상태로 깨어난다.
수집기별 정지 시간
| 수집기 | 전형적 STW |
|---|---|
| Serial / Parallel | 수백 ms ~ 수 초 |
| G1 | 수십 ms (목표 지정 가능) |
| ZGC / Shenandoah | 1ms 미만 (힙 크기와 무관) |
ZGC가 어떻게 1ms를 달성하나 — 마킹과 재배치를 애플리케이션과 동시에 수행한다. 객체를 옮기는 중에 앱이 접근하면 로드 배리어가 가로채 새 위치로 자동 전달한다. STW는 루트 스캔 등 극히 짧은 구간만 남는다.
대가는 처리량 약간 감소(배리어 오버헤드)와 메모리 사용 증가다.
줄이는 방법
- ① 객체를 덜 만든다 ← 가장 근본적
- 불필요한 박싱, 문자열 결합, 대용량 컬렉션 복사 제거
- ② 힙 크기를 적절히
- 너무 작으면 GC가 잦고, 너무 크면 한 번이 길다
- ③ 저지연 수집기로 교체 (ZGC)
- ④ Old 승격을 줄인다
- 요청 처리용 객체가 Old로 올라가면 Full GC를 유발한다
- Young 영역을 충분히 크게 (-XX:NewRatio)
- ⑤ 캐시를 힙 밖으로 (오프힙, Redis)
④가 중요하다 — Young이 너무 작으면 아직 처리 중인 객체가 살아남아 Old로 올라간다(조기 승격). Old가 차면 Full GC가 발생한다.
측정
-Xlog:gc*:file=gc.log:time,uptime,level,tags # 자바 9+
[15.234s][info][gc] GC(12) Pause Young (G1 Evacuation Pause) 512M->128M(1024M) 25.3ms
↑ 정지 종류 ↑ 전→후(전체) ↑ 정지 시간
봐야 할 것
- 정지 시간이 목표(보통 100~200ms) 안에 들어오는가
- Full GC가 자주 나는가 (드물어야 정상)
- GC 후에도 사용량이 안 줄어드는가 (누수 신호)
GCEasy 같은 도구에 로그를 넣으면 시각화해 준다.