가비지 수집의 요체는 시스템에 있는 모든 객체의 수명을 정확히 몰라도 런타임이 대신 객체를 추적하고 쓸모없는 객체를 알아서 제거하는 것.
가비지 수집 구현체의 두가지 기본 원칙
사용중인 객체를 수집하면 세그먼테이션 결함이 발생
마크 앤 스윕(Mark and Sweep)
가비지 컬렉터에는 GC Root라는 것이 있다. GC Root들은 힙 외부에서 접근할 수 있는 변수나 오브젝트를 뜻한다.
GC 루트 및 아레나#
GC 루트는 메모리의 고정점(앵커 포인트, anchor point)으로 메모리 풀 외부에서 내부를 가리키는 포인터입니다. 즉, 메모리 풀 내부에서 같은 메모리 풀 내부의 다른 메모리 위치를 가리키는 내부 포인터(internal pointer)와 정반대인 외부 포인터입니다.
다음과 같은 종류가 있습니다.
스택 프레임(stack frame)
JNI
레지스터(호이스트된 변수)
코드 루트
전역 객체
로드된 클래스의 메타데이터
핫스팟 GC는 아레나라는 메모리 영역에서 작동합니다. 핫스팟은 자바 힙을 관리할 때 시스템 콜을 하지 않습니다.
GC Root는 말그대로 가비지 컬렉션의 Root라는 뜻이다.
GC Root에서 시작해 이 Root가 참조하는 모든 오브젝트, 또 그 오브젝트들이 참조하는 다른 오브젝트들을 탐색해 내려가며 마크(Mark)한다.
이 탐색해 내려가며 마크하는 것을 Mark단계라고 한다.
GC Root가 될 수 있는 것들은 다음과 같다.
실행중인 쓰레드 (Active Thread)
정적 변수 (Static Variable)
로컬 변수 (Local Variable)
JNI 레퍼런스 (JNI Reference)
Mark가 끝나면 가비지 컬렉터는 힙 내부를 전체를 돌면서 Mark되지 않은 메모리들을 해제(Reclaim)한다. 이 과정을 Sweep이라고 부른다.
자바에서는 두가지 값만 사용함
자바는 C++와 달리 주소를 역참조(dereference)하는 일반적인 메커니즘이 없고 오직 오프셋 연산자나 객체 레퍼런스의 메서드를 호출할 수 있습니다. 또한 자바는 값으로 호출(callByValue)하는 방식으로만 메서드를 호출합니다. 객체 레퍼런스의 경우, 복사된 값은 힙에 있는 객체의 주소입니다.
핫스팟은 런타임에 oop(ordinary object pointer, 평범한 객체 포인터)라는 구조체로 자바 객체를 나타냅니다. (C의 포인터와 비슷합니다.) oop는 참조형 지역 변수 안에 위치하며 메서드의 스택 프레임으로부터 자바 힙을 구성하는 메모리 영역 내부를 가리킵니다.
oop를 구성하는 자료 구조는 여러가지가 있습니다. 그중 instanceOop는 자바 클래스의 인스턴스를 나타냅니다.
이는 크게 두개의 기계어 워드 2개로 구성됩니다.
Mark 워드(인스턴스 관련 메타데이터를 가리키는 포인터)
Klass 워드(클래스 메타데이터를 가리키는 포인터)
자바 8이후로는 기존과 달리 Klass 워드가 자바 힙 밖을 가리키므로 객체 헤더가 필요없습니다.
image
배열또한 이러한 객체입니다. 그렇기 때문에 Klass 워드 다음에 배열 길이를 나타내는 Length워드가 붙어있어서 C++처럼 길이를 주지 않아도 됩니다.
JVM 환경에서 자바 레퍼런스는 instanceOop를 제외한 어떤 것도 가리킬 수 없습니다.
자바 값은 기본형 값 또는 instanceOop 주소(레퍼런스)에 대응되는 비트 패턴입니다.
모든 자바 레퍼런스는 자바 힙의 주 영역에 있는 주소를 가리키는 포인터입니다.
자바 레퍼런스가 가리키는 주소에는 Mark 워드와 Klass 워드가 들어있습니다.
klassOop와 Class<?> 의 인스턴스는 다르며 klassOop(힙의 메타데이터 영역에 있음)을 자바 변수에 넣을 수 없습니다.
핫스팟의 oop 체계를 까보면 다음과 같습니다. (hotspot/src/share/vm/oops )
oop (추상 베이스)
instanceOop (인스턴스 객체)
methodOop (메서드 표현형)
arrayOop (배열 추상 베이스)
symbolOop (내부 심볼 / 스트링 클래스)
klassOop (Klass 헤더) (자바 7 이전만 해당)
markOop
자바/JVM 워크로드의 가비지 수집을 일으키는 두 가지 주요 특성에 대해 살펴보면 다음과 같습니다.
할당과 수명#
자바 애플리케이션에서 가비지 수집이 일어나는 주된 원인은 다음 두가지입니다.
할당률
일종 기간 동안 새로 생성된 객체가 사용한 메모리량
비교적 쉽게 측정이 가능하고 센섬 같은 툴을 통해서 구할 수 있습니다.
객체 수명
측정하기가 어렵습니다.
약한 세대별 가설#
소프트웨어 시스템의 런타임 작용을 관찰한 결과 알게 된 경험 지식이며, JVM 메모리 관리의 이론적 근간을 형성합니다.
JVM 및 유사 소프트웨어 시스템에서 객체 수명은 이원적 분포 양상을 보입니다. 거의 대부분의 객체는 아주 짧은 시장만 살아 있지만, 나머지 객체는 기대 수명이 큽니다.
image
핫스팟은 카드 테이블이라는 자료 구조에 늙은 객체가 젊은 객체를 참조하는 정보를 기록합니다.
자바 수집기는 힙을 영/올드 영역으로 나누어서 관리합니다. (현재는 조금 달라졌습니다.)
핫스팟의 가비지 수집#
자바는 C/C++와 달리 OS를 이용해 동적으로 메모리를 관리하지 않습니다. 대신, 일단 프로세스가 시작하면 JVM은 메모리를 할당하고 유저 공간에서 연속된 단일 메모리 풀을 관리합니다.
이 메모리 풀은 각자의 목적에 따라 서로 다른 영역으로 구성되며 객체는 보통 에덴 영역에 생성됩니다. 수집기는 객체를 이동시키는데 객체가 차지한 주소는 대부분 시간이 흐르면서 아주 빈번하게 바뀝니다. 이처럼 객체를 이동시키는 것은 '방출'이라고 하며, 핫스팟 수집기는 대부분 방출 수집기입니다.
스레드 로컬 할당#
JVM은 성능을 강화해서 에덴을 관리하며, 에덴은 대부분의 객체가 탄생하는 장소입니다. 특히 수명이 짧은 객체는 다른 곳에는 위치할 수 없으므로 특별히 관리를 잘해야합니다.
JVM은 에덴을 여러 버퍼로 나누어 각 애플리케이션 스레드가 새 객체를 할당하는 구역으로 활용하도록 배포합니다. 이 때 이 구역을 스레드 로컬 할당 버퍼(TLAB, Thread-Local Allocation Buffer)라고 합니다.
image
반구형 수집#
반구형 수집기는 두 공간을 사용하는 독특한 방출 수집기입니다. 즉, 오래 살지 못한 객체를 임시 수용소에 담아 두는 아이디어입니다. 이 공간은 두가지의 기본 특징을 가집니다.
수집기가 라이브 반구를 수집할 때 객체들은 다른 반구로 압착시켜 옮기고 수집된 반구는 비워서 재사용합니다.
절반의 공간은 항상 완전히 비웁니다.
핫스팟은 이 반구형 기법과 에덴 공간을 접목시켜서 영 세대 수집을 합니다. 핫스팟에서는 영 힙의 반구부를 서바이버(survivor) 공간이라고 합니다.
병렬 수집기#
자바 병렬 수집기도 여러개가 있습니다.
Parallel GC
가장 단순한 영 세대용 병렬 수집기
ParNew GC
CMS 수집기와 함께 사용할 수 있게 Parallel GC를 조금 변형한 것입니다.
ParallelOld GC
올드 세대용 병렬 수집기입니다.
영 세대 병렬 수집#
영세대 수집은 가장 흔한 가비지 수집 형태입니다. 스레드가 에덴에 객체를 할당하려는데 자신이 할당받은 TLAB 공간은 부족하고 JVM은 새 TLAB을 할당할 수 없을 때 영 세대 수집이 발생합니다.
올드 세대 병렬 수집#
올드 세대에 더 이상 방출할 공간이 없으면 병렬 수집기는 올드 세대 내부에서 객체들을 재배치해서 늙은 객체가 죽고 빠져 버려진 공간을 회수하려고 합니다.
올드 공간은 크게 눈에 띄는 변화가 없습니다. 때때로 큰 객체가 테뉴어드 세대에 직접 생성되는 경우도 있지만, 그 외에는 영 세대 객체가 승격되거나 올드/풀 수집이 일어나 객체를 재탐색 후 다시 패치하는 등의 수집이 일어날 때만 변합니다.
병렬 수집기의 한계#
뱡랼 수집기는 세대 전체 콘텐츠를 대상으로 한번에 가능한 한 효율적으로 가비지를 수집합니다. 다만 이러한 설계에도 단점은 있습니다.
풀 STW
힙 크기가 커질수록 느려집니다.
영역 내 살아 있는 객체 수만큼 마킹 시간이 늘어납니다.
할당의 역할#
자바의 가비지 수집 프로세스는 보통 유입된 메모리 할당 요청을 수용하기에 메모리가 부족할 때 작동하여 필요한 만큼 메모리를 공급합니다. 즉, GC 사이클은 어떤 고정된 예측 가능한 일정에 맞춰 발생하는 것이 아니라 순전히 필요로 할 때 발생합니다. (즉, 불확정적 + 불규칙적입니다.)
GC가 발생하면 모든 애플리케이션 스레드가 멈춥니다. (객체를 생성할 수 없으므로 오래 실행될 자바 코드가 없습니다.) JVM은 모든 코어를 총동원해 가비지를 수집하고 메모리를 회수한 후, 애플리케이션 스레드를 재개합니다.
앞서 이야기한 것처럼 가비지 수집은 일정한 주기마다 실행되는 것이 아니라 필요에 따라 그때마다 실행됩니다. 할당률이 높을수록 GC는 더 자주 발생합니다. 할당률이 너무 높은 경우에는 테뉴어드로 곧장 승격이 되는데 이를 조기 승격이라고 합니다.