GC — 세대 가설과 GC Root
GC가 객체의 생사를 판별하는 근거와, 실무에서 자주 마주치는 GC Root발 메모리 누수 패턴을 코드와 함께 파헤친다.
C와 C++에서는 메모리 할당과 해제를 개발자가 직접 관리해야 한다. Java는 이 과정을 자동화한 GC(Garbage Collector)를 제공한다.
GC 알고리즘(G1, ZGC 등)과 Stop-the-World, Card Table, Write Barrier 등 세부 동작은 다음 글에서 다룬다. 이 글에서는 GC가 세대를 나누는 근거와 GC Root, 그리고 GC Root로 인한 메모리 누수 패턴까지만 짚는다.
1 GC를 뒷받침하는 3가지 가설
GC는 아래 3가지 가설을 근거로 동작 방식을 설계한다.
| 가설 | 내용 | 근거가 되는 설계 |
|---|---|---|
| 약한 세대 가설 (Weak Generational Hypothesis) |
대부분의 객체는 생성된 직후 빠르게 사라진다 | Young 영역 분리 |
| 강한 세대 가설 (Strong Generational Hypothesis) |
오래 살아남은 객체일수록 더 오래 산다 | Old 영역 분리 |
| 세대간 참조 희소성 (Intergenerational Reference Rarity) |
Old 영역이 Young 영역을 참조하는 경우는 드물다 | Card Table로 그 드문 참조만 기록 → Minor GC 시 Old 영역 전체를 스캔할 필요 없음 |
이 세 가설이 바로 Java GC가 Heap을 Young/Old로 나누고, Minor GC에서 Old 영역을 건너뛸 수 있게 하는 근거다.
2 GC 대상은 어떻게 선별되는가
GC 대상을 판별하는 알고리즘은 크게 2가지다.
Reference Counting
객체가 참조될 때마다 카운트를 +1 하고, 참조가 끊기면 -1 한다. 카운트가 0이 되면 GC 대상으로 판단한다.
단점은 순환 참조다. 서로를 참조하는 두 객체는 외부에서 접근 불가능해도 카운트가 0이 되지 않아 영원히 수거되지 않는다. 이 문제를 해결하는 것이 Reachability다.
Reachability
GC Root를 기준으로 참조 그래프를 탐색해, GC Root에서 끝까지 도달 가능(Reachable)한 객체만 살아있다고 판단한다. 도달할 수 없는 객체는 GC 대상이 된다.
GC Root란?
무조건 살아있다고 간주하는 시작점이다. GC는 이 지점들에서 출발해 참조 그래프를 탐색(Mark)하고, 방문하지 못한 객체를 수거(Sweep)한다.
GC Root의 종류
- static 변수
- 스택 프레임의 지역 변수 / 매개변수
- 실행 중인 스레드
JVM에서 살아있는 모든 스레드 객체 자체가 GC Root다. 스레드가 살아있는 동안, 그 스레드의 스택 프레임과 내부 참조들은 모두 Reachable 상태를 유지한다.
3 GC Root로 발생하는 메모리 누수 패턴
GC는 도달 가능한 객체를 정확히 판단하지만, 개발자가 의도치 않게 GC Root까지의 참조 체인을 계속 살려두면 메모리 누수가 발생한다. 실무에서 자주 만나는 3가지 패턴을 코드로 살펴본다.
패턴 1: static 컬렉션에 지속적으로 추가
class EventBus {
// static → GC Root → listeners의 모든 원소가 Reachable
private static final List<EventListener> listeners = new ArrayList<>();
public static void register(EventListener listener) {
listeners.add(listener);
// 제거 로직이 없으면 등록된 listener는 절대 GC되지 않음
}
}
해결 1: 명시적 unregister() 제공
class EventBus {
private static final List<EventListener> listeners = new ArrayList<>();
public static void register(EventListener listener) {
listeners.add(listener);
}
// 사용 측에서 반드시 호출해야 함
public static void unregister(EventListener listener) {
listeners.remove(listener);
// listener → GC Root 체인 끊김 → GC 대상이 될 수 있음
}
}
// 사용 예
EventListener listener = new MyListener();
EventBus.register(listener);
try {
// ...
} finally {
EventBus.unregister(listener); // 반드시 해제
}
해결 2: WeakReference 활용
class EventBus {
// WeakReference로 감싸면 listener를 Strong 참조하는 곳이 없을 때 GC 가능
private static final List<WeakReference<EventListener>> listeners = new ArrayList<>();
public static void register(EventListener listener) {
listeners.add(new WeakReference<>(listener));
}
public static void notify(Event event) {
Iterator<WeakReference<EventListener>> it = listeners.iterator();
while (it.hasNext()) {
EventListener listener = it.next().get();
if (listener == null) {
it.remove(); // 이미 GC된 항목 정리
} else {
listener.onEvent(event);
}
}
}
}
WeakReference 방식은 unregister를 강제하지 않아도 외부에서 Strong 참조가 없어지면 자동으로 GC된다. 다만 get()이 null을 반환할 수 있으므로 null 체크가 필수다.
패턴 2: ThreadLocal 변수 미정리
// 스레드 풀 환경에서 스레드가 재사용되는 경우
private static final ThreadLocal<LargeObject> context = new ThreadLocal<>();
void handleRequest() {
context.set(new LargeObject());
process();
// context.remove() 호출 안 하면?
// 스레드가 풀로 반환된 후에도 ThreadLocal이 LargeObject를 참조
// 해당 스레드가 살아있는 한(= GC Root) LargeObject도 살아있음
}
해결: finally 블록에서 반드시 context.remove() 호출.
void handleRequest() {
context.set(new LargeObject());
try {
process();
} finally {
context.remove(); // 반드시 정리
}
}
패턴 3: 익명 클래스 / 람다의 외부 참조
class View {
private byte[] largeData = new byte[1024 * 1024]; // 1MB
void startAsyncTask() {
// 익명 클래스는 암묵적으로 외부 클래스(View) 인스턴스를 참조
new Thread(() -> {
// 이 람다가 살아있는 동안 View 인스턴스도 GC 불가
// largeData도 함께 메모리에 남음
doLongRunningTask();
}).start();
}
}
해결 1: 필요한 데이터만 지역변수로 캡처
class View {
private byte[] largeData = new byte[1024 * 1024]; // 1MB
void startAsyncTask() {
// largeData에서 실제로 필요한 값만 추출해서 캡처
// 람다는 View 인스턴스가 아닌 int 값만 참조 → View는 GC 가능
int targetValue = extractTargetValue(largeData);
new Thread(() -> {
doLongRunningTask(targetValue); // View 참조 없음
}).start();
}
}
해결 2: static 중첩 클래스 사용
class View {
private byte[] largeData = new byte[1024 * 1024];
void startAsyncTask() {
// static 중첩 클래스는 외부 클래스 인스턴스를 암묵적으로 참조하지 않음
new Thread(new StaticTask()).start();
}
// static → 외부 클래스(View)에 대한 암묵적 참조 없음
private static class StaticTask implements Runnable {
@Override
public void run() {
doLongRunningTask(); // View.this 참조 불가 → View GC 가능
}
}
}
내부 클래스(Inner Class)와 static 중첩 클래스(Static Nested Class)의 차이가 여기서 중요하다. 내부 클래스는 외부 인스턴스에 대한 암묵적 참조(Outer.this)를 갖지만, static 중첩 클래스는 그렇지 않다.
정리
| 개념 | 핵심 |
|---|---|
| 세대 가설 3가지 | 약한/강한 세대 가설로 Young·Old 분리, 참조 희소성으로 Card Table 기반 Minor GC 최적화 |
| Reference Counting | 참조 카운트 기반, 순환 참조 문제로 단독 사용 불가 |
| Reachability | GC Root에서 도달 가능한 객체만 생존 판정 |
| GC Root | static 변수, 스택 프레임의 지역변수/매개변수, 실행 중인 스레드 |
| 누수 패턴 | static 컬렉션 미정리, ThreadLocal 미정리, 람다/익명 클래스의 외부 참조 |
여기까지 GC의 세대 가설과 GC Root를 알아봤다. 다음 글에서는 실제 GC 알고리즘(Serial, Parallel, G1, ZGC)과 Stop-the-World, Card Table의 동작을 이어서 다룬다.
'Java' 카테고리의 다른 글
| JVM 정리 — Java 코드가 실행되기까지 (0) | 2026.05.28 |
|---|---|
| Json과 Object의 차이, Java에서 Json객체 사용법 (1) | 2024.12.09 |