GC(Garbage Collection)는 프로그램이 더 이상 접근할 수 없는(도달 불가능, unreachable) 객체가 점유한 힙(Heap) 메모리를 자동으로 회수하는 JVM 기능입니다. 회수 대상 판정은 참조 카운팅이 아니라 GC Root로부터의 도달 가능성(reachability)을 기준으로 하므로, 서로만 참조하는 순환 참조 덩어리도 루트에서 닿지 않으면 통째로 회수됩니다. GC Root는 실행 중인 스레드의 스택 지역 변수·매개변수, 클래스의 static 필드, JNI 전역 참조 등입니다. 덕분에 개발자는 C/C++처럼 free/delete로 해제 시점을 직접 관리할 필요가 없고, 이중 해제·dangling pointer 같은 결함이 언어 차원에서 사라집니다. 대신 회수 시점 계산 동안 애플리케이션 스레드가 멈추는 Stop-The-World(STW) 비용을 감수합니다.
"GC를 언어(런타임)가 제공하는가, 제공한다면 어떤 알고리즘인가"는 언어마다 다릅니다. 아래는 대략의 경향이며, 각 런타임의 세부 구현은 버전에 따라 달라질 수 있습니다.
요지: 위 스펙트럼에서 Java는 "런타임(JVM)이 GC를 전담하고, 세대별 추적 수집을 기본으로 하는" 쪽에 속합니다. 이하 내용은 모두 Oracle/OpenJDK의 HotSpot JVM을 기준으로 합니다.
한 줄 요약: 객체 본체(instance)는 Heap, 그 객체를 가리키는 참조 변수는 Stack에 놓입니다.
JVM 명세는 Runtime Data Areas를 5개로 규정합니다 — Heap, Method Area(HotSpot 구현상 Metaspace), PC Register, JVM Stack, Native Method Stack. 이 중 Heap과 Method Area는 전 스레드가 공유하고, PC Register·JVM Stack·Native Method Stack은 스레드마다 1개씩 갖습니다(공유 2 + 스레드별 3). Java 8에서 클래스 메타데이터가 PermGen에서 네이티브 메모리의 Metaspace로 옮겨졌고 PermGen은 완전히 제거되었습니다.
기본 알고리즘은 Mark → Sweep → (Compact)입니다. Mark는 GC Root에서 출발해 도달 가능한 객체에 "살아있음"을 표시하고, Sweep는 표시되지 않은 객체의 공간을 회수하며, Compact(선택적)는 살아있는 객체를 한쪽으로 몰아 단편화를 제거합니다. 새 객체가 몰리는 Young 영역은 Mark-Sweep 대신 Copying(살아있는 객체만 다른 공간으로 복사)을 써서, 비용이 죽은 객체 수가 아니라 살아남은 객체 수에 비례하게 만듭니다("대부분의 객체는 금방 죽는다"는 weak generational hypothesis 덕에 효율적). 수집 범위에 따라 Minor GC(Young) / Major GC(Old) / Full GC(전체)로 나뉘며, Full GC가 잦으면 심각한 튜닝 신호입니다. 모든 수집은 최소한의 STW를 동반합니다.
"서버급(server-class) 머신 기준"이라는 조건이 붙습니다. 같은 버전이라도 CPU·메모리가 작은 환경에서는 Serial GC가 기본으로 선택될 수 있습니다.
XX:MaxGCPauseMillis로 목표 pause 시간을 지정하면 회수 이득이 큰 Region부터 점진적으로 압축 수집. 현재 표준 기본 GC.부하가 커지면 결국 OutOfMemoryError(주로 힙)가 더 자주 나지만, 근본 원인은 대개 "메모리 누수"가 아니라 유입 속도가 처리 속도를 앞서는데 그 차이를 무제한(unbounded) 버퍼가 흡수하다 터지는 것입니다. 다운스트림(DB·외부 API)이 느려지면 리틀의 법칙(L = λ × W)대로 in-flight 객체 수가 늘어 "요청 수는 그대로인데 힙이 터지는" 현상이 나타납니다. 대응은 모든 큐·풀·동시 실행 수에 상한(limit)을 두고, 상한을 넘으면 거절하거나 지연시키는 것(backpressure)입니다. 무제한 큐(new LinkedBlockingQueue<>()), 무제한 스레드풀(newCachedThreadPool())은 붕괴 시점을 뒤로 미룰 뿐이며, 지연된 OOM은 진단이 더 어렵습니다. 상세: Java - 대용량 트래픽·비동기 병렬 환경의 메모리·리소스 한계 관리.
대량 데이터를 한 번에 List로 materialize하면, 단명할 수도 있었던 대용량 객체가 Young 영역을 넘겨 Old로 조기 승격(premature promotion)되고, 이는 Full GC와 (G1의 경우) humongous allocation 부담을 유발합니다. 대응은 청크 단위 스트리밍 처리입니다 — JDBC fetch size 조정, Stream/커서 기반 순회, 배치 크기 상한, 불필요한 중간 컬렉션 제거. 원칙은 "객체를 짧게 살고 작게" 만들어 Young GC 단계에서 회수되도록 하는 것입니다. 결과 집합·응답 바디를 전량 버퍼링하지 않는 것도 같은 맥락입니다.
동시성 도구는 편리하지만 동시 진행 수에 상한이 없으면 in-flight 객체가 힙에 쌓입니다. CompletableFuture 팬아웃은 수천 개 작업을 한꺼번에 만들지 말고 배치로 분할하거나 Semaphore로 제한하고, parallelStream은 공용 ForkJoinPool을 공유하므로 블로킹 작업에 쓰지 않습니다. 리액티브(Project Reactor)의 onBackpressureBuffer는 반드시 상한 용량을 지정하고 limitRate(n)으로 demand를 조절합니다. 가상 스레드(Java 21)는 "스레드 수" 병목은 없애지만, DB 커넥션·외부 API rate limit 같은 다운스트림 리소스의 동시 사용량은 여전히 Semaphore 등으로 제한해야 합니다. 기능별로 풀·세마포어를 분리하는 Bulkhead도 함께 고려합니다.
상세 노트 4개에서 정면으로 다루지 않은 주제로, GC Root에 준하는 강한 참조를 오래 쥐고 있는 객체가 사실상의 누수를 만드는 패턴입니다.
static 필드 / 애플리케이션 컨텍스트가 참조하는 객체는 GC Root에 준하는 강한 참조여서 앱이 종료될 때까지 회수되지 않습니다. 여기에 캐시를 무한정 담으면 그대로 누수입니다.
캐시(임시참조 - 동적참조)는 크기·TTL을 제한(LRU, Caffeine 등)하거나 WeakHashMap/SoftReference로 참조 강도를 낮춥니다.ThreadLocal은 스레드풀에서 스레드가 재사용되므로, 사용 후 remove()를 호출하지 않으면 그 스레드가 사는 내내 값이 누적됩니다. 요청 처리 후 정리가 필수입니다.OutOfMemoryError: Metaspace로 이어집니다.
댓글
GitHub 계정으로 의견이나 질문을 남길 수 있습니다.