book/자바 최적화

[자바 최적화] 가비지 수집 기초

danuri 2023. 8. 28. 00:14

자바 최적화 책 정리

 

자바 최적화(Optimizing Java) | 벤저민 J. 에번스 - 교보문고

자바 최적화(Optimizing Java) | 자바 애플리케이션 성능을 한 단계 높여줄 튜닝 이야기성능 튜닝은 실험과학이다. 추측과 구전 튜닝에 의존할 일이 아니다. 이 책은 복잡한 기술 스택을 다루는 중/고

product.kyobobook.co.kr

 

마크 앤 스위프

✅ 마크 앤 스위프: GC의 기본 알고리즘

  1. 할당 리스트를 순회하면서 마크 비트(mark bit)를 지운다.
    • 할당 리스트: 할당됐지만, 아직 회수되지 않은 객체 리스트
    • 마크 비트: 객체가 실제로 사용 중인지 표시
  2. GC 루트부터 살아 있는 객체를 찾는다.
  3. 이렇게 찾은 객체마다 마크 비트를 세팅한다.
  4. 할당 리스트를 순회하면서 마크 비트가 세팅되지 않은 객체를 찾는다.
    • 힙에서 메모리를 회수해 프리 리스트(메모리의 할당되지 않은 영역들을 연결 리스트로 운용)에 되돌린다.
    • 할당 리스트에서 객체를 삭제한다.

 

✅ 힙 상태 확인

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개로 구성된 헤더로 시작한다.

  1. Mark 워드: 인스턴스 관련 메타데이터를 가리키는 포인터
  2. Klass 워드: 클래스 메타데이터를 가리키는 포인터

 

✅ klassOop

자바 7까지는 instanceOop의 Klass 워드가 자바 힙의 메모리 영역을 가리켰다.

자바 힙에 있는 건 예외 없이 객체 헤더를 갖고 다녀야 한다는게 기본 원칙이었고,

Klass 워드의 메타데이터를 klassOop로 참조했다. (klassOop는 객체 헤더 다음에 메타데이터가 있는 단순한 구조다)

-> 자바 7 이후부터는 Klass가 자바 힙 영역 밖으로 빠지게 돼서, 객체 헤더가 필요 없다.

 

✅ 압축 oop

oop는 대부분 기계어 워드라서, 32비트 프로세서는 32비트, 요즘 프로세서는 64비트다.

-> 이런 구조로는 메모리가 크게 낭비될 우려가 있기 때문에 핫스팟은 다음 옵션을 통해 압축 oop를 제공한다.

-XX:+UseCompressedOops

 

(자바 7 버전 이상, 64비트 힙은 이 옵션이 디폴트임)

 

✅ 핫스팟 객체 헤더 구조

  1. Mark 워드: 32비트 환경은 4바이트, 64비트 환경은 8바이트
  2. Klass 워드: 압축됐을 수도 있음
  3. 객체가 배열이면 length 워드: 항상 32비트임
  4. 32비트 여백: 정렬 규칙 때문에 필요할 경우

 

GC 루트 및 아레나

✅ GC 루트

메모리의 '고정점(achor point)'으로, 메모리 풀 외부에서 내부를 가리키는 외부 포인터다.

-> 힙에 있는 객체를 가리키는 참조형 지역 변수도 말하자면 단순한 형태의 GC 루트다.

 

✅ 아레나

핫스팟 GC는 아레나(무대)라는 메모리 영역에서 작동한다.

+) 핫스팟은 유저 공간에서 힙 크기를 관리하므로, 자바 힙을 관리할 때 시스템 콜을 하지 않는다.

 


할당과 수명

✅ 자바 애플리케이션에서 가비지 수집이 일어나는 주된 원인

1. 할당: 객체가 사용한 메모리량

비교적 쉽고 정확하게 구할 수 있다.

2. 수명

대부분 측정하기 어렵다. 할당률보다 더 핵심적인 요인이다.

-> 가바지 수집은 '메모리를 회수해 재사용' 한다는 발상이 핵심이다.

 

약한 세대별 가설

✅ 소프트웨어 시스템의 런타임 작용을 관찰한 결과 알게 된 경험 지식

약한 세대별 가설은 JVM 메모리 관리의 이론적 근간을 형성한다.

 

✅ 객체 수명은 이원적 분포 양상을 보인다.

거의 대부분의 객체는 아주 짧은 시간만 살아있지만, 나머지 객체는 기대 수명이 훨씬 길다.

-> 가비지를 수집하는 힙은 단명 객체를 쉽고 빠르게 수집게끔 설계해야 하며, 장수 객체와 단명 객체를 완전히 떼어 놓는게 가장 좋다.

 

핫스팟은 다음 매커니즘을 통해 약한 세대별 가설은 활용한다.

  1. 객체마다 '세대 카운트(객체가 지금까지 무사 통과한 가비지 수집 횟수)'를 센다.
  2. 큰 객체를 제외한 나머지 객체는 에덴 공간에 생성한다. 여기서 살아남은 객체는 다른 곳으로 옮긴다.
  3. 장수했다고 할 정도로 충분히 오래 살아남은 객체들은 별도의 메모리 영역(테뉴어드 세대)에 보관한다.

 

 

 

✅ 늙은 객체가 젊은 객체를 참조할 일은 거의 없다

따라서 공간을 위 사진처럼 나눠도 큰 문제가 없다.

만약 늙은 객체가 젊은 객체를 참조한다면, 

핫스팟은 카드 테이블이라는 자료 구조에 참조 정보를 기록해서 다른 공간 간의 참조를 빠르게 식별한다.

 

✅ 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장에서 자세히 살펴본다)