Garbage Collector

mongBrown·2026년 4월 18일

JVM은 언제 메모리를 회수하는가

Object a = new Object();
Object b = a;
a = null;

a를 null로 바꿨다. new Object()로 만든 객체는 이제 GC가 가져가도 될까.

먼저 알아야 할 개념

이 글에서 반복 등장하는 두 가지 용어를 먼저 정의한다.

Reachability(도달 가능성): 어딘가에서 참조를 타고 해당 객체에 닿을 수 있는지 여부. GC는 이것을 기준으로 수거 대상을 판단한다.

STW(Stop-the-World): GC가 동작하는 동안 JVM이 애플리케이션 스레드를 전부 멈추는 현상. 이 시간이 길어지면 사용자에게 응답 지연이 그대로 노출된다.

GC가 보는 건 null이 아니다

위 코드로 돌아가면, a = null 이후에도 b가 같은 객체를 가리키고 있다. a가 null이 됐다고 해서 그 객체에 도달하는 방법이 사라진 게 아니다.

GC는 null 여부를 보지 않는다. "지금 이 객체에 닿을 수 있는 경로가 남아 있는가"를 본다.

그 경로의 출발점을 GC Root라고 한다. JVM은 GC Root에서 시작해서 참조를 타고 이동하며 닿을 수 있는 객체를 표시한다. 탐색이 끝났을 때 한 번도 표시되지 않은 객체가 수거 대상이다.

GC Root가 되는 것은 현재 실행 중인 메서드의 로컬 변수, static 변수, JNI에서 참조하는 객체다. 메서드가 끝나면 로컬 변수가 Stack에서 사라지고, GC Root에서 해당 객체로 이어지는 경로도 끊긴다.

Heap을 왜 나누는가

JVM은 Heap을 두 구역으로 관리한다.

Young Generation은 방금 생성된 객체들이 들어오는 곳이다. 내부적으로 Eden, Survivor 0, Survivor 1로 나뉜다. 객체는 처음 Eden에 생성되고, GC에서 살아남을 때마다 Survivor를 오가며 나이가 쌓인다.

Old Generation은 여러 번의 GC를 버텨낸 객체들이 승격되어 오는 곳이다.

이렇게 나누는 이유는 하나다. 대부분의 객체는 생성 직후에 쓸모가 없어진다. 메서드 안에서 만들어지는 지역 변수, 반복문 안에서 잠깐 쓰는 임시 객체들이 그렇다. 반면 오래 살아남은 객체는 앞으로도 계속 살아남을 가능성이 높다.

그래서 JVM은 두 가지 GC를 다른 빈도로 운용한다.

Minor GC는 Young Generation만 대상으로 한다. Eden이 꽉 차면 발동된다. 살아남은 객체는 Survivor로 옮기고, Survivor에서 일정 횟수 이상 버텨낸 객체는 Old Generation으로 승격한다. Young Generation은 전체 Heap에서 작은 비중을 차지하기 때문에 스캔 범위가 좁고, STW가 짧다. 자주 돌아도 부담이 적다.

Major GC(Full GC)는 Old Generation까지 포함해 Heap 전체를 대상으로 한다. Old Generation은 크기가 훨씬 크고 살아있는 객체도 많아서 스캔과 정리에 시간이 오래 걸린다. STW도 그만큼 길어진다. 이 시간이 수 초에 달하면 애플리케이션 응답이 그대로 멈추는 것처럼 보인다.

GC 알고리즘은 왜 계속 바뀌었는가

STW 때문이다.

초기의 Serial GC는 GC 스레드가 하나다. Major GC가 돌면 Heap 전체를 싱글 스레드 하나로 스캔하는 동안 애플리케이션이 멈춘다. Heap이 커질수록 멈추는 시간도 그대로 늘어난다.

Parallel GC(Java 8 기본값)는 GC 스레드를 여러 개 쓴다. STW 자체는 여전히 발생하지만 멀티 스레드로 같은 일을 처리하니 더 빠르게 끝난다. 구조는 Serial과 동일하다.

두 방식의 공통된 한계는 "끝날 때까지 멈춘다"는 것이다. Heap이 커지거나 Old Generation에 객체가 쌓이면 Major GC의 STW가 수 초까지 늘어날 수 있다.

G1은 무엇을 바꿨는가

G1 GC(Java 9 기본값)는 Heap을 고정 구역 대신 작은 Region들로 쪼갠다.

기존: [  Young  ][             Old             ]
G1:  [R][R][R][R][R][R][R][R][R][R][R][R][R][R]
      각 Region에 Eden/Survivor/Old 역할을 동적으로 부여

이 구조가 STW를 줄이는 방식은 두 가지다.

첫째, 어떤 객체가 살아있는지 표시하는 Marking 작업을 애플리케이션과 동시에 실행한다. 기존 GC는 이 작업도 STW 중에 했다.

둘째, 실제 수거 시 전체를 처리하지 않는다. 쓰레기가 많은 Region만 골라서 수거한다(Garbage First). STW 동안 처리하는 양 자체가 줄어드니 pause time이 짧아진다.

-XX:MaxGCPauseMillis=200 같은 옵션으로 목표 pause time을 설정하면, G1이 그 시간 안에 처리할 수 있는 만큼의 Region만 골라 수거한다. 얼마나 멈출지 예측이 가능해지는 이유다.

ZGC는 어디까지 갔는가

ZGC(Java 15 production ready)는 Marking뿐 아니라 객체 이동과 정리 작업도 애플리케이션과 동시에 실행한다. G1이 남겨뒀던 STW 구간까지 제거한 것이다.

STW가 1~2ms 이하로 유지된다. 수백 GB 규모의 Heap에서도 pause time이 늘어나지 않는다.

참조의 강도를 조절할 수 있다면

Java는 참조의 강도를 네 단계로 나눈다.

일반적으로 변수에 객체를 담으면 Strong Reference다. Strong Reference가 하나라도 있으면 GC는 수거하지 않는다.

WeakReference는 Strong Reference가 없어지면 다음 GC에서 바로 수거된다.

Object obj = new Object();
WeakReference<Object> weak = new WeakReference<>(obj);

obj = null;  // Strong Reference 제거
// 다음 GC 이후 → weak.get()은 null 반환

이 특성을 캐시에 활용하면 메모리 누수를 막을 수 있다. Strong Reference로 캐시를 구성하면 아무도 쓰지 않는 항목도 GC가 수거할 수 없어 메모리가 계속 쌓인다. WeakReference로 구성하면 다른 곳에서 참조가 사라질 때 GC가 자동으로 정리한다.

단, WeakReference는 GC가 돌 때마다 수거될 수 있어 캐시가 너무 자주 비워진다. 캐시에는 SoftReference가 더 적합한 경우가 많다. SoftReference는 메모리가 충분할 때는 유지되다가, OOM이 임박했을 때 수거된다. 캐시가 메모리 상황에 따라 자연스럽게 크기를 조절하는 셈이다.

PhantomReference는 객체가 소멸되기 직전 정리 작업이 필요할 때 쓰는 고급 용도다. 실무에서 직접 다룰 일은 드물다.

그래서 어떤 GC를 써야 하는가

Java 21 기준으로 대부분의 서비스는 G1 또는 ZGC를 쓴다.

G1은 pause time 목표를 설정할 수 있어 응답 지연을 어느 정도 예측 가능하게 만들어준다. 일반적인 웹 서비스에서 무난하게 쓸 수 있다.

ZGC는 pause time이 워크로드나 힙 크기와 무관하게 1~2ms 이하로 유지된다. 응답 지연이 매우 엄격하게 관리되어야 하는 서비스, 또는 수십 GB 이상의 힙을 쓰는 환경에서 선택한다.

Serial이나 Parallel을 새로 선택하는 경우는 없다고 봐도 된다. 힙이 매우 작고 단순한 배치 처리 애플리케이션이 아니라면.

profile
화이팅!

0개의 댓글