본문 바로가기

CS/JVM

[JVM 밑바닥까지 파헤치기] JVM GC의 메모리 관리: 힙과 메서드 영역

GC(Garbage Collector)가 관리하는 메모리 영역은 힙(Heap)메서드 영역(Method Area)입니다. 각 영역은 서로 다른 방식으로 회수가 이루어지는데, 이번 글에서는 그 차이와 핵심 개념들을 정리해보겠습니다.

 


힙 영역: 도달 가능성 분석

왜 참조 카운팅이 아닌가?

참조 카운팅(Reference Counting) 방식은 구현이 단순하지만, 순환 참조 문제가 있습니다.

class Node {
    Node next;
}

Node a = new Node();
Node b = new Node();
a.next = b;
b.next = a;
a = null;
b = null;
// 참조 카운팅: a, b 모두 참조 카운트 1 → 수거 불가
// 도달 가능성 분석: GC Roots에서 도달 불가 → 수거 가능

이런 이유로 JVM은 도달 가능성 분석(Reachability Analysis) 알고리즘을 사용합니다.

 

도달 가능성 분석이란?

핵심 아이디어는 간단합니다: GC Roots에서 시작해서 참조 체인을 따라갈 수 없는 객체는 수거 대상이 됩니다.

 

GC Roots가 될 수 있는 객체들

  • 스택 프레임의 지역 변수와 파라미터: 현재 실행 중인 메서드들이 참조하는 객체
  • 정적 변수: 클래스의 static 필드가 참조하는 객체
  • JNI 참조: 네이티브 코드에서 생성된 전역/로컬 참조
  • 활성 스레드: 살아있는 Thread 객체 자체
  • 동기화 모니터: synchronized 블록에서 사용 중인 객체
  • JVM 내부 참조: 클래스 로더, 기본 예외 객체(NPE, OOM 등), 시스템 클래스

 

finalize()와 2단계 회수 (레거시)

도달 불가능 판정을 받아도, finalize()를 오버라이드한 객체는 한 번 더 기회를 받습니다. F-Queue에 등록되어 Finalizer 스레드가 실행한 후, 다음 GC에서 다시 검사합니다.

 

다만 finalize()Java 9에서 deprecated, Java 18에서 forRemoval 표시되었습니다. 새 코드에서는 Cleaner API를 사용하세요.

 

Cleaner는 정리 로직을 별도 객체에 분리하기 때문에, 원본 객체에 대한 this 참조가 없습니다. 따라서 부활 자체가 구조적으로 불가능하고, 2단계 회수 과정 없이 바로 정리됩니다.

  finalize() Cleaner
부활 가능 O X
회수 사이클 최소 2번 1번

 


메서드 영역: 타입 언로딩

힙과 다른 회수 방식

메서드 영역은 도달 가능성 분석이 아닌 타입 언로딩 조건 검사로 회수됩니다.

영역 회수 단위 판단 방식
개별 객체 GC Roots → 참조 체인 추적
메서드 영역 클래스 전체 세 가지 조건 모두 만족 여부

 

타입 언로딩의 세 가지 조건

클래스가 언로딩되려면 세 가지 조건이 모두 만족되어야 합니다:

  1. 해당 클래스의 모든 인스턴스가 힙에서 회수됨
  2. 해당 클래스를 로딩한 ClassLoader가 회수됨
  3. 해당 클래스의 Class 객체가 어디서도 참조되지 않음

 

왜 회수 대상이 거의 없는가?

일반적인 애플리케이션에서는 대부분의 클래스가 Bootstrap/System ClassLoader로 로딩됩니다. 이 로더들은 JVM 종료 전까지 살아있기 때문에, 언로딩 조건 자체가 충족되지 않습니다.

GC가 메서드 영역을 "안 하는" 게 아니라, 조건을 검사해봐도 언로딩할 클래스가 없는 것입니다.

 

바이트코드 프레임워크 환경에서는 다르다

CGLib, ASM, ByteBuddy, Javassist 같은 프레임워크는 런타임에 새로운 클래스를 생성합니다. Spring AOP, Hibernate 지연 로딩 등이 이 방식을 사용하죠.

Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(UserService.class);
enhancer.setCallback(new MethodInterceptor() { ... });
UserService proxy = (UserService) enhancer.create();
// → UserService$$EnhancerByCGLIB$$xxxx 같은 새 클래스가 Metaspace에 로딩됨

이런 환경에서는:

  • 동적 클래스가 자체 ClassLoader를 통해 로딩됨
  • 언로딩 조건이 실제로 충족될 수 있음
  • 제대로 정리 안 되면 Metaspace 누수로 이어짐

 

안전장치 및 모니터링

# Metaspace 무한 증가 방지
-XX:MaxMetaspaceSize=256m
-XX:MetaspaceSize=128m

# 클래스 언로딩 활성화 확인
-XX:+ClassUnloading
-XX:+ClassUnloadingWithConcurrentMark  # G1

다만 이 설정만으로 누수가 해결되는 건 아닙니다. 클래스 로더 누수나 캐시 미정리 등 근본 원인을 파악하는 게 중요합니다.


정리

구분 메서드 영역
회수 단위 개별 객체 클래스 전체
알고리즘 도달 가능성 분석 타입 언로딩 조건 검사
회수 빈도 자주 (Minor/Major GC) 드물게
주의 상황 일반적인 객체 관리 바이트코드 프레임워크 사용 시

 

GC의 핵심은 힙의 도달 가능성 분석이지만, 동적 클래스 생성이 많은 환경에서는 메서드 영역 관리도 신경 써야 합니다.