[자바 최적화] 가비지 수집 기초
자바 최적화 책 정리
자바 최적화(Optimizing Java) | 벤저민 J. 에번스 - 교보문고
자바 최적화(Optimizing Java) | 자바 애플리케이션 성능을 한 단계 높여줄 튜닝 이야기성능 튜닝은 실험과학이다. 추측과 구전 튜닝에 의존할 일이 아니다. 이 책은 복잡한 기술 스택을 다루는 중/고
product.kyobobook.co.kr
마크 앤 스위프
✅ 마크 앤 스위프: GC의 기본 알고리즘
- 할당 리스트를 순회하면서 마크 비트(mark bit)를 지운다.
- 할당 리스트: 할당됐지만, 아직 회수되지 않은 객체 리스트
- 마크 비트: 객체가 실제로 사용 중인지 표시
- GC 루트부터 살아 있는 객체를 찾는다.
- 이렇게 찾은 객체마다 마크 비트를 세팅한다.
- 할당 리스트를 순회하면서 마크 비트가 세팅되지 않은 객체를 찾는다.
- 힙에서 메모리를 회수해 프리 리스트(메모리의 할당되지 않은 영역들을 연결 리스트로 운용)에 되돌린다.
- 할당 리스트에서 객체를 삭제한다.
✅ 힙 상태 확인
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
8: 8708 1764328 [Ljava.util.HashMap$Node;
9: 39047 1561880 jdk.nashorn.internal.runtime.CompiledFunction
10: 23688 1516032 com.mysql.jdbc.Co...$BooleanConnectionProperty
11: 24217 1356152 jdk.nashorn.internal.runtime.ScriptFunction
12: 27344 1301896 [Ljava.lang.Object;
가비지 수집 용어
✅ STW(Stop-The-World)
GC 사이클이 발생하여 가비지를 수집하는 동안에는 모든 애플리케이션 스레드가 중단된다.
✅ 동시(Concurrent)
GC 스레드는 애플리케이션 스레드와 동시(병행) 실행될 수 있다.
이는 아주 어렵고 비싼 작업인 데다, 실상 100% 동시 실행을 보장하는 알고리즘은 없다.
✅ 병렬(Parallel)
여러 스레드를 동원해서 가비지 수집을 한다.
✅ 정확(Exact)
전체 가비지를 한방에 수집할 수 있게 힙 상태에 관한 충분한 타입 정보를 지니고 있다.
✅ 보수(Conservative)
보수적인 스킴(GC 전략)은 객체 포인터 정보가 명시적으로 제공되지 않을 때, 메모리 내의 값들을 분석하여 객체의 참조 여부를 추측해서 가비지를 수집한다.
이러한 추측은 정확하지 않을 수 있으며, 가비지가 아닌데 가비지로 판단할 수 있는 등 비효율적이고 리소스 낭비하는 일이 잦다
✅ 이동(Moving)
이동 수집기는 객체를 한 영역에서 다른 영역으로 복사하면서 사용 가능한 메모리 블록을 유지하는 방법이다.
-> 따라서 객체들의 주소가 변경될 수 있다.
-> C/C++같이 메모리 주소를 가리키는 변수인 raw pointer를 사용하는 환경은 포인터가 가리키는 객체의 주소가 변경될 가능성이 있기 때문에 이동수집기와 잘 맞지 않는다.
✅ 압착(Compacting)
할당된 메모리는 GC 사이클 마지막에 연속된 단일 영역으로 배열되며, 객체 쓰기가 가능한 시작점을 가리키는 포인터가 있다.
-> 메모리 단편화를 방지한다.
✅ 방출(Evacuating)
GC 사이클 마지막에 할당된 영역을 완전히 비우고 살아남은 객체를 모두 다른 메모리 영역으로 이동(방출)한다.
핫스팟 런타임 개요
객체를 런타임에 표현하는 방법
✅ oop(ordinary object pointer)
핫스팟은 런타임에 oop라는 구조체로 자바 객체를 나타낸다.
oop는 스택에서 자바 힙을 구성하는 메모리 영역 내부를 가리킨다.
✅ instanceOop
oop 중 instanceOop는 자바 클래스의 인스턴스를 나타낸다.
instanceOop는 모든 객체에 대해 기계어 워드 2개로 구성된 헤더로 시작한다.
- Mark 워드: 인스턴스 관련 메타데이터를 가리키는 포인터
- Klass 워드: 클래스 메타데이터를 가리키는 포인터
✅ klassOop
자바 7까지는 instanceOop의 Klass 워드가 자바 힙의 메모리 영역을 가리켰다.
자바 힙에 있는 건 예외 없이 객체 헤더를 갖고 다녀야 한다는게 기본 원칙이었고,
Klass 워드의 메타데이터를 klassOop로 참조했다. (klassOop는 객체 헤더 다음에 메타데이터가 있는 단순한 구조다)
-> 자바 7 이후부터는 Klass가 자바 힙 영역 밖으로 빠지게 돼서, 객체 헤더가 필요 없다.
✅ 압축 oop
oop는 대부분 기계어 워드라서, 32비트 프로세서는 32비트, 요즘 프로세서는 64비트다.
-> 이런 구조로는 메모리가 크게 낭비될 우려가 있기 때문에 핫스팟은 다음 옵션을 통해 압축 oop를 제공한다.
-XX:+UseCompressedOops
(자바 7 버전 이상, 64비트 힙은 이 옵션이 디폴트임)
✅ 핫스팟 객체 헤더 구조
- Mark 워드: 32비트 환경은 4바이트, 64비트 환경은 8바이트
- Klass 워드: 압축됐을 수도 있음
- 객체가 배열이면 length 워드: 항상 32비트임
- 32비트 여백: 정렬 규칙 때문에 필요할 경우
GC 루트 및 아레나
✅ GC 루트
메모리의 '고정점(achor point)'으로, 메모리 풀 외부에서 내부를 가리키는 외부 포인터다.
-> 힙에 있는 객체를 가리키는 참조형 지역 변수도 말하자면 단순한 형태의 GC 루트다.
✅ 아레나
핫스팟 GC는 아레나(무대)라는 메모리 영역에서 작동한다.
+) 핫스팟은 유저 공간에서 힙 크기를 관리하므로, 자바 힙을 관리할 때 시스템 콜을 하지 않는다.
할당과 수명
✅ 자바 애플리케이션에서 가비지 수집이 일어나는 주된 원인
1. 할당: 객체가 사용한 메모리량
비교적 쉽고 정확하게 구할 수 있다.
2. 수명
대부분 측정하기 어렵다. 할당률보다 더 핵심적인 요인이다.
-> 가바지 수집은 '메모리를 회수해 재사용' 한다는 발상이 핵심이다.
약한 세대별 가설
✅ 소프트웨어 시스템의 런타임 작용을 관찰한 결과 알게 된 경험 지식
약한 세대별 가설은 JVM 메모리 관리의 이론적 근간을 형성한다.
✅ 객체 수명은 이원적 분포 양상을 보인다.
거의 대부분의 객체는 아주 짧은 시간만 살아있지만, 나머지 객체는 기대 수명이 훨씬 길다.
-> 가비지를 수집하는 힙은 단명 객체를 쉽고 빠르게 수집게끔 설계해야 하며, 장수 객체와 단명 객체를 완전히 떼어 놓는게 가장 좋다.
핫스팟은 다음 매커니즘을 통해 약한 세대별 가설은 활용한다.
- 객체마다 '세대 카운트(객체가 지금까지 무사 통과한 가비지 수집 횟수)'를 센다.
- 큰 객체를 제외한 나머지 객체는 에덴 공간에 생성한다. 여기서 살아남은 객체는 다른 곳으로 옮긴다.
- 장수했다고 할 정도로 충분히 오래 살아남은 객체들은 별도의 메모리 영역(테뉴어드 세대)에 보관한다.
✅ 늙은 객체가 젊은 객체를 참조할 일은 거의 없다
따라서 공간을 위 사진처럼 나눠도 큰 문제가 없다.
만약 늙은 객체가 젊은 객체를 참조한다면,
핫스팟은 카드 테이블이라는 자료 구조에 참조 정보를 기록해서 다른 공간 간의 참조를 빠르게 식별한다.
✅ G1GC
자바 수집기는 오래전부터 힙을 영/올드 영역으로 나누어 관리했는데,
자바 9부터는 새로운 수집기(G1)를 디폴트 수집기로 지정했다. (이에 대한 내용은 7장에 나온다)
핫스팟의 가비지 수집
✅ 방출 수집기
앞서 언급했듯이 힙은 서로 다른 영역으로 구성되며, 객체는 보통 에덴 영역에 생성돼서 줄곧 다른 영역으로 이동한다.
-> 즉, 객체의 주소가 빈번하게 바뀌며, 이처럼 객체를 이동시키는 수집기를 방출 수집기라고 한다.
스레드 로컬 할당
✅ TLAB(Thread-Local Allocation Buffer)
에덴은 대부분의 객체가 탄생하는 장소라 특별히 관리를 잘해야 하는 영역이다.
-> JVM은 성능을 강화하여 에덴을 관리한다.
JVM은 에던을 여러 버퍼로 나누어 각 애플리케이션 스레드가 새 객체를 할당하는 구역으로 활용하도록 한다.
-> 즉 각각의 스레드가 자신의 버퍼에 객체를 할당하고 다른 스레드는 이를 침범할 수 없다.
-> 이 구역을 스레드 로컬 할당 버퍼 TLAB라고 한다.
애플리케이션 스레드가 자신의 TLAB를 제어한다는건 JVM 스레드의 할당 복잡도가 O(1)이라는 뜻이다.
반구형 수집
✅ 두 공간을 사용하는 독특한 방출 수집기
실제로 장수하지 못한 객체를 임시 수용소에 담아 두자는 아이디어.
-> 덕분에 단명 객체가 바로 테뉴어드 세대를 어지럽히지 않게 하고 풀 GC 발생 빈도를 줄일 수 있다.
두 공간(반구)은 두 가지 기본적인 특성을 지닌다.
- 수집기가 라이브 반구를 수집할 때 객체들은 다른 반구로 압착시켜 옮기고, 수집된 반구는 비워서 재사용한다.
- 절반의 공간은 항상 완전히 비운다.
✅ 서바이버 공간
핫스팟에서는 영 힙의 반구부를 서바이버 공간이라고 한다.
ex)
에덴 영역에 대한 GC가 발생할 때, 살아남은 객체를 서바이버1 공간으로 옮긴다.
또 GC가 발생하면 에덴 영역과 서바이버1 영역에서 살아남은 객체를 서바이버2 공간으로 옮기고, 에덴과 서바이버1은 비운다.
병렬 수집기
✅ 자바 8 이전까지 JVM 디폴트 가비지 수집기
병렬 수집기는 애플리케이션 스레드를 모두 중단시킨 다음, 가용 CPU 코어를 총동원해 가능한 한 재빨리 메모리를 수집한다.
영 세대 병렬 수집기
✅ Parallel GC: 가장 흔한 가비지 수집 형태
스레드가 에덴에 객체를 할당하려는데 자신이 할당받은 TLAB 공간은 부족하고, JVM은 새 TLAB를 할당할 수 없을 때 발생한다.
- 전체 애플리케이션 스레드가 중단되면,
- GC 루트를 출발점으로 삼아 현재 살아남은 객체를 현재 비어있는 서바이버 공간으로 모두 방출한 후,
- 세대 카운트를 늘려 한 차례 이동했음을 기록한다.
- 그리고 에덴과 객체를 방출시킨 서바이버 공간을 빈 공간으로 표시하고,
- 애플리케이션 스레드를 재시작한다.
올드 세대 병렬 수집
✅ ParallelOld GC: 자바 8 디폴트 올드 세대 수집기
Parallel GC는 방출하는 반구형 수집기이지만, ParallelOld GC는 하나의 메모리 공간에서 압착하는 수집기다.
올드 세대에 더 이상 방출할 공간이 없으면,
병렬 수집기는 올드 세대 내부에서 객체들을 재배치해서 늙은 객체가 죽고 빠져 버려진 공간을 회수한다.
-> 메모리 단편화가 일어날 일이 없다.
병렬 수집기의 한계
✅ 올드 수집의 한계
병렬 수집기는 세대 전체 콘텐트를 대상으로 한번에, 가능한 한 효율적으로 가비지를 수집한다.
-> 그런데 이러한 설계 방식에도 단점이 있다. 바로 풀 STW를 유발한다는 것이다.
사실 영 수집에서는 STW가 매우 작기 때문에 문제가 되지 않지만,
올드 수집은 크기 자체가 크고, 살아남는 객체도 많기 때무네 STW가 치명적일 수 있다.
할당의 역할
✅ 예측 불가능한 GC
자바의 가비지 수집 프로세스는 메모리가 부족할 때 작동하여 필요한 만큼 메모리를 공급한다.
즉, GC 사이클은 예측 가능한 일정이 아닌, 불확정적, 불규칙하게 발생한다.
-> 보통 대부분의 객체가 사망하고, 일부 객체는 테뉴어드에 도달한다.
✅ 할당률과 GC
할당률은 실제로 확 치솟기도 한다.
만약 할당률이 갑자기 치솟아 에덴에 큰 공간이 할당되고, 바로 GC가 발생하는 경우
기본 객체 수명보다 작은 객체들은 무조건 살아남게 되는데, 이 때 큰 공간을 차지하는 객체들이 서바이버 공간보다 크게되면,
바로 테뉴어드 공간으로 가게 된다.
-> 이 현상을 조기 승격(premature promotion)이라고 하는데, 가비지 수집의 튜닝의 출발점 중 하나다. (7장에서 자세히 살펴본다)