한 줄 정의

핵심 메시지

가비지 컬렉션은 “활성 객체는 절대 수집하지 않는다”와 “모든 가비지를 수집한다” 라는 두 규칙 사이에서 트레이드오프를 조정하는 시스템입니다. 핫스팟은 약한 세대 가설을 기반으로 힙을 영역별로 나누고, oop·TLAB·카드 테이블·반구형 대피 같은 기술을 조합해 짧은 수명의 객체를 거의 무료로 수집합니다.

쉽게 말하면

가비지 컬렉션은 호텔 청소 시스템과 같습니다. 손님(객체)이 체크아웃하면 자동으로 방을 비우고 새 손님을 받을 수 있게 정리해줍니다. 직접 방을 비우러 다니지 않아도 되니 편하지만, 청소부가 일하는 동안 호텔 로비가 잠깐 멈출 수 있습니다(STW).

핫스팟은 똑똑한 호텔 매니저처럼 일합니다. 손님 대부분이 1박만 묵고 떠난다는 사실(약한 세대 가설)을 알기 때문에, 단기 투숙 구역(에덴)과 장기 투숙 구역(영구 세대)을 나누어 관리합니다. 단기 구역은 비우는 비용이 거의 들지 않고, 살아남는 객체만 골라 장기 구역으로 옮깁니다.

왜 중요한가?

자바의 가비지 컬렉션의 핵심은 시스템 내 모든 객체의 정확한 수명 주기를 이해하지 않아도, 런타임이 이를 대신 관리한다는 점입니다. 더 이상 필요하지 않은 객체를 자동으로 제거하며, 회수된 메모리를 초기화 후 재사용할 수 있습니다.

그러나 자동화는 공짜가 아닙니다. GC가 어떻게 동작하는지 모르면 성능 문제의 원인을 찾을 수 없습니다. 메모리 누수가 의심될 때, 일시 정지 시간이 SLA를 위반할 때, 처리량이 떨어질 때 — 이 모든 상황은 GC의 내부 동작을 이해해야 진단할 수 있습니다.

또한 GC 알고리즘 선택은 운영 환경의 처리량·지연·메모리 사용량 트레이드오프를 직접 결정합니다. 백엔드 엔지니어가 “ParallelGC 쓸지 G1 쓸지 ZGC 쓸지”를 의식적으로 고를 수 있어야 한다는 뜻입니다.

이 챕터의 범위

가비지 컬렉션은 매우 방대한 주제이므로 이 챕터에서는 기본적인 입문 내용만 다룹니다. 세대별 힙·약한 세대 가설·TLAB·카드 테이블 등 핵심 메커니즘을 익히는 단계입니다.

핵심 내용

4.0 GC가 지키려는 두 가지 규칙

모든 가비지 컬렉션 구현은 다음 두 가지 기본 규칙을 준수하려고 노력합니다.

  • 활성 객체는 절대 수집되지 않아야 합니다.
  • 알고리즘은 모든 가비지를 수집해야 합니다.

두 원칙 중 첫 번째가 훨씬 더 중요합니다. 활성 객체를 수집하면 세그멘테이션 오류(segmentation fault)나 더 나쁜 경우 데이터가 조용히 손상(silent corruption)될 수 있습니다. 자바의 GC 알고리즘은 프로그램이 여전히 사용 중인 객체를 절대 수집하지 않도록 보장해야 합니다.

두 번째 규칙은 비교적 유연하게 적용됩니다. 세대별 GC는 종종 오래된 객체를 힙에 오랫동안 남겨두고, 전체 GC 사이클이 실행될 때까지 이를 수집하지 않습니다. 또 어떤 알고리즘은 특정 메모리 영역에 가비지가 충분히 쌓이지 않았다면 그 영역을 수집하지 않기도 합니다.

왜 두 번째 규칙이 더 유연한가

활성 객체를 잘못 수집하는 것은 즉시 치명적 오류이지만, 가비지를 일부 남겨두는 것은 메모리 비효율 정도의 비용입니다. 트레이드오프의 비대칭성이 알고리즘 설계의 모든 결정을 좌우합니다.

전반적으로 프로그래머가 저수준 메모리 관리를 직접 제어하는 대신, GC를 통해 자동 처리하는 방식은 자바를 실무 중심의 ‘블루칼라 언어’로 설계했다는 제임스 고슬링의 개념을 반영합니다.

4.1 마크 앤 스윕 소개

대부분의 자바 프로그래머는 자바의 GC가 마크 앤 스윕(mark and sweep) 알고리즘을 기반한다는 사실을 알고 있지만, 이 과정이 실제로 어떻게 작동하는지에 대한 세부 사항은 잘 떠올리지 못합니다.

기본 알고리즘

기본적인 마크 앤 스윕은 할당된 객체 리스트를 사용하여, 회수되지 않은 객체들의 포인터를 유지하는 방식으로 동작합니다. 과정은 다음과 같습니다.

  1. 할당된 리스트를 순회하며 마크 비트(mark bit) 를 초기화합니다.
  2. 힙으로의 모든 포인터를 시작점으로 하여 접근 가능한 모든 객체를 찾습니다.
  3. 도달한 각 객체에 마크 비트를 설정합니다.
  4. 할당된 리스트를 다시 순회하며, 마크 비트가 설정되지 않은 객체에 대해 다음을 수행합니다.
    • 힙에서 해당 객체의 메모리를 회수하여 자유 리스트(free list) 에 다시 추가합니다.
    • 객체를 할당된 리스트에서 제거합니다.
활성 객체 그래프

활성 객체는 보통 깊이 우선 탐색(depth-first search) 으로 탐색되며, 이렇게 만들어진 객체의 그래프를 활성 객체 그래프(live object graph) 라고 합니다. 이는 접근 가능한 객체의 추이적 폐쇄(transitive closure of reachable object) 라고도 부릅니다. 반대로 접근할 수 없는 객체는 죽은 객체라고 합니다.

graph LR
    Stack[스택 프레임] --> A[객체 1]
    A --> B[객체 2/3]
    A --> C[객체 4]
    D[객체 5<br/>고립된 가비지]
    E[객체 6<br/>고립된 가비지]

스택 프레임에서 시작해 참조를 따라가 도달할 수 있는 객체는 살리고, 도달할 수 없는 객체(5, 6)는 회수합니다.

jmap -histo로 힙 들여다보기

힙의 상태를 시각화하는 가장 기본적인 도구는 명령줄 도구인 jmap -histo입니다. 타입별로 할당된 바이트 수와 메모리 사용량을 차지하는 인스턴스 개수를 보여줍니다.

 num     #instances         #bytes  class name
   1:        20839       14983608  [B
   2:       118743       12370760  [C
   3:        14528        9385360  [I
   4:          282        6461584  [D
   5:       115231        3687392  java.util.HashMap$Node
   6:       102237        2453688  java.lang.String
   7:        68388        2188416  java.util.Hashtable$Entry
  10:        23688        1516032  com.mysql.jdbc.Co...$BooleanConnectionProperty
  13:        10040        1107896  java.lang.Class
  14:        44090        1058160  java.util.LinkedList$Node
도구 선택

힙 분석은 상황에 따라 적합한 도구가 다릅니다.

  • 정밀 분석: 이클립스 MAT 같은 강력한 도구
  • 실시간 시각화: VisualVM이나 VisualGC 플러그인
  • 시간에 따른 변화 추적: JFR(JDK Flight Recorder)이나 GC 로그

힙의 특정 순간 상태만으로는 충분히 정확한 분석을 하기 어렵습니다. 누수 여부, 힙 증가 패턴 같은 질문은 JFR이나 GC 로그처럼 시간축이 있는 도구가 필요합니다.

4.2 가비지 컬렉션 용어집

GC 알고리즘을 설명하는 데 사용되는 전문 용어는 다소 혼란스러울 수 있으며, 일부 용어의 의미는 시간이 지나면서 변경되기도 했습니다.

STW (Stop-The-World)

GC 사이클이 진행되는 동안 모든 애플리케이션 스레드를 멈춰야 합니다. 이는 애플리케이션 코드가 GC 스레드가 보는 힙 상태를 무효화하지 않도록 방지하는 역할을 합니다. 간단한 GC 알고리즘에서는 일반적으로 이런 방식을 사용합니다.

동시성 (Concurrent)

GC 스레드가 애플리케이션 스레드가 실행되는 동안에도 동작할 수 있습니다. 구현이 더 어렵고 계산 자원을 더 많이 소모합니다. 자바 9부터 핫스팟의 기본 GC는 G1(garbage first) 이며, G1은 일부 동시성 측면을 가지고 있습니다.

동시성은 슬라이딩 스케일이다

동시성이라는 용어는 이진 상태가 아니라 슬라이딩 스케일(sliding scale) 개념으로 이해하는 것이 가장 좋습니다. 사용 중인 컬렉터에 따라 컬렉션 작업 중 일부 또는 전부가 애플리케이션 스레드와 동시적으로 수행될 수 있습니다.

병렬 (Parallel)

GC 작업을 여러 스레드와 여러 코어를 사용해 실행합니다. 동시성과 병렬은 다른 개념입니다. 동시성은 애플리케이션 스레드와 GC가 동시에 도는 것이고, 병렬은 GC 자체가 여러 코어를 활용하는 것입니다.

정확성 (Exact)

힙 상태에 대한 타입 정보를 충분히 가지고 있어, 단일 사이클에서 모든 가비지를 수집할 수 있습니다. int 필드와 객체 참조 필드를 항상 구별할 수 있습니다.

보수성 (Conservative)

정확한 방식과 달리 타입 정보를 충분히 제공하지 않습니다. 그 결과 자원을 낭비하는 경우가 많고, 일반적으로 효율성이 낮습니다. 자바 가상 머신은 정확성을 채택합니다.

이동 (Moving)

이동형 컬렉터에서는 객체가 메모리 내에서 재배치될 수 있습니다. 즉 객체가 고정된 주소를 갖지 않습니다. 자바와 달리 원시 포인터를 직접 다룰 수 있는 환경에서는 이동형 컬렉터를 적용하기 어렵습니다.

압축 (Compaction)

GC 사이클이 끝날 때, 할당된 메모리(생존 객체)는 단일 연속 영역(보통 해당 영역의 시작 부분)에 배치되고, 새로운 객체를 기록할 수 있는 빈 공간의 시작 지점을 가리키는 포인터가 존재합니다. 압축 컬렉터는 메모리 단편화를 방지합니다.

대피 (Evacuation)

GC 사이클이 끝날 때, 수집된 영역은 완전히 비워지고, 모든 활성 객체가 다른 메모리 영역으로 이동(대피)됩니다. 우수한 대피형 컬렉터는 메모리 단편화를 방지합니다.

용어 혼용에 주의

다른 언어와 환경에서는 동일한 용어가 사용되지만, 일부 환경에서는 동시성(concurrent) 또는 병렬(parallel) 의 의미를 다르게 사용하거나, 이동(moving)복사(copying) 라고 부르는 경우가 있으니 주의해야 합니다.

4.3 핫스팟 런타임 소개

일반적인 GC 용어 외에도, 핫스팟은 구현에 특화된 추가적인 용어를 사용합니다. 자바에는 두 가지 종류의 값만 있다는 점을 기억하는 것이 중요합니다.

  • 기본 타입(primitive types): byte, int
  • 객체 참조(object references)

많은 자바 프로그래머는 객체에 대해 느슨하게 표현하는 경향이 있지만, 여기에서 중요한 점은 C++과 달리 자바는 일반적인 주소 역참조(dereference) 메커니즘을 제공하지 않는다는 것입니다. 대신 자바는 오직 오프셋 연산자(. 연산자) 를 사용하여 객체 참조에서 필드에 접근하거나 메서드를 호출할 수 있습니다.

또한 자바의 메서드 호출은 항상 값 복사 방식으로 이루어집니다. 객체 참조의 경우, 이는 힙에 있는 객체의 주소가 복사된다는 것을 의미합니다.

4.3.1 런타임에서 객체 표현

oop (ordinary object pointer)

핫스팟은 런타임에 자바 객체를 oop(ordinary object pointer)라는 구조를 통해 표현합니다. 이 oop는 C 언어에서 사용하는 진정한 포인터의 역할을 합니다. 이러한 포인터는 참조 타입의 로컬 변수에 저장될 수 있으며, 자바 메서드의 스택 프레임에서 자바 힙의 메모리 영역으로 연결됩니다.

핫스팟이 자바 힙을 관리할 때 시스템 호출을 사용하지 않는다는 점이 중요합니다. 핫스팟은 사용자 공간 코드에서 힙 크기를 관리합니다. 따라서 간단한 관찰만으로 GC 하위 시스템이 특정 성능 문제를 유발하는지 확인할 수 있습니다.

oop의 종류
종류표현 대상
instanceOop자바 클래스의 인스턴스
arrayOop배열 (길이를 나타내는 32비트 헤더 추가)
objArrayOop객체 배열
typeArrayOop기본 타입 배열
객체 헤더의 두 머신 워드

instanceOop의 메모리 레이아웃은 모든 객체에 존재하는 두 개의 머신 워드로 시작됩니다.

  • 마크 워드(mark word): 객체의 인스턴스 별 메타데이터를 가리키는 포인터입니다.
  • klass 워드: 클래스 전체의 메타데이터를 가리키는 포인터입니다.

klass 워드는 klass 메타데이터를 찾는 데 사용됩니다. 이 메타데이터는 자바 힙의 주요 부분 외부에 저장되지만, 자바 가상 머신 프로세스의 C 힙 외부에 있는 것은 아닙니다. klass는 자바 힙 외부에 존재하기 때문에 객체 헤더가 필요하지 않습니다.

klass vs Class

‘klass’의 시작 문자 k는 가상 머신 수준의 klass와 자바의 Class<?> 객체를 나타내는 instanceOop를 구분하기 위해 사용됩니다. 이 둘은 동일하지 않습니다.

기본적으로 klass는 클래스에 대한 가상함수 테이블(vtable)을 포함하고 있으며, 반면에 Class 객체는 (그 외의 여러 데이터와 함께) 리플렉션 호출에 사용되는 메서드 객체 배열을 포함합니다.

graph TD
    Entry[InstanceOop<br/>Entry 객체] --> klass1[klass Entry용]
    Entry --> vtable1[vtable]
    klass1 --> toString["toString()"]
    EntryClass[InstanceOop<br/>Entry.class] --> klass2[klass Class용]
    EntryClass --> methods[메서드 배열<br/>리플렉션]
    klass2 --> vtable2[vtable]
    klass2 --> getMethod["getMethod()"]
압축 oop

oop는 일반적으로 머신 워드 크기를 따릅니다. 따라서 구형 32비트 머신에서는 32비트이고, 현대적인 프로세서에서는 64비트입니다. 그러나 이는 상당한 메모리를 낭비할 가능성이 있습니다. 이를 완화하기 위해 핫스팟은 압축 oop라는 기술을 제공합니다.

-XX:+UseCompressedOops

해당 옵션이 설정되면(64비트 힙에서 기본적으로 활성화됩니다), 힙 내의 다음 oop가 압축됩니다.

  • 힙에 있는 모든 객체의 klass 워드
  • 참조 타입의 인스턴스 필드
  • 객체 배열의 각 요소

압축 oop 헤더는 네이티브 크기 마크 워드, (압축될 수 있는) klass 워드, 배열인 경우 길이를 나타내는 32비트 값, 정렬 규칙에 따라 필요한 경우 32비트 간격, 헤더 바로 뒤에 객체의 인스턴스 필드로 구성됩니다.

압축 oop는 거의 항상 켜둘 것

과거에는 지연에 매우 민감한 애플리케이션에서 압축 oop를 비활성화하면 성능이 개선되는 경우가 있었지만, 보통 힙 크기가 10~50%까지 증가했습니다. 측정 가능한 성능 이점을 얻는 애플리케이션은 매우 적으며, 대부분 ‘스위치 만지작거리기(fiddling with switches)’ 안티패턴의 사례입니다.

배열도 객체

배열도 객체이므로 자바 가상 머신의 배열도 oop로 표현됩니다. 따라서 배열에는 마크 워드와 klass 워드 외에도 배열의 길이를 나타내는 세 번째 메타데이터 워드가 추가됩니다. 자바에서 배열의 인덱스가 32비트 값으로 제한되는 이유도 여기에서 비롯됩니다(자바의 초기 버전은 순전히 32비트 아키텍처를 위해 설계되었기 때문입니다).

배열 길이 메타데이터의 의미

C와 C++에서는 배열의 길이를 알 수 없는 경우 함수에 추가 매개변수를 전달해야 하는 번거로움이 생깁니다. 자바는 배열 자체에 길이를 박아둠으로써 이 문제를 차단합니다.

oop 계층 구조

자바 22 기준으로 oop의 기본적인 상속 계층 구조는 다음과 같습니다.

oop (abstract base)
  instanceOop (instance objects)
    stackChunkOop
  arrayOop (array abstract base)
    objArrayOop (array object)
    objArrayOop (array of primitives)

4.3.2 가비지 컬렉션 루트

가비지 컬렉션 루트는 메모리의 앵커 포인트(anchor point)입니다. 이는 특정 메모리 풀 외부에서 시작하여 해당 메모리 풀 내부를 가리키는 외부 포인터를 의미합니다. 반면, 메모리 풀 내부에서 시작하여 같은 메모리 풀 내의 다른 위치를 가리키는 포인터는 내부 포인터로 간주됩니다.

GC 루트의 유형
  • 스택 프레임(stack frames)
  • 자바 네이티브 인터페이스(JNI, java native interface)
  • 레지스터 (JIT 컴파일된 코드에서 객체 참조가 레지스터로 이동했을 때 발생)
  • 코드 루트(자바 가상 머신 코드 캐시에서 유래)
  • 글로벌 변수
  • 로드된 클래스의 메타데이터

이 정의가 복잡하게 보이지만, 참조 타입의 로컬 변수가 가비지 컬렉션 루트의 가장 간단한 예입니다. 이 변수는 null이 아닌 한 항상 힙에 있는 객체를 가리킵니다.

GC 루트가 곧 살아남는 객체의 출발점

활성 객체 그래프를 만드는 시작점이 GC 루트입니다. 메모리 누수 분석에서 “왜 이 객체가 안 죽지?”를 물을 때 결국 추적해야 하는 것은 어떤 GC 루트에서 이 객체까지의 참조 체인입니다. 힙 덤프 분석 도구가 ‘GC root path’를 보여주는 이유가 여기에 있습니다.

4.4 할당과 수명 주기

자바 애플리케이션의 GC 동작을 결정하는 두 가지 주요 요인은 다음과 같습니다.

  • 할당 속도
  • 객체의 수명 주기
할당 속도

할당 속도는 일정 시간 동안 새로 생성된 객체가 사용하는 메모리의 양을 나타냅니다(보통 MB/s로 측정됨). 자바 가상 머신은 기본적으로 할당 속도를 직접 보여주지 않지만, 비교적 쉽게 추정할 수 있으며 JFR 같은 도구가 이를 제공할 수 있습니다.

객체의 수명 주기

반면, 객체의 수명 주기는 보통 측정하거나 추정하기가 훨씬 어렵습니다. 실제 애플리케이션에서 객체의 수명 주기를 제대로 이해하는 것은 매우 복잡하며, 따라서 수동 메모리를 관리하는 것을 반대하는 이유입니다. 결과적으로 객체의 수명 주기는 할당 속도보다 훨씬 더 근본적인 요소라고 할 수 있습니다.

GC는 메모리 회수와 재사용이다

가비지 컬렉션은 메모리 회수와 재사용으로도 생각할 수 있습니다. 객체의 수명이 짧기 때문에 동일한 물리적 메모리를 반복적으로 사용할 수 있다는 점이 GC 기술의 핵심 요소입니다.

객체는 생성되고 일정 시간 동안 존재하며, 이러한 객체의 수명이 짧다면 동일한 물리적 메모리를 반복적으로 재사용할 수 있습니다. 이 개념이 없다면 GC는 전혀 작동하지 않을 것입니다. GC는 다양한 트레이드오프를 균형 있게 조정해야 하며, 이러한 트레이드 오프는 객체의 수명 주기와 속도에 따라 중요하게 결정됩니다.

4.5 약한 세대 가설

자바 가상 머신의 메모리 관리의 중요한 부분 중 하나는 소프트웨어 시스템의 런타임에서 관찰되는 약한 세대 가설(weak generational hypothesis) 을 기반으로 합니다.

객체 수명의 이중분포

이 가설에 따르면, 자바 가상 머신과 유사한 소프트웨어 시스템에서 객체의 수명 분포는 이중분포(bimodal) 를 나타냅니다. 대부분의 객체는 수명이 매우 짧은 반면, 일부 객체는 훨씬 더 긴 수명을 가지는 경향이 있습니다.

관련된 메모리
   │
   │ ╱╲
   │╱  ╲
   │    ╲    ╱╲
   │     ╲__╱  ╲___
   └─────────────────→ 수명 주기
   짧은 수명 객체     긴 수명 객체

이 가설은 객체 지향 워크로드의 동작을 실험적으로 검증한 결과이며, 명확한 결론을 도출합니다. GC 힙은 짧은 수명을 가진 객체를 쉽고 빠르게 수집할 수 있도록 구조화되어야 하며, 긴 수명을 가진 객체는 짧은 수명의 객체와 분리되어야 합니다.

세대별 힙

이는 힙이 두 개의 별도 영역, 짧은 수명의 영역과 긴 수명의 영역을 가져야 하며, 각각 젊은 세대(young generation) 컬렉션과 전체(full) 컬렉션에서 별도로 수집되어야 한다는 것을 의미합니다.

주요 기술 중 하나는 최근 생성된 객체가 있는 영역(보통 에덴(Eden) 이라고 불림)에 마크 앤 스윕 컬렉션을 사용하는 것입니다. 스윕 단계에서 컬렉터는 대피(evacuation) 단계를 통해 생존한 객체를 긴 수명의 공간으로 이동시킵니다. 그런 다음 에덴 공간 전체를 한 번에 회수합니다.

graph LR
    Stack[스택] --> Young[젊은 세대<br/>에덴]
    Young -->|생존| Old[영구 세대]
    Young -->|대피| Old
    Stack --> Old
세대별 힙의 이점

세대별 힙을 사용하는 주요 이점 중 하나는 죽은 객체를 수집하는 데 비용이 전혀 들지 않는다는 점입니다. 죽은 객체에 대한 기록은 전혀 필요하지 않으며, 관심의 대상은 오직 활성 개체뿐입니다.

백엔드 관점에서의 함의

짧게 살다 죽는 객체(요청 단위로 생성되는 DTO·임시 컬렉션 등)는 GC가 거의 무료로 처리합니다. 반면 캐시·세션·연결 풀처럼 오래 사는 객체는 영구 세대에서 전체 GC 비용을 발생시킵니다. 요청 단위 객체를 굳이 캐싱하려고 들면 오히려 GC를 괴롭히게 됩니다.

4.6 핫스팟의 프로덕션 가비지 컬렉션 기술

4.6.1 스레드-로컬 할당 (TLAB)

에덴은 힙의 영역 중 대부분의 객체가 생성되는 영역이며, 수명이 매우 짧은 객체가 존재하는 곳입니다. 다음 GC 사이클이 실행되기 전까지 생존하지 못하는 객체는 이 영역을 벗어나지 않습니다. 따라서 에덴을 효율적으로 관리하는 것이 매우 중요합니다.

TLAB의 동작

할당 효율성을 개선하기 위해 핫스팟은 에덴을 여러 개의 버퍼로 분할하고, 각 애플리케이션 스레드에 독립적인 에덴 영역을 할당하여 새 객체를 생성하도록 합니다. 이러한 영역을 스레드-로컬 할당 버퍼(TLAB, thread-local allocation buffer) 라고 합니다.

이 방식의 장점은 각 스레드가 자신에게 할당된 버퍼에서만 작업하므로, 다른 스레드가 동일한 버퍼에서 할당 작업을 수행하는 것을 걱정할 필요가 없다는 점입니다.

O(1) 할당

애플리케이션 스레드가 TLAB를 독점적으로 제어할 수 있다는 것은, 자바 가상 머신 스레드에서 객체 할당이 O(1) 시간 복잡도로 이루어진다는 것을 의미합니다. 새 객체를 생성할 때, 해당 객체를 위한 저장 공간이 할당되고 스레드 로컬 포인터가 다음 사용 가능한 메모리 주소로 업데이트되기 때문입니다. C 코드로 설명하자면, 이는 단순히 포인터를 이동하는 작업으로, ‘다음 사용 가능’ 포인터를 앞으로 옮기는 추가 명령 하나로 처리됩니다.

graph LR
    T1[스레드 A] --> B1[TLAB A<br/>포인터: ★ 다음 위치]
    T2[스레드 B] --> B2[TLAB B<br/>포인터: ★ 다음 위치]
    T3[스레드 C] --> B3[TLAB C<br/>포인터: ★ 다음 위치]
    B1 -.->|Eden 공간| Eden
    B2 -.->|Eden 공간| Eden
    B3 -.->|Eden 공간| Eden

TLAB 크기는 동적이다

핫스팟은 애플리케이션 스레드에 할당하는 TLAB의 크기를 동적으로 조정합니다. 특정 스레드가 메모리를 빠르게 소모하는 경우, 해당 스레드에 더 큰 TLAB을 할당하여 버퍼를 제공하는 과정에서 발생하는 오버헤드를 줄일 수 있습니다.

TLAB가 꽉 차면

애플리케이션 스레드가 현재 TLAB를 모두 채우면, 자바 가상 머신은 에덴의 새로운 영역을 할당하여 새로운 TLAB에 대한 포인터를 제공합니다. 이는 자바 가상 머신이 더 이상 할당할 TLAB가 없을 경우, 젊은 세대 GC가 발생한다는 것을 뜻합니다.

4.6.2 반구형 컬렉션

특히 주목할 만한 대피형 컬렉터의 사례 중 하나는 반구형 대피 컬렉터(hemispheric evacuating collector) 입니다. 이 유형의 컬렉터는 보통 같은 크기의 두 개 공간을 사용합니다. 이 방식의 핵심 개념은 한 공간을 실제로 오래 지속되지 않는 객체의 임시 보관 장소로 활용하는 것입니다. 이를 통해 짧은 수명의 객체가 영구 세대(tenured generation)에 혼입되는 것을 방지하고, 전체 GC의 빈도를 줄일 수 있습니다.

반구형의 기본 속성
  • 컬렉터가 현재 활성화된 반구를 수집할 때, 객체는 압축 방식으로 다른 반구로 이동되며, 수집된 반구는 재사용을 위해 비워집니다.
  • 공간의 절반은 항상 완전히 비워진 상태로 유지됩니다.

이 방식은 컬렉터의 반구형 영역에 실제로 저장할 수 있는 메모리의 두 배를 사용하게 됩니다. 다소 비효율적으로 보일 수 있지만, 공간 크기가 과도하지 않다면 유용하게 활용됩니다. 핫스팟은 에덴 공간과 결합된 이 반구형 방식을 젊은 세대 컬렉터에 적용합니다.

서바이버 공간

핫스팟 젊은 힙의 반구형 영역은 서바이버 공간(survivor space) 이라고 합니다. VisualGC를 통해 확인할 수 있듯이, 서바이버 공간은 일반적으로 에덴 공간보다 상대적으로 작으며, 젊은 세대 컬렉션이 진행될 때마다 역할이 교대됩니다(S0 ↔ S1).

[Eden]  →  [S0 / S1]  →  [Old Gen]
   ↑         ↑              ↑
 신규 생성  살아남은 객체   오래 생존

4.6.3 클래식 핫스팟 힙

이제 모든 내용을 종합하여 핫스팟 힙의 기본적인 특징을 설명합니다.

  • 각 객체의 ‘세대 카운트’(지금까지 생존한 GC 수)를 추적합니다.
  • 큰 객체를 제외하고, 새 객체는 ‘에덴’ 공간(또는 ‘영 세대(nursery)‘)에 생성되며, 생존한 객체는 두 개의 서바이버 공간 중 하나로 이동됩니다.
  • 메모리의 별도 영역(오래되었거나 영구한 세대)을 유지하여, 일정 기간 생존한 객체를 저장합니다. 이러한 객체는 오래 지속될 가능성이 높다고 간주됩니다.
graph LR
    Eden[에덴<br/>신규 객체] -->|생존| S0[서바이버 0]
    Eden -->|생존| S1[서바이버 1]
    S0 -->|일정 횟수 생존| Old[영구 세대]
    S1 -->|일정 횟수 생존| Old

각 영역이 연속적(contiguous) 으로 구성된다는 점에 주목할 필요가 있습니다.

카드 테이블

메모리를 세대별 수집(generational collection)으로 나누는 것은 핫스팟이 마크 앤 스윕 컬렉션을 구현하는 방식에 몇 가지 추가적인 영향을 미칩니다. 그중 중요한 기술 중 하나는 외부에서 젊은 세대를 가리키는 포인터를 추적하는 것입니다.

이를 통해 GC 사이클이 전체 객체 그래프를 모두 순회하지 않고도 여전히 살아 있는 젊은 객체를 효율적으로 식별할 수 있습니다.

약한 세대 가설의 보조 원칙

‘오래된 객체에서 젊은 객체로의 참조는 상대적으로 적다’는 약한 세대 가설(weak generational hypothesis)의 보조적인 원칙으로 자주 언급됩니다.

이 과정을 지원하기 위해 핫스팟은 카드 테이블(card table) 이라는 구조를 유지합니다. 카드 테이블은 오래된 세대의 객체가 젊은 객체를 가리킬 가능성을 기록하는 데 사용됩니다. 이는 기본적으로 자바 가상 머신에서 관리되는 바이트 배열로, 배열의 각 요소는 오래된 세대 공간의 512바이트 영역을 나타냅니다.

카드 테이블의 진화형

나중에 카드 테이블보다 더 정교한 구조인 기억 집합(remembered set) 이 등장합니다. G1 GC가 사용하는 구조입니다.

쓰기 장벽

카드 테이블의 핵심은 오래된 객체 o의 참조 타입 필드가 수정될 때, o가 포함된 카드의 카드 테이블 항목을 더티(dirty) 로 표시하는 것입니다. 핫스팟은 참조 필드가 업데이트될 때 간단한 쓰기 장벽(write barrier) 을 사용하여 이를 처리합니다. 이는 필드 값이 저장된 후 다음과 같은 코드가 실행되는 방식으로 작동합니다.

cards[*oop >> 9] = 0;

카드의 더티 값은 0이며, 9비트를 오른쪽으로 이동하면 카드 테이블의 크기가 512바이트임을 알 수 있습니다.

영역 기반 컬렉터로의 진화

또한, 젊은 세대와 오래된 세대 영역을 연속적으로 관리하는 힙 구조에 대한 설명은 과거의 방식이라는 점을 기억해야 합니다. 이는 자바 컬렉터가 전통적으로 메모리를 관리하던 방식입니다. 그러나 현대적인 컬렉터인 G1은 세대를 연속적인 저장소로 관리하지 않습니다. 대신 세대에 속하더라도 서로 인접하지 않아도 되는 영역(region) 기반 구조를 사용합니다.

핫스팟에서 제공되는 컬렉터 대부분은 대피형 컬렉터지만, 모든 컬렉터가 그런 것은 아닙니다.

JVM의 자체 메모리 관리

C/C++와 같은 환경과는 달리, 자바는 동적 메모리를 관리하기 위해 운영 체제를 사용하지 않습니다. 대신, 자바 가상 머신은 프로세스 시작 시 메모리를 미리 할당(또는 예약)하고 사용자 공간에서 단일 연속 메모리 풀을 관리합니다.

이 메모리 풀은 특정 용도를 가진 여러 영역으로 나뉘며, 객체가 위치하는 주소는 컬렉터가 객체를 이동시키면서 종종 변경됩니다. 이러한 객체 이동을 수행하는 컬렉터를 ‘대피형 컬렉터(evacuating collector)’ 라고 부릅니다.

4.7 병렬 컬렉터

자바 8 또는 이전 버전에서 자바 가상 머신의 기본 GC는 병렬 컬렉터였습니다. 이 컬렉터들은 젊은 세대와 전체 컬렉션 모두에서 STW 방식으로 작동하며, 처리량 최적화에 중점을 둡니다. 애플리케이션 스레드를 모두 중지한 후, 병렬 컬렉터는 사용 가능한 모든 CPU 코어를 활용하여 메모리를 가능한 한 빠르게 수집합니다.

병렬 컬렉터의 종류
컬렉터특징
병렬 가비지 컬렉션(ParallelGC)젊은 세대를 위한 가장 간단한 컬렉터
ParNew병렬 GC의 변형된 버전으로, 더 이상 사용되지 않는 동시 마크 스윕 컬렉터와 함께 사용됨
ParallelOld영구 세대를 위한 병렬 컬렉터

병렬 컬렉터는 여러 면에서 서로 비슷합니다. 가능한 한 빠르게 활성 객체를 식별하기 위해 여러 스레드를 사용하도록 설계되었으며, 최소한의 기록 관리(bookkeeping)로 작동하도록 만들어졌습니다.

자바 17부터의 변화

자바 17부터 동시 마크 스윕(CMS) 컬렉터가 제거되었으며, 하나의 병렬 GC 컬렉션만 존재합니다. 이 병렬 GC 컬렉션은 젊은 세대와 오래된 세대의 병렬 컬렉션으로 구성됩니다.

4.7.1 젊은 병렬 컬렉션

가장 일반적인 컬렉션 유형은 젊은 세대 컬렉션입니다. 이는 보통 스레드가 에덴에 객체를 할당하려고 시도했을 때 TLAB에 충분한 공간이 없고, 자바 가상 머신이 해당 스레드에 새로운 TLAB을 할당할 수 없는 경우에 발생합니다.

이런 상황이 발생하면 자바 가상 머신은 모든 애플리케이션 스레드를 중지할 수밖에 없습니다. 한 스레드가 할당할 수 없는 상태라면, 곧 모든 스레드가 할당할 수 없는 상태에 도달하기 때문입니다.

비-TLAB 할당의 위험

스레드는 TLAB 외부에서도 메모리를 할당할 수 있습니다(메모리 블록의 사이즈가 클 경우). 이상적인 상황은 비-TLAB 할당 버퍼 할당의 비율이 낮을 때입니다. 예를 들어, 수명이 짧은 큰 객체를 너무 많이 할당하면 추가적인 전체 GC를 유발하게 되며, 이는 젊은 세대 컬렉션보다 비용이 더 많이 들기 때문에 매우 중요합니다.

애플리케이션 스레드가 모두 중지되면, 핫스팟은 젊은 세대(즉, 에덴과 현재 비어 있지 않은 서바이버 공간)를 확인하고, 가비지가 아닌 모든 객체를 식별합니다. 이 과정은 GC 루트(그리고 오래된 세대로부터 오는 GC 루트를 식별하기 위한 카드 테이블)를 시작점으로 하여 병렬 마킹 스캔을 수행합니다.

대피와 세대 카운트 증가

병렬 가비지 컬렉터는 생존한 모든 객체를 현재 비어 있는 서바이버 공간으로 이동시키며, 이때 각 객체의 세대 카운트를 함께 증가시킵니다. 이동이 끝나면 에덴과 방금 대피해 비워진 서바이버 공간을 모두 재사용 가능한 공간으로 표시합니다.

graph LR
    subgraph Before["GC 시작"]
        Eden1[에덴<br/>살아있는 객체 + 가비지]
        S0_1[S0<br/>이전 GC의 생존자]
        S1_1[S1<br/>비어 있음]
    end
    subgraph After["GC 종료"]
        Eden2[에덴<br/>비어 있음 · 재사용 가능]
        S0_2[S0<br/>비어 있음 · 재사용 가능]
        S1_2[S1<br/>모든 생존자<br/>세대 카운트 +1]
    end
    Before --> After

이 접근 방식은 약한 세대 가설을 최대한 활용하여 활성 객체만 처리하도록 설계되어 있습니다. 죽은 객체는 아예 건드리지 않기 때문에, 마킹 단계의 길이는 생존 객체의 (적은) 수에 비례합니다. 그래서 작은 크기의 젊은 세대에 적은 수의 생존자만 남는 일반적인 워크로드에서는 STW 정지 시간이 밀리초 단위로 짧습니다.

4.7.2 오래된 병렬 컬렉션 (ParallelOld)

ParallelOld 컬렉터는 자바 8까지 오래된 세대를 위한 기본 컬렉터였으며, 지속적인 처리량 성능을 중시하는 일부 애플리케이션에서는 여전히 G1보다 더 나은 성능을 보여줍니다.

자바 11 이상의 기본값

자바 11 이상부터는 G1 컬렉터가 기본으로 설정됩니다. 자바 17에서는 병렬 가비지 컬렉터가 G1보다 더 나은 성능을 내는 경우를 찾기가 거의 어렵습니다. 즉, 최신 자바 버전으로 갈수록 G1이 더 효과적이라는 뜻입니다. 다만 가비지 컬렉션 알고리즘을 바꾸기 전에는 반드시 프로그램 성능을 측정해 어떤 방식이 더 적합한지 확인해야 합니다.

압축형 vs 반구형 대피의 본질적 차이

병렬 가비지 컬렉션과 ParallelOld는 몇 가지 공통점을 가지고 있지만 근본적인 차이가 있습니다. 젊은 세대의 병렬 가비지 컬렉션은 반구형 대피 컬렉터이지만, ParallelOld는 단일 연속 메모리 공간을 사용하는 압축형 컬렉터입니다.

graph TB
    subgraph Young["젊은 세대 (반구형 대피)"]
        direction LR
        E[에덴] -->|생존자 이동| S[서바이버<br/>S0 또는 S1]
        E -.->|에덴 통째로 비움| Empty[빈 공간]
    end
    subgraph Old["오래된 세대 (in-place 압축)"]
        direction LR
        OldBefore[T T _ T _ T _<br/>단편화된 상태] --> OldAfter[T T T T _ _ _<br/>앞쪽으로 압축]
    end

오래된 세대에는 대피할 별도 공간이 없기 때문에, 병렬 컬렉터는 오래된 객체가 소멸하며 남긴 공간을 회수하기 위해 오래된 세대 내에서 객체를 재배치합니다(in-place compaction). 이를 통해 메모리를 매우 효율적으로 사용할 수 있고 메모리 단편화 문제를 피할 수 있습니다.

압축의 비용

이 접근 방식은 효율적인 메모리 레이아웃을 제공하지만, 전체 가비지 컬렉션 사이클에서 상당한 CPU 사용량을 초래합니다. 살아남은 객체를 모두 끌어모아 한쪽 끝으로 압축해야 하기 때문에, 생존 객체가 많은 오래된 세대에서는 비용이 빠르게 누적됩니다.

4.7.3 직렬 또는 SerialOld

직렬(Serial)과 SerialOld 컬렉터는 주로 경고의 사례로 책에 포함되어 있습니다. 이 컬렉터들은 병렬 가비지 컬렉션 및 ParallelOld와 유사하게 작동하지만, 가비지 컬렉션을 수행할 때 단일 CPU 코어만 사용합니다. 즉, 동시 실행을 지원하지 않으며 여전히 STW 방식입니다.

왜 멀티코어 시스템에서 피해야 하는가

멀티코어 시스템에서 직렬 컬렉터를 사용하는 것은 비효율적입니다. 단일 코어만 STW 가비지 컬렉션을 수행하는 동안 나머지 CPU 코어가 모두 유휴 상태로 남기 때문에, 불필요하게 긴 일시 중지 시간을 초래합니다.

특별한 이유가 없는 한 이런 컬렉터는 사용하지 않는 것이 좋습니다. 단일 코어 컨테이너처럼 코어가 하나뿐인 환경이거나, 매우 작은 힙에서 동시성 오버헤드를 피하고 싶은 경우 정도에만 의미가 있습니다.

4.7.4 병렬 컬렉터의 한계

병렬 컬렉터는 한 번에 세대 전체를 처리하며 가능한 한 효율적으로 수집하려고 설계되었습니다. 하지만 이 방식에는 몇 가지 단점이 있으며, 그 핵심은 완전히 STW로 작동한다는 점입니다.

젊은 세대는 보통 문제가 아니다

약한 세대 가설 덕분에 젊은 세대 컬렉션에서는 생존 객체가 매우 적기 때문에, 일반적으로 큰 문제가 되지는 않습니다. 기본 설정의 2GB JVM 기준으로 젊은 세대 컬렉션의 일시 중지 시간은 몇 밀리초에 불과하거나 심지어 밀리초 이하일 수도 있습니다.

오래된 세대 컬렉션이 진짜 약점

하지만 오래된 세대의 수집은 상황이 매우 다릅니다.

  • 오래된 세대는 기본적으로 젊은 세대 크기의 7배 정도입니다. 이 사실만으로도 전체 컬렉션의 STW 시간이 젊은 세대보다 훨씬 길어질 가능성이 있습니다.
  • 마킹 시간은 해당 영역의 활성 객체 수에 비례합니다. 오래된 객체는 수명이 긴 경우가 많아 전체 컬렉션에서 생존하는 객체 수도 많아질 가능성이 큽니다.

이러한 특성은 ParallelOld의 주요 약점을 잘 보여줍니다. STW 시간은 힙 크기에 거의 선형적으로 비례하여 증가하므로, 힙 크기가 계속 커지면 ParallelOld는 일시 중지 시간 문제로 확장성에 한계를 드러내기 시작합니다.

마크 앤 스윕에 손대도 STW는 줄지 않는다

가비지 컬렉션 이론을 처음 접하는 사람들은 마크 앤 스윕 알고리즘에 약간의 수정을 가하면 STW 일시 중지 시간을 줄일 수 있다고 생각하는 경우가 있습니다. 하지만 이는 현실적으로 불가능합니다. 동시 컬렉터나 영역 기반 구조로의 전환이 필요한 이유입니다.

4.8 할당의 역할

가비지 컬렉션은 40년 넘게 연구되어 온 분야이며, 프로덕션 컬렉터는 매우 복잡하고 다양한 트레이드오프를 고려해야 합니다. 단순한 ‘이렇게 하면 되지 않을까?‘라는 수정이 일반적인 개선을 가져올 가능성은 거의 없습니다.

가비지 컬렉션과 관련된 근본적인 문제들 중 일부는 동시 컬렉터를 도입해도 여전히 남아 있으며, 그 중심에 할당(allocation) 동작이 있습니다.

4.8.1 TLAB의 ‘단일 스레드 전용’은 할당 순간뿐

스레드-로컬 할당 버퍼는 할당 성능을 크게 향상시키지만, 컬렉션 사이클에서는 아무런 도움이 되지 않습니다. 이를 이해하기 위해 다음 코드를 살펴봅니다.

public static void main(String[] args) {
    int[] anInt = new int[1];
    anInt[0] = 42;
    Runnable r = () -> {
        anInt[0]++;
        System.out.println("Changed: " + anInt[0]);
    };
    new Thread(r).start();
}

anInt 변수는 단일 int 값을 포함하는 배열 객체입니다. 이 객체는 메인 스레드의 TLAB에서 할당되지만 곧바로 새 스레드로 전달됩니다. 다시 말해, TLAB의 주요 특징인 ‘단일 스레드 전용’ 속성은 객체가 할당되는 순간에만 유지됩니다. 객체가 할당된 이후에는 이 속성이 쉽게 깨질 수 있습니다.

자바에서 새로운 스레드를 간단히 생성할 수 있는 기능은 플랫폼의 매우 강력한 특성이지만, 이 기능은 가비지 컬렉션을 복잡하게 만듭니다. 새로운 스레드는 실행 스택을 가지며, 이 스택의 각 프레임이 가비지 컬렉션 루트의 출처가 되기 때문입니다.

4.8.2 GC는 결정론적이지 않다

자바의 가비지 컬렉션은 보통 메모리 할당 요청 시 필요한 메모리를 제공할 만큼 충분한 여유가 없을 때 발생합니다. 따라서 GC 사이클은 정해진 일정이나 예측 가능한 주기로 실행되지 않고, 필요에 따라 실행됩니다.

이것이 가비지 컬렉션의 핵심적인 특징 중 하나입니다. GC는 결정론적이지 않으며, 규칙적인 간격으로 발생하지 않습니다. 대신 힙의 메모리 공간 중 하나 이상이 거의 가득 차 객체를 더 이상 생성할 수 없게 될 때 GC 사이클이 트리거됩니다.

시계열 분석을 어렵게 만드는 특성

가비지 컬렉션 이벤트가 필요에 따라 발생한다는 특성은 이를 전통적인 시계열 분석 기법으로 처리하기 어렵게 만듭니다. GC 이벤트 간의 발생 간격이 규칙적이지 않다는 점은 대부분의 시계열 라이브러리가 다루기 힘든 요소입니다. GC 동작은 이벤트로 다루고 집계해서 지표를 만들어야 합니다.

STW GC가 발생하면 모든 애플리케이션 스레드가 중지됩니다. 객체를 더 이상 생성할 수 없는 상황이기 때문이며, 대부분의 자바 코드는 새로운 객체를 생성하지 않고는 오랜 시간 실행될 수 없기 때문입니다. JVM은 모든 코어를 사용해 GC를 수행하여 메모리를 회수한 뒤 애플리케이션 스레드를 다시 시작합니다.

시간 기반 주기 GC는 거의 항상 나쁜 선택

일부 자바 가비지 컬렉터는 시간 기반의 주기적 GC를 구성할 수 있는 방법을 제공합니다. 예를 들어 N밀리초마다 한 번씩 GC를 시행하도록 설정할 수 있습니다. 처음에는 좋아 보일 수 있지만, 실제로는 거의 쓸모가 없고 성능 문제를 일으킬 가능성이 높습니다. 개발자가 ‘가비지 컬렉터보다 더 잘 판단하려고’ 노력하면서 오히려 성능을 저하시킬 수 있기 때문입니다.

4.8.3 단순화된 할당 시나리오와 단순 톱니 패턴

할당이 왜 이렇게 중요한지 이해하기 위해 단순화된 사례를 살펴봅니다. 힙 매개변수가 다음과 같이 설정되고 시간이 지나도 변경되지 않는다고 가정합니다.

힙 영역크기
전체 힙2 GB
오래된 세대1.5 GB
젊은 세대500 MB
에덴 공간400 MB
서바이버 공간 1 (S1)50 MB
서바이버 공간 2 (S2)50 MB

애플리케이션이 안정 상태에 도달한 후 관찰된 GC 지표는 다음과 같습니다.

지표
할당 속도100 MB/s
젊은 세대 GC 시간2 ms
전체 GC 시간100 ms
객체 수명 주기200 ms
안정 상태에서의 GC 흐름

할당 속도가 100MB/s이고 에덴이 400MB이므로 에덴은 4초마다 한 번씩 가득 찹니다. 에덴이 차면 GC가 트리거되며, 마지막 200ms 동안 생성된 객체만 살아남아 서바이버 공간으로 대피합니다.

GC0  @ 4 s       20 MB Eden → S1 (20 MB)
GC1  @ 8.002 s   20 MB Eden → S2 (20 MB)
GC2  @ 12.004 s  20 MB Eden → S1 (20 MB)

GC1 시점에는 GC0에서 S1으로 승격됐던 객체들이 이미 모두 소멸했습니다(수명 200ms인데 4초가 더 지났음). 따라서 S2에는 에덴에서 새로 이동된 객체들만 남으며, 모든 객체의 세대 연령은 1 이하입니다.

이 이상적인 모델에서는 어떤 객체도 오래된 세대로 승격되지 않으며, 실행 내내 오래된 세대가 비어 있게 됩니다. 하지만 실제로는 약한 세대 가설에 따라 객체 수명이 분포를 이루기 때문에, 일부 객체는 오래된 세대까지 생존하게 됩니다.

단순 톱니 패턴

이 동작을 VisualVM 같은 도구로 모니터링하면 단순 톱니 패턴(sawtooth pattern) 이 나타납니다. 힙 사용량이 선형으로 증가하다가 GC가 발생할 때마다 급격히 떨어지는 모양으로, 자바 애플리케이션이 힙을 효율적으로 사용할 때 자주 보이는 정상적인 모습입니다.

힙 사용량
   │      ╱│      ╱│      ╱│
   │    ╱  │    ╱  │    ╱  │
   │  ╱    │  ╱    │  ╱    │
   │╱      │╱      │╱      │
   └───────┴───────┴───────┴──→ 시간
          GC0     GC1     GC2

톱니 패턴이 안 보이면 의심하자

정상적인 톱니 모양이 아니라 계단식 누적 그래프가 보인다면, 그것은 메모리 누수 또는 오래된 세대로 객체가 계속 쌓이고 있다는 신호입니다. GC가 메모리를 회수하지 못하고 있다는 뜻이기 때문입니다.

4.8.4 조기 승격 (Premature Promotion)

실제 환경에서는 할당 속도가 크게 변동하거나 ‘버스트형(bursty)’ 으로 나타나는 경우가 많습니다. 다음 시나리오를 살펴봅니다.

단계할당 속도
2초간 안정 상태100 MB/s
1초간 할당률 급증1 GB/s
이후 100초간 안정 상태로 복귀100 MB/s
할당 스파이크가 만드는 비극

초기 안정 상태에서 에덴에는 200MB가 할당되어 있고, 긴 수명을 가진 객체가 없는 경우 이 메모리의 수명은 100ms입니다. 이후 할당 스파이크가 발생해 에덴 공간의 나머지 200MB가 단 200ms 만에 추가로 할당됩니다.

이 중 100MB는 아직 100ms의 수명 제한을 초과하지 않아 살아 있는 상태입니다. 그러나 생존 객체의 크기(100MB)가 서바이버 공간(50MB)보다 커지기 때문에, JVM은 이 객체들을 영구 세대로 직접 승격할 수밖에 없습니다.

GC0  @ 2.2 s    100 MB Eden → Tenured (100 MB)
GC1  @ 2.602 s  200 MB Eden → Tenured (300 MB)
GC2  @ 3.004 s  200 MB Eden → Tenured (500 MB)
GC3  @ 7.006 s   20 MB Eden → S1 (20 MB) [+ Tenured (500 MB)]

문제는 이 객체들이 실제로는 수명이 짧은 객체라는 점입니다. 영구 세대로 승격된 직후 곧바로 소멸하지만, 전체 GC가 발생하기 전까지는 회수되지 않습니다. 결과적으로 영구 세대에 500MB의 가비지가 쌓이고, 이는 다음 전체 GC를 더 일찍, 더 비싸게 만듭니다.

이 현상을 조기 승격(premature promotion) 이라고 합니다. 가비지 컬렉션의 가장 중요한 간접적 영향 중 하나이며, 많은 튜닝 작업의 시작점이 됩니다.

조기 승격은 도미노다

조기 승격 자체는 한 번의 GC에서 끝나지 않습니다. 영구 세대가 빠르게 차오르면 전체 GC가 더 자주 발생하고, 전체 GC는 STW 시간이 길기 때문에 SLA를 깹니다. 할당 스파이크 → 조기 승격 → 잦은 전체 GC → 응답 시간 폭주라는 도미노로 이어집니다. 튜닝의 시작점이 여기인 이유입니다.

비교 / 트레이드오프

동시성 vs 병렬
항목동시성 (Concurrent)병렬 (Parallel)
비교 대상애플리케이션 스레드와의 관계GC 내부 스레드 수
의미애플리케이션 실행 중에도 GC 작동여러 코어로 GC 동시 수행
구현 난이도매우 어려움비교적 단순
예시G1, ZGC, ShenandoahParallelGC

헷갈리기 쉬운 개념

‘동시성=병렬’이 아닙니다. ParallelGC는 GC가 도는 동안 애플리케이션을 멈추는 STW 방식이지만, 그 GC 작업 자체는 여러 코어로 병렬화됩니다. 반면 G1은 일부 단계가 애플리케이션과 동시에 도는 동시성 컬렉터이며, 동시에 여러 코어를 쓰는 병렬성도 갖춥니다.

압축 vs 대피
항목압축 (Compaction)대피 (Evacuation)
끝난 후 상태살아남은 객체가 영역 앞쪽에 모임영역 자체가 통째로 비워짐
다른 영역 필요?같은 영역 안에서 처리별도 목적지 영역 필요
단편화 방지가능가능
예시ParallelOld의 영구 세대젊은 세대(에덴 → 서바이버)
세대별 vs 영역 기반
항목클래식 세대별 (ParallelGC)영역 기반 (G1, ZGC)
메모리 레이아웃연속적 영역(에덴/서바이버/올드)동일 크기 region 모음
세대 구분물리적으로 분리된 영역각 region에 세대 태그
부분 수집 가능?젊은 세대만 가능임의의 region 집합 선택 가능
대용량 힙 적합성낮음높음
oop / mark word / klass word 정리
구성 요소위치역할
oop스택 프레임 → 힙으로 연결객체 참조 (C 포인터 역할)
mark word객체 헤더 첫 워드인스턴스별 메타데이터(락 상태, GC 정보 등)
klass word객체 헤더 두 번째 워드클래스 메타데이터로의 포인터
klass자바 힙 외부, C 힙 내부vtable, 타입 정보
Class<?>자바 힙 내부리플렉션용 자바 객체
병렬 컬렉터 3종 비교
항목ParallelGC (젊은)ParallelOld (오래된)Serial / SerialOld
사용 코어멀티 코어 병렬멀티 코어 병렬단일 코어
메모리 처리 방식반구형 대피in-place 압축각각 대피/압축 (단일 코어)
STW항상 STW항상 STW항상 STW
자바 8 기본?
자바 11+ 기본?G1로 교체됨G1로 교체됨
권장 상황처리량 중시 워크로드처리량 중시 워크로드단일 코어 컨테이너 한정
정상 톱니 패턴 vs 위험 신호
패턴의미해석
톱니형(sawtooth)젊은 세대 GC가 주기적으로 회수정상 동작. 워크로드가 약한 세대 가설에 잘 부합
계단식 누적GC 후에도 베이스라인이 우상향메모리 누수 또는 영구 세대 지속 적재
잦은 깊은 골전체 GC가 자주 발생영구 세대 압박, 조기 승격 의심
평탄선 → 절벽장시간 GC 없이 누적되다 한 번에 정리매우 큰 힙 또는 동시 컬렉터의 정상 패턴일 수 있음

내 생각

  • GC를 이해해야 하는 실용적인 이유는 p99 응답 시간입니다. 평균 50ms 서비스가 갑자기 1초씩 튀면 십중팔구 GC 일시 정지이고, 세대 구조를 모르면 원인을 짚을 수 없습니다.

  • TLAB의 O(1) 할당 때문에 객체 풀링은 대부분 안티패턴입니다. new Object()는 포인터 한 칸 밀기에 가깝고, 풀링은 오히려 단명 객체를 영구 세대로 끌어올립니다.

  • ParallelOld의 STW는 힙 크기에 선형 비례합니다. 자바 11에서 G1이 기본이 된 것은 유행이 아니라 8GB·16GB 시대에 맞춰진 필연입니다.

  • 조기 승격은 백엔드 사고의 단골 시나리오입니다. 트래픽 스파이크나 큰 JSON 직렬화로 서바이버를 넘는 단명 객체가 영구 세대로 쌓이면, 다음 전체 GC에서 응답 시간이 폭발합니다. 튜닝의 첫 점검 항목이 여기입니다.

더 알아볼 것

  • -Xlog:gc* + JFR로 ParallelGC vs G1 비교 측정 및 할당 속도 파악
  • G1의 region 구조와 RememberedSet(기억 집합) 동작 원리
  • 조기 승격 튜닝 옵션(MaxTenuringThreshold, TargetSurvivorRatio) 실습
  • 큰 객체 할당과 비-TLAB 경로 임계값(-XX:PretenureSizeThreshold)
  • ZGC·Shenandoah의 컬러드 포인터·쓰기 장벽 차이

관련 개념

출처

  • 벤 에반스, 제임스 고프, 크리스 뉴랜드. 『자바 최적화 2판』. 한빛미디어. Ch04 가비지 컬렉션 이해하기.