[CS] Garbage Collection

U·2025년 10월 5일

CS

목록 보기
14/23

이번주 스터디 주제는 그 유명한 Garbage Collection이다.

📚 Garbage Collection

가비지 컬렉션은 프로그래머가 동적으로 할당한 메모리 영역 중에서 더 이상 사용하지 않게 된 영역을 자동으로 찾아내어 해제해주는 관리 기법이다.

C나 C++ 같은 언어에서는 개발자가 malloc()으로 메모리를 할당하면 반드시 free()를 통해 직접 해제해야 했다. 개발자가 해제를 깜빡한 경우에는 메모리 누수가 발생하고, 이미 해제된 메모리에 접근하게 되면 댕글링 포인터와 같은 심각한 오류가 발생할 수 있었다.

💡 댕글링 포인터(Dangling Pointer)란?

  • 포인터가 여전히 해제된 메모리 영역을 가리키는 것

자바에서는 JVM의 가비지 컬렉터가 이러한 작업을 대신 처리해주기 때문에 개발자는 메모리 관리에 부담을 덜고 비즈니스 로직 개발에 더 집중할 수 있는 것이다.

GC의 기본 동작 방식

📌 Mark

  • GC는 먼저 GC Root에서 시작

✔️ GC Root가 될 수 있는 주요 대상들

  • 실행 중인 스레드 (Active Thread) : 현재 실행되고 있는 모든 스레드는 그 자체로 GC Root가 됨
  • JVM 스택 (Stack Area) : 스택에 있는 지역 변수나 매개변수들이 참조하는 객체들
  • 메서드 영역(Method Area)의 정적(static) 변수 : static 키워드로 선언된 변수들이 참조하는 객체들은 프로그램 시작부터 끝까지 메모리에 유지되므로 GC Root의 역할을 함
  • JNI(Java Native Interface) 참조 : 네이티브 코드(C, C++ 등)에서 생성했거나 참조하고 있는 자바 객체들
  • GC Root가 참조하는 모든 객체를 찾아내고 살아있음을 의미하는 표시(Mark)를 남김
  • 표시된 객체들이 또 참조하고 있는 다른 객체들을 따라가면서 연쇄적으로 모두 표시 → 이 과정에서 Reachable한 모든 객체에 표시가 남게 됨

💡 Reachable한 객체란?
GC Root로부터 직간접적으로 참조되어 있는 객체다. GC Root에서부터 참조를 따라 도달할 수 있는 모든 객체는 살아있는 객체로 간주되며, 이러한 객체들은 프로그램이 아직 사용하고 있을 가능성이 높다고 판단하여 메모리에서 제거되지 않는다.

📌 Sweep

  • Mark 단계가 끝난 후, GC는 전체 힙 메모리를 훑어보면서 표시되지 않은 객체들, 즉 Unreachable한 객체들을 모두 쓰레기로 간주하고 메모리에서 제거(Sweep)

💡 Unreachable한 객체란?
GC Root로부터 어떤 참조 경로로도 도달할 수 없는 객체다. GC Root와의 연결이 완전히 끊어진 객체로, 프로그램에서는 더 이상 이 객체에 접근하거나 사용할 방법이 없으므로, 죽은 객체로 간주되어 가비지 컬렉터의 수거 대상이 된다.

📌 Compaction (선택)

  • Sweep 단계 이후에는 메모리 공간이 조각조각난 단편화 상태가 될 수 있으며, 이는 큰 객체를 할당할 때 비효율을 초래할 수 있음
  • Compact 단계는 살아남은 객체들을 힙의 한쪽으로 이동시켜 빈 공간을 하나로 합쳐주는 작업으로, 이를 통해 단편화를 해결하고 메모리 할당 속도를 높일 수 있음

GC의 종류

자바는 다양한 GC 알고리즘을 제공하며, 애플리케이션의 특성에 맞게 선택할 수 있다. 각 알고리즘은 처리량(Throughput)과 응답 시간(Latency, STW) 사이에서 서로 다른 장단점을 가진다.

💡 STW(Stop-the-World)란?
STW는 가비지 컬렉션을 실행하기 위해 JVM이 애플리케이션 실행을 완전히 멈추는 현상을 의미한다.
GC가 작동하는 동안에는 객체의 참조 관계가 계속해서 바뀔 수 있다. 예를 들어 애플리케이션 스레드가 계속 실행되면서 객체의 상태를 변경한다면, GC는 어떤 객체가 Reachable하고 어떤 객체가 Unreachable한지 정확하게 파악하기 어려워진다.
따라서 GC는 정확하고 안전한 메모리 정리를 위해, 모든 애플리케이션 스레드를 일시적으로 중단시키고 오직 GC 관련 스레드만 동작하도록 한다. STW는 GC 과정에서 필수적일 수 있지만, STW 시간이 길어지면 애플리케이션의 응답 시간 저하 및 성능에 치명적인 영향을 줄 수 있다. 특히 실시간 처리가 중요한 서비스에서는 STW 시간을 최소화하는 것이 GC 튜닝의 핵심 목표가 된다.

1️⃣ Serial GC

  • 가장 단순한 GC로, 하나의 스레드로만 GC 수행
  • STW 시간이 길어 현재는 거의 사용되지 않지만, CPU 코어가 하나뿐인 매우 간단한 애플리케이션 환경에서 사용될 수 있음

2️⃣ Parallel GC

  • 여러 개의 스레드를 병렬적으로 사용하여 Young 영역의 GC(Minor GC)를 수행
  • Serial GC보다 STW 시간이 짧고, 처리량을 우선시하는 GC로 Java 8의 기본 GC였음

3️⃣ CMS (Concurrent Mark Sweep) GC

  • Old 영역의 GC를 수행할 때 발생하는 STW를 최소화하기 위해 애플리케이션 스레드와 동시에(Concurrent) GC의 일부 단계 실행
  • 응답 시간을 우선시하는 GC지만, 메모리 단편화 문제와 CPU 사용량이 높은 단점이 있음
  • Java 9부터는 사용이 중단되었음

4️⃣ G1 (Garbage-First) GC

  • 전체 힙을 리전(Region)이라는 여러 개의 작은 구역으로 나눔
  • 전체 힙을 대상으로 GC를 수행하는 대신, 쓰레기가 가장 많이 쌓인 리전(Garbage-First)을 우선적으로 정리하여 STW를 예측 가능하게 관리
  • 처리량과 응답 시간을 균형 있게 맞춘 GC로 Java 9부터 기본 GC로 사용되고 있음

5️⃣ ZGC / Shenandoah GC

  • 최신 GC로, STW를 수 밀리초(ms) 단위로 최소화하는 것을 목표로 함
  • G1 GC보다 더 많은 작업을 애플리케이션 스레드와 동시에 수행하여 GC로 인한 지연을 거의 없앰
  • 대용량 힙 메모리(수백 GB 이상)를 사용하는 시스템에 적합

🤔 그렇다면 자바에서 Memory Leak이 절대 일어나지 않을까?

답은 아니다.

자바의 가비지 컬렉터는 Unreachable 객체, 즉 더 이상 참조되지 않는 객체들만 자동으로 회수한다. 프로그래머의 실수로 인해 더 이상 사용하지 않는 객체가 계속 Reachable 상태로 남아있는 경우, GC는 이 객체를 필요한 객체로 판단하여 메모리에서 제거하지 못한다.

주요 메모리 누수 발생 원인

1️⃣ 정적 컬렉션 객체 : static으로 선언된 List, Map 등의 컬렉션에 객체를 계속 추가만 하고 제거하지 않는 경우, static 객체는 GC Root이므로 이 컬렉션이 참조하는 모든 객체는 프로그램이 끝날 때까지 메모리에 남게 됨

2️⃣ 닫지 않은 리소스 : Stream, DB Connection, Socket 등의 리소스를 사용한 뒤 close() 메서드를 통해 명시적으로 닫아주지 않으면, 관련 객체들이 메모리에 계속 남아 누수를 유발할 수 있음
-> try-with-resources 구문을 사용해서 자동으로 닫아주는 습관을 들이자!

3️⃣ 내부 클래스와 외부 클래스의 참조 : 내부 클래스의 인스턴스가 외부 클래스의 인스턴스를 참조하고 있는데, 이 내부 클래스 인스턴스가 오랫동안 살아남는 경우 외부 클래스 인스턴스도 GC의 대상이 되지 못함

4️⃣ 캐시 구현의 오류 : 객체를 캐시에 저장하고, 더 이상 필요 없어진 후에도 캐시에서 비워주지 않으면 메모리 누수가 발생함

Generational Hypothesis

대부분의 현대적인 GC는 세대 가설(Generational Hypothesis)이라는 두 가지 경험적인 관찰 결과를 기반으로 설계되었다. 이 가설은 애플리케이션에서 생성되는 객체들의 생명주기 패턴을 분석하여 GC의 효율을 높이기 위해 탄생했다.

가설 1. 대부분의 객체는 생성된 직후, 곧바로 접근 불가능한 상태(Unreachable)가 된다. (Weak Generational Hypothesis)

객체들은 대부분 잠깐 사용되고 버려지는 경우가 많다는 의미이다. 예를 들면, 메서드 내에서 선언된 지역 변수가 참조하는 객체는 그 메서드가 종료됨과 동시에 더 이상 필요 없게 되는 경우가 많다.

💭 따라서..

JVM의 힙 영역을 Young GenerationOld Generation으로 나눈다. 새로 생성된 객체는 모두 Young 영역에 할당하고, 이 영역에서 가비지 컬렉션(Minor GC)을 더 자주, 빠르게 수행한다. 대부분의 객체가 금방 사라지므로, 좁은 영역만 집중적으로 정리하여 전체 GC 비용을 줄일 수 있다.

가설 2. 오래된 객체에서 젊은 객체로의 참조는 거의 발생하지 않는다. (Strong Generational Hypothesis)

객체 참조의 방향은 대부분 생성 시점이 비슷한 젊은 객체에서 젊은 객체로 향하거나, 젊은 객체에서 오래된 객체로 향하는 경우가 많다는 의미이다. 반대로 이미 오랫동안 살아남은 Old 영역의 객체가 방금 생성된 Young 영역의 객체를 참조하는 경우는 드물다는 것이다.

💭 따라서..

Young 영역의 GC를 수행할 때, 전체 Old 영역의 객체들을 모두 스캔할 필요가 없어진다. 만약 Old 영역의 객체가 Young 영역의 객체를 참조하게 되면, 이 정보를 카드 테이블이라는 별도의 공간에 기록해둔다. 그리고 Minor GC 시에는 전체 Old 영역 대신 이 카드 테이블만 확인하여 GC Root에 포함시켜 스캔 범위를 대폭 줄일 수 있다. 이는 Minor GC의 속도를 획기적으로 향상시켜준다.

profile
백엔드 개발자 연습생

0개의 댓글