본문으로 건너뛰기
yutils

garbage collection 은 어떻게 동작할까?

mark-and-sweep · reference counting · generational hypothesis, 왜 99% object 가 young 에서 죽나, stop-the-world pause, G1 vs ZGC vs Shenandoah, GC 튜닝이 결국 allocation pattern 튜닝인 이유.

약 9분 읽기

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 선택지 (대표)

GCpausethroughput최적 영역
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 와 함께 읽으면 더 좋다.

가이드 목록으로