GC(Garbage Collection)는 JVM의 핵심 기능 중 하나로, 힙(Heap) 영역에 동적으로 할당된 메모리 중 더 이상 필요 없는 객체(Garbage)를 찾아 제거하는 자동 메모리 관리 기법입니다.
이 포스트에서는 JVM이 어떤 객체를 '쓰레기'로 판단하는지, 그리고 그렇게 판단된 객체를 어떤 방식으로 '수거'하는지에 대해 자세히 알아보겠습니다.
JVM은 수많은 객체 중에서 어떤 객체를 '필요 없다'고 판단하고 수거 대상으로 삼을까요?
JVM은 '도달 가능성(Reachability)'이라는 개념을 사용해 객체가 사용 중인지 아닌지를 판단합니다.
JVM은 특정 객체가 '도달 가능한지'를 판단하기 위해 'GC Root(Root Set)'에서부터 참조 사슬을 추적하기 시작합니다. 힙 내부의 객체들끼리만 서로 참조하고 있는 것(순환 참조)만으로는 '도달 가능하다'고 보지 않습니다.
GC Root는 이 참조 사슬의 '시작점'이 되는 특별한 참조들입니다.
주요 GC Root의 유형:
GC는 이 Root Set에서부터 시작하여, 참조 관계를 따라가며 도달할 수 있는 모든 객체를 표시(Mark)합니다. 이 과정이 끝난 후, 표시되지 않은 모든 객체(즉, Root Set에서 도달할 수 없는 객체)가 GC 대상으로 식별됩니다.
원칙적으로 GC는 자동으로 동작하지만, 개발자가 java.lang.ref 패키지의 특별한 참조 클래스를 사용하여 GC의 판단에 어느 정도 영향을 줄 수 있습니다.
SoftReference: 감싸고 있는 원본 객체(Referent)에 대한 참조가 Root Set에서 끊겼을 때, 힙 메모리가 부족한 경우에만 GC 대상이 됩니다. (주로 캐시 구현에 사용)WeakReference: 감싸고 있는 원본 객체에 대한 참조가 Root Set에서 끊겼을 때, 메모리 상태와 관계없이 다음 GC 사이클에서 바로 GC 대상이 됩니다.GC 대상을 식별했다면, 이제 JVM은 이 객체들을 '수거'해야 합니다. 이 수거 작업은 여러 알고리즘을 기반으로 동작합니다.
GC를 실행하기 위해 JVM은 애플리케이션 실행을 멈춥니다. 이를 'Stop-The-World'라고 합니다. GC가 실행되는 동안에는 GC 스레드를 제외한 모든 애플리케이션 스레드가 작업을 멈춥니다. Stop-The-World 시간이 길어질수록 애플리케이션의 응답 시간(Latency)이 길어지므로 이 시간을 최소화하는 것이 GC 튜닝의 핵심입니다.
가장 기본적인 GC 알고리즘입니다.
OutOfMemoryError가 발생할 수 있습니다.Mark-and-Sweep의 파편화 문제를 해결하기 위해 고안된 방식입니다.
현대의 JVM(HotSpot 등)은 대부분 '세대별 GC' 방식을 사용합니다. 이는 다음 두 가지 '약한 세대 가설(Weak Generational Hypothesis)'에 기반합니다.
이 가설에 따라, 힙 영역을 두 세대로 나눕니다.
Young Generation (영 세대):
Old Generation (올드 세대 / Tenured):
세대별 GC는 금방 죽는 객체(Young)와 오래 사는 객체(Old)를 분리하여, GC의 효율을 극대화하고 Stop-The-World 시간을 최소화하는 전략입니다. 하지만 Old Generation이 꽉 찼을 때 발생하는 Full GC의 긴 Stop-The-World 시간은 대용량 힙(Heap) 환경에서 심각한 서비스 지연을 유발할 수 있습니다.
기존 세대별 GC의 가장 큰 숙제는 'Full GC로 인한 긴 STW'였습니다. 힙 크기가 수십 GB, 수백 GB로 커지면서, 한 번의 Full GC가 몇 초에서 심하면 몇 분까지 애플리케이션을 멈추게 만들었습니다.
이 문제를 해결하기 위해 '예측 가능하고 짧은 STW'를 목표로 하는 새로운 GC들이 등장했습니다.
Java 9부터 기본 GC로 채택된 G1은 세대별 GC의 구조를 유지하면서도, Full GC의 개념을 사실상 없앤 GC입니다.
핵심 아이디어: "힙 전체를 한 번에 청소하지 말고, 작은 영역(Region)으로 쪼개서 쓰레기가 가장 많은(Garbage-First) 영역부터 예측 가능하게 청소하자!"
특징:
-XX:MaxGCPauseMillis=200 처럼 목표 STW 시간을 설정할 수 있습니다. G1은 이 목표 시간을 지키기 위해 '청소할 Region의 수'를 조절합니다.동작 방식 (Mixed GC):
결론: G1은 Full GC(Mark-Sweep-Compact)를 피하고, 짧은 STW의 'Mixed GC'를 여러 번 수행하여 Old Generation을 점진적으로 정리합니다. 덕분에 파편화 문제가 해결되고, STW 시간을 예측 가능한 수준(수십 ~ 수백 ms)으로 관리할 수 있게 되었습니다.
G1이 STW를 '짧게' 만드는 데 집중했다면, ZGC와 Shenandoah는 STW를 '거의 0'에 가깝게 만드는 것을 목표로 합니다. 힙 크기가 수백 GB, 수 TB가 되어도 STW 시간을 1ms 미만으로 유지하는 것이 목표입니다.
핵심 아이디어: "Mark(표시), Sweep(제거), 심지어 Compact(압축)까지 모든 작업을 애플리케이션과 동시에(Concurrent) 진행하자!"
특징:
어떻게 이것이 가능할까? (Load Barriers)
이 GC들의 핵심 기술은 '로드 배리어(Load Barrier)'입니다.
문제 상황: GC가 객체 O를 A 주소에서 B 주소로 이동시켰습니다.
동시에: 애플리케이션 스레드가 O의 옛날 주소 A를 읽으려고 합니다.
해결 (Load Barrier): JVM(JIT 컴파일러)이 객체 참조를 읽는 모든 코드에 '배리어(방어막)' 코드를 삽입합니다. 이 배리어는 객체를 읽을 때마다 "이 객체가 혹시 이사(Relocation) 중인가?"를 체크합니다.
만약 이사 중이거나 이사가 끝났다면, 애플리케이션 스레드에게 새 주소 B를 알려줍니다.
ZGC는 컬러드 포인터(Colored Pointers), Shenandoah는 브룩스 포인터(Brooks Pointers)라는 각자의 방식으로 이 '로드 배리어'를 구현하여, GC와 애플리케이션이 동시에 힙에 접근할 수 있도록 합니다.
결론: ZGC와 Shenandoah는 STW가 거의 없는 대신, 애플리케이션의 모든 '참조 읽기' 작업에 약간의 오버헤드(배리어 체크)를 추가합니다. 따라서 실시간 응답이 매우 중요한 대용량(수백 GB 이상) 힙을 다루는 시스템에 가장 적합합니다.