가비지 컬렉터 - JVM 밑바닥까지 파헤치기

이상윤·2026년 3월 2일

책 이름이 밑바닥까지 파헤치기라는걸 1회독을 끝내고 알아버렸다..

이번 포스팅에서는 가비지 컬렉터에 대해 요점을 정리해 보려 한다.

가비지 컬렉션은 어떤 메모리를 회수해야 하나?

우선, 객체가 죽었는지 판단 해야한다. 자바에서는 이를 위해 도달 가능성 분석 알고리즘을 사용하고 있다.

도달 가능성 분석 알고리즘

이 알고리즘은 메모리 관리의 시작점을 GC 루트라고 불리는 특별한 객체들의 집합으로 설정하는 것에서 시작한다. 가비지 컬렉터는 GC 루트를 기점으로 연결된 객체들을 따라가며 참조 체인을 만듭니다. 이 체인에 묶여 있으면 도달 가능한 객체로 보아 메모리에 유지하고, 연결이 끊겨 도달할 수 없는 객체는 회수 대상으로 판단해 회수한다.

참조 4개

원래는 참조 타입 데이터의 값이 다른 메모리 조각의 시작 주소를 뜻한다면 참조 한다고 하였지만 JDK 1.2부터는 이 정의가 확장되어 4단계로 나뉘었다.

  1. 강한 참조
    코드 수준에서 new 연산자처럼 참조를 할당하는 걸 말한다. 강한 참조가 남아있는 객체는 가비지 컬렉터가 회수하지 않는다.
  2. 부드러운 참조
    유용하지만 필수는 아닌 객체를 표현하며, 부드러운 참조만 남아있는 객체는 메모리가 넉넉하다면 살아있지만 OOM이 나기 전에 두 번째 회수 목록에 추가된다.
  3. 약한 참조
    부드러운 참조와 비슷하지만 연결 강도가 더 약하다. 약한 참조뿐인 객체는 메모리가 넉넉하더라도 다음 GC때 회수된다.
  4. 유령 참조
    객체 수명에 아무런 영향을 주지 않으며, 이를 통해 객체 인스턴스를 가져오는 것도 불가능하다. 이를 참조하는 유일한 목적은 대상 객체가 회수될 때 알림을 받기 위해서이다.

가비지 컬렉션 알고리즘

약한 세대 가설, 강한 세대 가설, 세대 간 참조 가설

  • 약한 세대 가설
    대다수의 객체는 일찍 죽는다.
  • 강한 세대 가설
    GC에서 살아남은 횟수가 늘어날수록 객체는 앞으로도 살아남을 확률이 커진다.
  • 세대 간 참조 가설
    세대 간 참조의 개수는 동일 세대 안에서의 참조 개수보다 적다. 세대 간 참조의 수는 적으므로, 이를 관리하기 위해 신세대에 기억 집합이하는 맵을 하나 두어 세대 간 참조가 존재하는 구세대 조각의 객체들만 GC 루트에 추가한다.

마크-스윕, 마크-카피, 마크-컴팩트

  • 마크-스윕
    이름처럼 이 알고리즘은 먼저 회수할 객체들에 전부 표시를 한 다음, 표시된 객체들을 쓸어 담는 식이다. 간단하지만, 단점이 두 가지 존재한다.
    첫째로, 실행효율이 일정하지 않다. 객체가 많아질수록 표시와 회수 작업량이 늘어나기 때문이다.
    두 번째로는 단편화가 심해진다는 점이다. 가비지 컬렉터가 쓸고 간 자리에는 불연속적인 메모리 파편이 만들어지기 때문이다.
  • 마크-카피
    이는 회수할 객체가 많아질수록 효율이 떨어지는 마크-스윕의 단점을 해결하기 위해, 우선 힙을 반으로 나눠 한 번에 한 블록만 사용한다. 이때 한쪽 블록이 꽉 차면 살아남은 객체들을 다른 블록으로 복사하고 기존 블록을 한 번에 청소한다.
    이 방식은 메모리 파편화 문제를 해결하고 구현이 쉬우며 실행 효율도 좋지만 가용 메모리 공간이 절반밖에 되지 않는다는 명백한 단점을 가지고 있다.
  • 마크-컴팩트
    이 방식은 마크-스윕 방식과 마크 과정은 똑같다. 하지만 다음 컴팩트 단계에서 생존한 모든 객체를 메모리 한쪽 끝으로 모은 후 나머지 공간을 한꺼번에 비운다.
    이러한 객체 이동은 기존 참조들을 모두 변경해야 하고, 사용자 애플리케이션을 모두 멈춘 이후에 진행해야 하므로 부담이 크다.

가비지 컬렉터

시리얼 & 시리얼 올드

가비지 컬렉션을 단 하나의 GC스레드로 처리하며, 신세대에서는 마크-카피 알고리즘을 사용하고 구세대에서는 마크-컴팩트 알고리즘을 사용한다.
가비지 컬렉션동안 다른 사용자 스레드가 모두 정지해야하는 단점이 존재한다. 하지만 시리얼 컬렉터는 다른 컬렉터의 단일 스레드 알고리즘보다 간단하고 효율적이라서 프로세서 또는 코어 수가 적은 환경이라면 시리얼 컬렉터는 스레드 상호 작용에 의한 오버헤드가 없다는 장점이 있다.

G1GC

G1은 힙 메모리의 어느 곳이든 회수 대상에 포함할 수 있다. 이를 회수 집합(CSet)이라고 한다.

G1GC는 어느 세대에 속하느냐가 아닌 어느 영역에 쓰레기가 가장 많으냐를 회수 기준으로 하며, 이를 G1의 혼합 모드라고 한다.

G1은 연속된 자바 힙을 동일 크기의 여러 독립 리전으로 나눠서 각 리전을 고정된 역할이 아닌 필요에 따라 에덴, 생존자, 구세대 등으로 사용한다. 그래서 모든 리전은 새로 생성된 객체를 담을수도, 큰 객체를 담을수도, 오래 살아남은 객체를 담을수도 있다. 또한 이러한 구조는 G1의 정지 시간 예측 모델을 가능하게 한다.

처리 방식을 좀 더 구체적으로 살펴보면, G1은 각 리전의 쓰레기 누적값을 추적하며 이 값이란 가비지 컬렉션으로 회수할 수 있는 공간의 크기와 회수에 드는 시간의 경험값이다.

G1의 정지 시간 예측 모델의 이론적 기초는 감소 평균이며, 이는 GC동안 리전별 회수 시간, 리전별 기억 집합,에서 더럽혀진 카드 개수 등 측정할 수 있는 각 단계의 소요 시간을 기록해 이 정보로부터 평균, 표준 편차, 신뢰도 같은 통계를 분석한다. 감소 평균은 최근의 데이터에 가중치를 더 줘서 계산하는 방식이다.

G1의 가비지 컬렉션 과정은 대략 다음 4단계와 같다.

  1. 최초 표시
    GC 루트가 직접 참조하는 객체들을 표시하고 TAMS 포인터의 값을 수정한다(시작 단계 스냅숏을 수정한다). 사용자 스레드를 정지해야 하지만 시간이 매우 짧고, 마이너 GC가 실행되는 시간을 틈타 동시에 끝나므로 추가 STW는 없다.
  2. 동시 표시
    GC 루트로부터 시작하여 객체들의 조작 도달 가능성을 분석하고, 전체 힘의 객체 그래프를 재귀적으로 스캔하며 회수할 객체를 찾는다.이는 사용자 스레드와 동시에 수행된다. 스캔이 끝나면, 시작 지점 스냅숏과 비교하여 변경된 객체를 찾는다.
  3. 재표시
    또 한번 사용자 스레드를 멈춘다. 시작 단계 스냅숏 이후 변경된 소수의 객체만 처리하므로 빠르다.
  4. 복사 및 청소
    통계 데이터를 기초로 리전들을 회수 순서대로 줄세우고, 목표한 정지 시간에 맞춰 계획을 세운다. 목표 리전에서 회수 후 살아남은 객체들을 새 리전으로 이주시킨다. 사용자 스레드가 멈추지만, 병렬로 처리한다.

동시 표시단계를 제외한 모든 과정에서 사용자 스레드를 멈춰야 하지만, G1의 목표였던 지연시간을 제어하는 동시에 높은 처리량을 달성한다. 를 만족하였다. 여기서 정지 시간을 사용자가 설정할 수 있지만, 너무 터무니없이 적게 설정하면 쓰레기가 쌓여 전체 GC가 일어나 오히려 성능이 나빠질 수 있으니 주의하자.

ZGC

ZGC는 오라클이 개발한 저지연 가비지 컬렉터이다.

ZGC의 목표는 STW시간을 최대한 억제하면서 힙 크기에 상관없이 가비지 컬렉션으로 인한 정지 시간을 10 밀리초 안쪽으로 줄이고자 했다.

ZGC는 세대 구분 없이 리전 기반 메모리 레이아웃을 사용한다. 낮은 지연 시간을 최우선 목표로 하며, 동시 마크-컴팩트 알고리즘을 구현하기 위해 읽기 장벽, 컬러 포인터, 매모리 다중 매핑 기술을 이용하는 가비지 컬렉터이다.

동작 방식은 다음과 같다.

  1. 동시 표시
    G1과 비슷하게 객체 그래프를 탐색하며 도달 가능성을 분석하지만, 다른 점은 객체가 아니라 포인터에서 이뤄진다는 점이다. Marked0과 Marked1플래그가 이 표시 단계에서 갱신된다.
  2. 동시 재배치 준비
    청소해야 할 리전들을 선정하여 재배치 집합을 만든다. G1과는 다르게, ZGC는 가비지 컬렉션 때마다 모든 리전을 스캔하기 때문에 ZGC의 재배치 집합에서는 살아남은 객체 이주 후 리전 자체를 회수할지 여부만 결정한다. 그리고 앞 단계의 표시 대상이 힙 전체이므로 재배치 집합에 포함되지 않은 리전들도 회수될 수 있다.
  3. 동시 재배치
    이 단계에서 재배치 집합 안의 생존 객체들을 새로운 리전으로 이주시키고, 재배치 집합에 속한 각 리전의 포워드 테이블에 옛 객체와 새 객체의 이주 관계를 기록한다. 만약 사용자 스레드가 재배치 집합에 포함된 객체에 접근하려고 하면, 미리 설정해 둔 메모리 장벽이 끼어들어 새로운 객체로 포워드시키고 해당 참조의 값도 새로운 객체를 직접 가리키도록 갱신한다.
  4. 동시 재매핑
    재매핑이란 힙 전체에서 재배치 집합에 있는 옛 객체들을 향하는 참조 전부를 갱신하는 작업이다.

Generational ZGC

JDK 21부터 세대 구분 ZGC가 추가되었고, 다중 매핑 메모리 제거, 다양한 장벽 최적화, 이중 버퍼를 이용한 기억 집합 관리, 밀집도 기반 리전 처리, 거대 객체 처리, ZPage 할당 방식 등 여러가지가 바뀌었다. 이들에 관해서는 따로 정리 해 두었으니 궁금하다면 한번 들어가서 확인해보자.

0개의 댓글