Java / Go / JavaScript / Python — 모두 GC 가 메모리를 회수한다. 그러나 GC 가 "magic" 처럼 동작한다고 생각하면 GC pause 가 production latency spike 의 원인이 됐을 때 손쓸 방법이 없다. 이 가이드는 mark-and-sweep 의 실제 메커니즘, generational hypothesis 가 왜 work 하는지, 99% object 가 young 에서 죽는 통계의 의미, modern GC (G1/ZGC/Shenandoah) 의 trade-off 를 정리한다.
GC 가 푸는 문제 — reachability
function foo() {
let a = {x: 1}; // 할당
let b = {y: 2}; // 할당
a.next = b; // a → b 참조
return a; // a 만 return
}
const root = foo();
// root → a → b (모두 reachable)
root.next = null; // a 의 b 참조 끊김
// 이제 b 는 unreachable → GC 가 회수GC 의 핵심 질문: 어떤 object 가 reachable 한가? Reachable = root (stack 변수, 전역, 레지스터) 에서 참조 chain 으로 도달 가능. 나머지는 garbage.
알고리즘 1 — Reference Counting (Python, Swift, ObjC ARC)
# Python (CPython)
a = [1, 2, 3] # refcount = 1
b = a # refcount = 2
b = None # refcount = 1
a = None # refcount = 0 → 즉시 해제- 장점: 즉시 회수 (deterministic). pause 없음.
- 단점 1: 증가/감소 비용이 모든 assign 마다 발생.
- 단점 2: cyclic reference 못 잡음. a → b → a 면 둘 다 refcount 1+ → 영원히 회수 안 됨.
CPython 은 refcount + 별도 cycle detector 병용. Swift 는 weak reference 로 cycle 회피 책임 프로그래머. 단순함이 장점.
알고리즘 2 — Mark-and-Sweep
Phase 1 — Mark (reachability tracing):
worklist = [root1, root2, ...]
while worklist not empty:
obj = worklist.pop()
if not obj.marked:
obj.marked = true
for child in obj.references:
worklist.push(child)
Phase 2 — Sweep:
for obj in heap:
if not obj.marked:
free(obj)
else:
obj.marked = false // reset for next cycle- Cyclic reference 정확히 처리.
- 증가/감소 overhead 없음.
- 그러나 전체 heap walk — 큰 heap 에서 pause 길어짐.
Generational Hypothesis — 99% 가 어려서 죽는다
실제 프로그램의 통계:
object 수명 분포 (전형적 server 어플리케이션):
수명 < 1 GC cycle : ~95-99% ← request 단위 임시 변수
수명 ~수 cycle : ~1-4%
수명 > 100 cycle : ~0.1% (long-lived caches, 전역 state)즉 대부분의 garbage 는 young object. 그러면 전체 heap 을 매번 walk 할 필요 없다 — young 만 자주 보고, old 는 가끔만.
Generational GC 구조
heap:
┌──────────────┬──────────────┬──────────────────┐
│ Young (Eden) │ Survivor 0/1 │ Old Gen │
└──────────────┴──────────────┴──────────────────┘
↑ ↑ ↑
새 할당 minor GC 후 N번 survive
여기에 살아남은 것 한 후 promote
Minor GC (자주, 빠름):
- Young 만 walk
- 살아남은 것 → Survivor 로 copy
- 나머지 Eden 통째 해제 (cost O(살아남은 것), 죽은 것 cost 0!)
Major GC (드물게, 느림):
- 전체 heap walk (Old 포함)핵심 트릭 — copying collector. 살아남은 것만 다른 영역에 복사하고 원래 영역 통째 free. Live object 가 적으면 (95% 가 죽으니) cost 가 매우 낮다.
Cross-generation reference 문제
Old gen 의 object 가 young 의 object 참조하면?
→ minor GC 가 young 만 walk 하면 그 young object 가 reachable 인 줄 모름
해결: write barrier
- old 가 young 참조 박힐 때 "card" 표시
- minor GC 가 그 card 의 영역만 추가로 root 로 취급Write barrier = 모든 reference assign 에 작은 추가 코드 박힘. cost 가 있지만 generational 의 throughput 이득이 훨씬 큼.
Stop-the-World — 모든 thread 정지 동안
Normal: [T1 running] [T2 running] [T3 running]
GC start: [T1 pause ] [T2 pause ] [T3 pause ]
GC: (single-threaded mark + sweep)
GC end: [T1 resume] [T2 resume] [T3 resume]
영향: 그 동안 request 처리 못 함 → latency spike예전 Java GC (Serial, Parallel) 는 전체 GC 가 STW. 100 GB heap 의 Major GC 면 수 초 pause. p99 latency 와 SLA 의 적.
Modern GC — pause 줄이기
Concurrent — mark 단계를 application 과 동시 진행
STW 는 mark 의 "snapshot 잡기" 와 sweep 만. 대부분 시간은 application 과 같이 돌아감.
Incremental — 작은 단위로 쪼개기
GC 를 작은 chunk 로 나누어 application 중간중간 끼워넣기. 한 번에 길게 pause 하지 않음.
Region-based — heap 을 작은 region 으로
G1, ZGC. Heap 을 수백 개 region 으로 쪼개고 garbage 가 많은 region 부터 회수. Major GC 도 부분적으로 가능.
Java GC 선택지 (대표)
| GC | pause | throughput | 최적 영역 |
|---|---|---|---|
| Serial | 큼 | 중 | <100MB heap, single core |
| Parallel | 큼 | 최고 | throughput 최우선 batch |
| G1 (default 9+) | 중 (10-200ms) | 중 | 일반 server, 4-100 GB |
| ZGC | <1ms (대부분) | 약간 손해 | 대용량 heap (100GB+), latency SLA |
| Shenandoah | <10ms | 약간 손해 | latency SLA, OpenJDK |
Go GC — concurrent tri-color mark-sweep
Go 의 GC 는 generational 아님 (escape analysis 로 stack 우선). Concurrent mark + write barrier + STW 짧음 (목표 100 μs ~ 수 ms).
Tri-color invariant:
- white : 아직 mark 안 됨 (garbage 후보)
- gray : mark 됐지만 children 아직 안 봄
- black : mark + children 까지 다 봄
mark phase 동안:
- gray worklist 가 비면 mark 끝
- application 이 black → white reference 만들면 invariant 깨짐
→ write barrier 가 white 를 gray 로 박음 (mark 보존)V8 (JavaScript) GC
- Generational (young = "new space" + survivor; old = "old space")
- Young: Cheney's copying algorithm (Eden Scavenge)
- Old: Concurrent mark + sweep + compact (Major GC, 점진적 incremental)
GC 튜닝의 실용 진실
대부분의 GC 튜닝은 allocation pattern 튜닝 이지 GC 옵션 변경이 아니다.
// 나쁨 — 매 request 마다 새 object 수천 개
public Response handle(Request req) {
List<Item> items = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
items.add(new Item(i)); // 10000 alloc
}
return build(items);
} // 모두 즉시 garbage
// 좋음 — pool 재사용
private final ItemPool pool = new ItemPool(10000);
public Response handle(Request req) {
pool.reset();
for (int i = 0; i < 10000; i++) {
pool.acquire().set(i); // 0 alloc (재사용)
}
return build(pool);
}Allocation rate 가 GC pause 의 주범
Allocation rate 가 높을수록 young gen 이 빨리 차고 → minor GC 빈도 ↑ → GC pause 합계 ↑.
Long-lived object 가 갑자기 쏟아지면 promotion 폭주
Survivor 가 차서 old 로 빨리 promote → old 빨리 참 → Major GC → 긴 pause.
흔한 함정
- "GC 가 알아서 한다" 환상 — production latency spike 의 큰 비중이 GC. profile 안 보면 모름.
- JVM heap 크기를 무작정 크게 — 큰 heap = 긴 GC pause (G1 이전). ZGC 면 OK.
- String 등 immutable object 남발 — 매 modification = 새 alloc. StringBuilder / pool 활용.
- Finalizer / WeakReference 남용 — GC 가 추가 작업 해야 함. 명시적 close pattern 권장.
마무리
GC 는 magic 아니라 명확한 알고리즘. mark-and-sweep + generational + concurrent 의 조합으로 modern GC 가 만들어졌다. 그 동작을 이해하면 "왜 우리 서비스의 p99 가 가끔 튀는가" 같은 질문에 답할 수 있다.
Allocation pattern 이 GC behavior 를 결정한다 — how-memory-allocation-works 와 함께 읽으면 더 좋다.