Chapter 06. 가비지 수집 기초
Opened this issue · 3 comments
끄적끄적
가비지 수집 두가지 원칙
- 알고리즘은 반드시 모든 가비지를 수집해야 한다
- 살아 있는 객체는 절대로 수집해선 안 된다
- segmentation fault가 발생하거나, 프로그램 데이터가 조용히 더럽혀짐
- 프로그래머가 저수준 세부를 일일이 신경쓰지 않는 대가로 저수준 제어권(가비지 수집)을 포기하고 JVM에 맡긴다 -> 자바는 블루칼라 언어
기본적인 GC 알고리즘
-
- 'Allocated List'를 순회하면서 'Mark Bit'를 지운다
-
'Allocated List'란?:
-
'Mark Bit'란?:
-
- GC Root부터 살아있는 객체를 DFS로 찾는다 (이렇게 생성된 객체 그래프를 'Live Object Graph' 라고 함)
-
- 찾은 객체마다 Mark Bit를 세팅한다
-
- Allocated List를 순회하면서 Mark Bit가 세팅되지 않은 객체를 찾는다
- a) Heap에서 메모리를 회수해 'Free List'에 되돌려준다
- 'Free List'란?: 메모리의 할당되지 않은 영역들을 연결 리스트로 연결해서 운용
- b) Allocated List에서 객체를 삭제한다
GC 관련 용어
-
이번 절 번역체 너무 심한데.... 맨포인터(raw pointer) ㄷㄷ...
-
근데 원문도 설명이 그렇게 친절해보이진 않다
- Stop-The-World
- GC Cycle이 발생하여 가비지를 수집하는 동안에는 모든 Application Thread가 중단됨
- Concurrent (동시): GC Thread는 Application Thread와 동시 실행될 수 있음
- ex) CMS(Concurrent Mark and Sweep)는 사실상 '준 동시' 수집
- Parallel (병렬): 여러 GC Thread를 사용해서 병렬로 수집할 수 있음
- Exact(정확한): 정확한 GC Scheme은 힙 상태에 관한 충분한 타입 정보를 지니고 있음
-
Q. GC Scheme이란?
-
- Conservative(보수적인): 보수적인 Scheme은 정확한 스킴의 정보가 없음. 그래서 리소스를 낭비하는 일이 잦고 근본적으로 타입 체계를 무시하기 때문에 훨씬 비효율적임
-
이건 또 무슨 말이지..... '보수적인 Scheme'이라는 말만 들으면 뭔가 더 정확한 스킴을 제공할 것 같은데...
-
Q. 보수적인 Scheme이란?
-
- Moving(이동): 이동 수집기에서 객체는 메모리를 여기저기 오갈 수 있음. raw pointer로 직접 액세스하는 환경은 이동 수집기와 잘 맞지 않음.
-
Q. 이동 수집기(moving collector)란?
-
Compaction 또는 Evacuation 시 객체를 이동시키는 앤가?
-
-
- Compacting(압축): 메모리 단편화(Memory Fragment)를 방지하기 위해 조각모음
- Evacuating(방출): 가비지 수집 후 살아남은 객체들을 다른 영역으로 이동시킴
-
ex.
Eden -> Surv1
/Surv1 -> Surv2
/Surv2->Old
/ ...
-
Hotspot VM관련 용어
- 이번 장은 굳이 볼 필요는 없을 듯
- oop(Ordinary Object Pointer): Hotspot은 런타임에 oop라는 구조체로 자바 객체를 나타냄 (C언어 느낌의 순수 포인터)
-
Q. oop?
-
Q. 순수 포인터?
- oop를 구성하는 자료구조는 여러가지 있음
- instanceOop: 자바 클래스의 인스턴스를 나타내며, �machine word 2개로 구성된 헤더로 시작함
- Mark word: 인스턴스 메타데이터를 가리키는 포인터
- Klass word: 클래스 메타데이터(klassOop인 듯?)를 가리키는 포인터
- Java7까진 PermGen을 가리켰고, Java8부턴 Metaspace 가리킴
- Java8부터는 Klass word가 Java Heap 밖을 가리키므로 객체 헤더가 필요 없음
-
Q. JVM 메모리 영역 vs C 메모리 영역 (JVM Heap vs C Heap)
- Java7까진 PermGen을 가리켰고, Java8부턴 Metaspace 가리킴
- klassOop: JVM Class Loader가 로드한 Class 객체를 JVM 수준에서 나타낸 구조체
- 앞에
k
를 붙인 것은, 자바의 Class<?> 인스턴스를 나타내는 instanceOop와 구분하기 위함 -
klassOop vs Class<?>의 instanceOop
-
klassOop: 클래스의 메타데이터
-
Class의 instanceOop: Java에서 사용할 수 있는 인스턴스화된 Class
-
classMetadataOop, classInstanceOop로 네이밍했다면 좀 더 직관적이지 않았을까?
-
- 앞에
- Compressed oop
- instanceOop: 자바 클래스의 인스턴스를 나타내며, �machine word 2개로 구성된 헤더로 시작함
-
GC Roots and Arenas
- Hotspot VM의 GC는 Arena라는 메모리 영역에서 작동함
- Hotspot VM은 Java Heap을 관리할 때 '시스템 콜'을 하지 않고 '유저 공간 코드'에서 힙 크기를 관리
- 단순 측정값을 이용해 GC 서브시스템이 어떤 성능 문제를 일으키고 있는지 파악 가능
- GC Root: External Pointer. 메모리의 고정점(Anchor point)
- Internal Pointer: 메모리풀 내부에서 같은 메모리풀 내부의 다른 메모리 위치를 가리키는 포인터
- External Pointer: 메모리풀 외부에서 메모리풀 내부를 가리키는 포인터
- 종류
- Stack Frames
- JNI
- Registers (Hoisted variable)
- Registers (for the hoisted variable case)
- Code roots (from the JVM code cache)
- Globals
- Class metadata from loaded classes
Allocation and Lifetime
- 가비지 수집이 일어나는 주된 원인
- 할당률: 일정 기간 새로 생성된 객체가 사용한 메모리량
- 객체 수명:
- 수동 메모리 관리 시스템에서 가장 논란됐던 부분 중 하나. 객체 수명을 제대로 파악하기가 너무 복잡함
- Weak Generational Hypothesis
- 거의 대부분의 객체는 아주 짧은 시간만 살아있지만, 나머지 객체는 기대 수명이 훨씬 길다
- 단명 객체와 장수 객체를 완전히 떼어놓고, 단명 객체는 쉽고 빠르게 수집할 수 있게 설계하는 것이 좋다
- 단명 객체/장수 객체를 나눈 덕분에 GC cycle에서 살아남은 젊은 객체들을 집어내느라 전체 객체 그래프를 뒤질 필요가 없음
- 늙은 객체가 젊은 객체를 참조할 일은 거의 없다
- Card Table: 늙은 객체가 젊은 객체를 참조하는 정보를 기록
- 늙은 객체 o에 있는 참조형 필드값이 바뀌면 o에 해당하는 instanceOop가 들어있는 카드를 찾아 해당 엔트리를 Dirty Marking
- Card Table: 늙은 객체가 젊은 객체를 참조하는 정보를 기록
- 객체마다 generational count(age)를 센다
- generational count: 객체가 무사 통과한 가비지 수집 횟수
- 큰 객체(Humongous Object)를 제외한 나머지 객체는 Eden region에 생성하고, 여기서 살아남으면 다른(Survivor) region으로 옮긴다
-
G1GC에는 Humongous Region이 따로 있음
-
Humongous Region: Region size의 50%보다 큰 객체를 할당하는 공간
-
보통 Region Size는 전체 Heap / 2024 개로 구성된다. (Heap이 8GB면 4MB) (
-XX:G1HeapRegionSize=
로 지정 가능)
-
-
-
- 충분히 오래 살아남은 객체들은 별도의 region(Old (=Tenured) Region)에 보관
-
-XXMaxTenurintThreshold
값으로 수명 임계값 조정 가능 (default: 15) - 살아남은 객체 용량이 Survivor �space보다 크면 바로 전부 Old Gen으로 보내는 조기 승격(�Premature Promotion)이 일어남
-
- 거의 대부분의 객체는 아주 짧은 시간만 살아있지만, 나머지 객체는 기대 수명이 훨씬 길다
Thread-Local Allocation
- Eden을 여러 Buffer로 나누어 각 Application Thread가 새 객체를 할당하는 구역으로 활용
- TLAB(Thread-Local Allocation Buffer) 덕분에 각 Thread는 다른 Thread�의 Buffer를 침범하지 않음
Parallel Collector
- 병렬 수집기는 처리율에 최적화되어 있고, Young GC/Full GC 모두 STW를 일으킴 (가용 CPU core를 총동원해 최대한 빨리 메모리를 수집)
- 종류
- Parallel GC
- ParNew GC
- CMS Collector와 함께 사용할 수 있게 parallel GC를 조금 변형
- ParallelOld GC: 하나의 연속된 메모리 공간에서 Compaction
- Java8 기준 default Old Gen Collector (G1GC랑 같이 사용하는건가?)
- STW 시간이 Heap 크기에 거의 비례
-
Q. 다른 GC랑 같이 사용한다는게 무슨 뜻이지?
- Young Gen Parallel Collection
- Thread가 Eden에 객체를 할당하려는데 자신이 할당받은 TLAB 공간은 부족하고 JVM은 새 TLAB를 할당할 수 없을 때 Minor GC 발생
- Old Gen Parallel Collection
- 한계
- Full STW를 유발
인상 깊었던 것
- 살아 있는 객체를 DFS로 찾는 거
- KlassOop가 Java Heap Object로 저장하기 위한 Wrapper 역할을 한다는 것
- 약한 세대별1에 의해 객체 영역을 두 개로 나누는 것 외에 장수하지 못한 객체를 임시 수용소에 담아 두자는 아이디어
- '늙은 객체가 젋은 객체를 참조할 일은 거의 없다'라는 약한 세대별2. 하지만 예외 케이스를 커버하기 위해 Card Table 이용
정리
가비지 수집 Point: 시스템에 있는 모든 객체의 수명을 정확히 몰라도 런타임이 대신 객체를 추적하며 쓸모없는 객체를 알아서 제거
가비지 수집 구현체 기본 원칙
- 알고리즘은 반드시 모든 가비지를 수집해야 한다
- 살아 있는 객체는 절대로 수집해서는 안된다.
- 살아 있는 객체를 수집했다간 Segmentation fault가 발생하거나 프로그램 데이터가 더럽혀짐
마크 앤 스위프(Mark and Sweep) 알고리즘
- 할당 리스트를 순회하면서 Mark bit를 지운다
- GC 루트부터 살아 있는 객체를 찾는다
- 이렇게 찾은 객체마다 Mark bit를 세팅한다
- 할당 리스트를 순회하면서 마크 비트가 세팅되지 않은 객체를 찾는다
- Heap에서 메모리를 회수해 free list에 되돌린다
- 할당 리스트에서 객체를 삭제한다
살아 있는 객체는 DFS로 찾는다.
HotSpot runtime 개요
객체를 런타임에 표현하는 방법
instanceOop의 메모리 레이아웃: Mark 워드, Klass 워드라는 2개의 기계어 워드로 구성된 헤더로 시작.
- Mark 워드: 인스턴스 관련 메타데이터를 가리키는 포인터
- Klass 워드: 클래스 메타데이터를 가리키는 포인터.
- Java 7까지는 instanceOop의 Klass 워드가 자바 힙의 일부인 PermGen이라는 메모리 영역을 가리켰다
- Java 8부터는 Klass가 자바 Heap의 주 영역 밖으로 빠지게 되었다. 그래서 최신 버전의 자바는 Klass 워드가 자바 힙 밖을 가리키므로 객체 헤더가 필요없다
CompressedOops
-XX:+UseCompressedOops option을 주면, 힙에 있는 다음 oop가 압축된다 (Java 7 이상, 64비트 힙은 이 옵션이 디폴트)
- 힙에 있는 모든 객체의 Klass 워드
- 참조형 인스턴스 필드
- 객체 배열의 각 원소
GC 루트 및 아레나
- GC 루트: 메모리의 고정점(Anchor Point)로 메모리 풀 외부에서 내부를 가리키는 포인터.
- GC 루트의 종류: Stack Frame, JNI, Register, Code root, 전역 객체, 로드된 클래스의 메타데이터
- 핫스팟 GC는 Arena라는 메모리 영역에서 작동한다.
- 핫스팟은 자바 힙을 관리할 때 시스템 콜을 하지 않는다.
할당과 수명
약한 세대별 가설1: JVM 및 유사 소프트웨어 시스템에서 객체 수명은 이원적 분포 양상을 보인다. 거의 대부분의 객체는 아주 짧은 시간만 살아 있지만, 나머지 객체는 기대 수명이 훨씬 길다
→ 결론: 가비지를 수집하는 힙은 단명 객체를 쉽고 빠르게 수집할 수 있게 해야 하며, 장수 객체와 단명 객체를 완전히 떼어놓는 게 가장 좋다
- 객체마다 Generational count를 센다.
- 큰 객체를 제외한 나머지 객체는 Eden 공간에 생성한다. 살아남은 객체는 다른 곳으로 옮긴다
- 오래 살아남은 객체들은 별도의 메모리 영역으로 보관한다.
약한 세대별 가설2: 늙은 객체가 젋은 객체를 참조할 일은 거의 없다.
- Card Table이라는 자료구조에 늙은 객체가 젋은 객체를 참조하는 정보를 기록한다. GC Young generation을 수집할 때, Card Table을 이용해서 Old generation에서 참조하는 젋은 객체를 알아낼 수 있다. 만약 Card Table이 없다면, 모든 Old generation을 추적해야 하기 때문에 비효율적이다.
- 더티 marking하는 식으로 관리한다.
- GC에 의해 해제되거나 young object가 old generation으로 옮겨졌을 때도 더티 값을 지운다.
핫스팟의 가비지 수집
Thread-Local Allocation Buffer(TLAB): JVM은 Eden 영역을 여러 버퍼로 나누어 각 Application 스레드가 새 객체를 할당하는 구역으로 활용하도록 한다.
- Application thread가 자신의 TLAB를 배타적으로 제어한다는 것은 JVM 스레드의 할당 복잡도가 O(1)이라는 뜻. 어떠한 동기화 작업도 필요없기 때문.
- TLAB는 동적으로 조정한다.
반구형 수집기(Hemispheric evacuating collector): 장수하지 못한 객체를 임시 수용소에 담아 두자는 아이디어. 덕분에 단명 객체가 테뉴어드 세대를 어지럽히지 않게 하고 풀 GC 발생 빈도를 줄일 수 있습니다.
할당의 역할
- GC는 불확정적으로, 불규칙적으로 발생한다. GC 사이클은 하나 이상의 힙 메모리 공간이 꽉 채워져 더 이상 객체를 생성할 공간이 없을 때 일어난다.
→ 그렇기에 GC 로그는 기존의 시계열 해석 방법으로 처리하기 어렵다. GC 이벤트 간의 규칙성이 거의 없어서 대다수 시계열 라이브러리로 쉽게 끼워맞출 수 없다.
- GC가 발생하면 모든 Application thread가 멈춘다.
- 할당률이 높을수록 GC는 자주 발생하고, 할당률이 너무 높으면 객체는 어쩔 수 없이 Tenured로 곧장 승격(조기 승격)
더 알아본 내용
- KlassOop와 instance의 klass는 뭐가 다른가?
- KlassOop는 C++ Klass object를 Java Heap Object로 저장하기 위한 Wrapper 역할을 하는 것이다. 그렇기에 Class metadata를 Heap의 Perm Gen에 저장하는 JDK7까지는 필요했고, Perm Gen이 native 영역으로 옮겨진 JDK8부터는 C++ Klass object를 그대로 저장할 수 있기에 KlassOop는 필요없다.
- https://stackoverflow.com/questions/42477215/whats-the-real-difference-between-klassoop-and-instanceklass
GC!!
- 알고리즘은 반드시 모든 가비지를 수집해야하며 절대 살아있는 객체를 수집하면 안된다.
Mark And Sweep
- 할당 리스트(회수되지 않은 객체를 가르키는 포인터를 포함한 리스트)를 순회하며 마크 비트(아마도 이제 얘들을 지울거라는 뜻이 될 듯)를 지운다.
- 살아있는 객체를 찾아서 마크 비트를 세팅한다.
- 할당 리스트를 순회하며 마크 비트가 세팅되지 않은 객체들을 찾고 힙에서 메모리를 회수해서 프리 리스트(메모리 할당 안된 리스트들을 연결 리스트로 만들어서 운영함)에 되돌리고 할당 리스트에서 객체를 제거한다.
- 살아있는 객체를 찾을 때는
DFS
를 사용한다고 한다. (오옹!)
Hot-Spot Runtime
- 핫스팟은 런타임에 oop라는 구조체로 자바 객체를 나타낸다.
- oop가 오브젝트 오리엔티드 프로그래밍.. 그건 줄 알았는데 그건 아니고 ordinary object pointer의 줄임말이라고 한다. 하핫
- instanceOop의 메모리 레이아웃은 모든 객체에 대해 기계어 워드 2개로 구성된 헤더로 시작한다고 한다.
- Mark(인스턴스 관련 메타데이터를 가르키는 포인터)와 Klass(클래스 메타데이터를 가르키는 포인터)가 나온다고 한다.
- 8버전부터는 klass가 자바의 힙 영역에서 네이티브 영역으로 나가게 되면서 자연스럽게 klassOop는 더 이상 사용되지 않는다고 한다. ( 원래는 7버전에서 PermGen 영역을 가르키는 헤더 포인터로써 사용되었다고 함)
GC 루트 및 아레나
- GC 루트는 메모리의 Anchor point로 메모리 풀 외부에서 내부를 가르키는 포인터이다.
- 핫스팟 GC는 arena라는 메모리 영역에서 작동한다고 한다. (오.. 신기;; )
- 핫스팟은 자바 힙을 관리할 때
시스템 콜
을 하지 않는다고 한다. (이야..!!) - 핫스팟은 유저 공간 코드에서 힙 크기를 관리하기에 이게 가능하다고 한다.
할당과 수명
- 자바 애플리케이션에서 GC가 일어나는 원인은 두 가지이다.
- 할당률 : 일정 기간 동안 새로 생성된 객체가 사용한 메모리량.
- 수명 : 얼마나 오래 살아남았는가? -> 측정하기 매우 어려움. GC에서 핵심적인 요인
- 약한 세대별 가설 : 대부분의 객체는 아주 짧은 시간만 살아있지만, 나머지 객체는 기대 수명이 훨씬 길다. ( 스프링은 왠지 더 그럴 것 같음 )
핫스팟 GC가 단명 객체와 장수 객체를 구분하는 방법
- 객체마다 Geneartion Count라고 얼마나 GC에서 살아남았는지를 카운팅함.
- 큰 객체를 제외한 나머지 객체는 Eden에 박아둔다. 살아남으면 다른 곳으로 옮김.
- 장수했다고 판단될 정도로 오래 살아남은 객체들은 별도의 메모리 영역에 보관한다. ( Old 또는 Tenured..? 이런것도 있구나;; )
핫스팟의 가비지 수집
- JVM은 에덴을 여러 버퍼로 나눠서 각 애플리케이션 스레드가 새 객체를 할당하는 구역으로 활용하도록 배포한다고 한다.
- 각 스레드나 다른 스레드가 자신의 버퍼에 객체를 할당할 필요가 없다고 한다. 스레드 로컬 할당 버퍼라고 한다네.
hemispheric evacuating collector ( 반구형 수집 )
- 내가 예전에 배운 서바이버 영역이 이 반구형 수집이라고 한다.
- 장수하지 못한 객체를 서바이버 영역에 두고 테뉴어드 존으로 옮기지 않게 하는 것 같음.
- 기본적으로 이 서바이버 영역은 둘 중 하나는 무조건 비워져있어야함.
Young Parallel GC
- Young GC도 STW는 발생한다.
Old Parallel GC
- 여기서는 하나의 연속된 메모리 공간에서 압착하는 수집기이다.
- Old GC는 늙고 병든 객체들을 회수해서 메모리 사용이 효율적이고 단편화도 일어나지 않는다고 한다.
병렬 수집기의 한계
- 병렬 수집기의 단점은 풀 STW가 유발되는데, 영 STW는 극소수 객체만 살아남으므로 크게 문제가 안된다.
- 올드 GC에는 디폴트 크기 자체가 영의 7배라서 훨씬 더 시간이 길어지게 된다.
- 올드 GC의 가장 큰 단점은, STW 시간이 힙 크기에 비례한다는 뜻인데, 힙이 계속 커질수록 올드 GC의 시간도 같이 늘어나게 된다.
할당의 역할
- GC는 그때 그때 필요할 때 돈다. 정해진 규칙대로 동작하지 않음.
총평
- GC를 다시 공부할 수 있어서 좋은 기회였고 애매모호했던, 몰랐던 개념들을 새롭게 배우고 정립할 수 있어서 무척 좋은 챕터였음!!