Search
Duplicate
🎿

가비지 수집 기초 GC의 기본개념

태그
Java
공개여부
작성일자
2022/03/02
GC가 처음 나왔을때 언어 수준에서 의도적으로 수집기 작동을 제어하지 못하도록 했다는 점에서 여론이 좋지 않았다.
그러나 세월이 지나면서 GC가 사람의 손이 아닌 직접 메모리를 수집하는 것에 이견이 없어졌다.
모든 GC는 다음의 두 가지 원칙을 준수해야 한다
알고리즘은 반드시 모든 garbage 를 수집해야 한다
살아 있는 객체는 절대로 수집해선 안된다
이 원칙이 더 중요하다. 살아 있는 객체를 수집한다면 segmentation fault가 발생한다
개발자가 저수준 세부를 신경쓰지 않고, 저수준 제어를 포기한다는 사상이 java 관리의 핵심이며 jame gosling 이 블루칼라 언어라고 말한 특징이 잘 반영되는 대목이다
이 챕터는
GC의 이론을 소개한다
자바 플랫폼에서 GC 완전히 이해/제어하기 어려운 이유를 설명한다
hotspot 이 runtime에 객체를 heap나타내는 방법과 기본 특성을 알아본다

Introducing Mark and Sweep

GC 는 Mark and sweep 알고리즘이 기초이다.
실제 프로세스(GC 알고리즘 기본 개념, 이것이 memory를 어떻게 자동 회수하는지)가 어떻게 동작하는지 알아본다 여기서 설명하는 알고리즘은 매우 단순화 한 것이다 실제로 JVM 에서는 더 복잡하고 어렵다
object 가 할당은 되었지만, 아직 회수되지 않은 객체의 포인터를 포함하는 allocated list 를 사용한다
전체적인 GC의 알고리즘은 다음과 같다
1.
할당 리스트를 순회하면서 mark bit 를 지운다
2.
GC root 부터 살아있는 객체를 찾는다
3.
2에서 찾은 객체마다 mark bit 를 세팅한다
4.
allocated list 를 순회하면서 mark bit 가 세팅되지 않은 객체를 찾는다
a.
heap 에서 memory 를 회수해 free list 에 되돌린다
b.
allocated list 에서 객체를 삭제한다
살아 있는 객체를 찾는 방식이 DFS 인데 이 탐색으로 만들어진 그래프를 live object graph 라고하며
접근 가능한 객체의 전이 폐쇄(trasitive closure of reachable objects) 라고도 한다
단순 메모리 layout
heap 의 상태를 시각화하긴 어렵지만 다행히 jmap -histo 라는 명령어로 각 type 별 할당된 bytes 수를 볼 수 있다.
jmap -histo 를 통해 객체별 할당된 bytes 수 확인
그때 그때 힙 모습만 봐서는 정확한 분석이 불가능하기 때문에 8장의 GC로그를 이용해야 한다

가비지 수집 용어

GC 알고리즘을 설명하는 용어를 설명한다

STW: Stop-the-world

CG 사이클이 발생하여 GC를 수행하는 동안 모든 application thread 가 중단된다
application 의 코드가 heap 상태를 무효화 할 수 없으므로 GC 알고리즘에서 대부분 이럴때 STW 가 발생한다

Concurrent - 동시

GC 의 thread 는 application thread 와 동시에 실행될 수 없다.
이것을 동시에 실행하는 방법은 계산 비용 면에서 매우 어렵고, 비싼 작업인데다 실제로 100% 동시 실행을 보장할 수 없다

Parallel - 병행

여러 thread 를 동원해서 garbage collection 한다

Exact - 정확

정확한 GC scheme(계획, 전략)은 전체 garbage 를 한 cycle에 수집할 수 있게 heap 상태에 관한 충분한 type 정보를 지니고 있다. 더 정확히 이 exact scheme 이 int 와 pointer 를 구분할 만큼의 속성을 가지고 있다. (그래서 정확하다)
→ type 에 대한 정보를 정확하게 알아야 GC를 한 cycle 만에 수행할 수 있는듯

Conservative - 보수

conservative scheme 은 정확한 정보가 없다.
그래서 resource 낭비가 잦고 type 체계를 무시하기 때문에 비효율적이다

Moving - 이동

이동 수집기(moving collector)에서 객체는 메모리를 여기저기 오고 갈 수 있따.
객체 주소는 고정된 것이 아니고 raw pointer로 직접 access 하는 환경은 이동 수집기와 잘 맞지 않다

Compacting - 압착

GC 사이크 마지막에 allocated memory 들은 단일 영역으로 (대개 이 영역 첫 부분) 배열된다

Evacuating - 방출

GC cycle 에서 마지막에 할당된 영역을 비우고 살아남은 객체를 다른 memory 영역으로 방출한다

Introducing the Hotspot runtime

GC의 작동 원리를 온전히 이해하기 위해 hotspot 의 내부도 어느정도 알아야 한다
이것을 위해 자바는 다음의 두 가지 값만 가지고 있다
primitive types(byte, int, etc.)
Object reference
Java 는 C++ 과 달리 dereference(&) 가 없고, offset operator(.) 를 통해 필드에 접근하거나
객체의 reference 의 method 를 호출한다또한 자바는 call-by-value 방식으로 method 를 호출하는데 객체 레퍼런스의 경우 복사된 값은 heap 에 있는 객체의 주소 value 이다.

객체를 runtime 에 표현하는 방법

hotspot 은 java 객체를 runtime 에 oop(Ordinary object pointer)라는 구조체(struct)로 표현한다(c언어 스럽다)
oop는 reference type 지역 변수 안에 위치한다 → 자바 heap 을 구성하는 memory 영역 내부를 가리킨다
oop 의 구성 요소중 instanceOop 는 java class 의 instance 를 나타낸다
instanceOop 는 모든 객체의 header 에 두 기계어(machine word)로 시작한다
Mark word는 instance 의 metadata 를 가리키는 pointer 이며
klass word 는 class 의 metadata 를 가리키는 pointer 이다
java 7까지는 klass 가 permGen(메소드와 같이 지워지지 않는 영역)을 가리키고 있었다
java8 부터는 klass 가 자바 heap 의 주영역 밖으로 빠지면서 객체 헤더가 필요 없어졌다
?
(갑자기 이게 왜 나오지?)
java heap 에 있는 객체는 예외 없이 header 가 필요하다
옛날 자바 버젼은 metadata 를 klassOop 를 통해 참조했다
klassOop의 메모리 layout 은 단순해서 object header 다음에 klass metadata 가 나온다
klassOop 앞에 k를 붙인 것은 자바 Class<?> 객체를 나타내는 instanceOop 와 구분하기 위함이다
hotspot 에서 java 를 표현하는 klassOop와 class 의 차이
klassOop 는 가상 함수 테이블(virtual function table - vtable) 을 가지고 있고
class 는 reflection 으로 호출할 method 객체의 reference 배열이 있다.
oop 는 기계어이기 때문에 32bit 머신에선 32, 64bit 머신에선 64bit 를 차지하는데 여기서 메모리를 조금이라도 절약하기 위해 압축 oop 라는 기능을 제공한다
-XX:+UseCompressOops
JSON
이 옵션을 사용하면 다음의 oop 가 압축된다
heap 에 있는 모든 객체의 Klass 워드
참조형 instance field
객체 배열의 각 원소
hotspot 객체의 헤더는 다음과 같이 구성된다
Mark 워드(32bit → 4byte, 64bit → 8byte)
Klass 워드(압축됐을 수도 있다)
객체가 배열이면 length 워드(항상 32bit)
32bit 여백(정렬 규칙 때문에 필요하다면)
oop 압축
객체 인스턴스 필드는 header 다음에 나열된다
klassOop는 klass 워드 다음에 method vtable 이 나온다
압축 oop 옵션을 끄면 성능이 개선되기도 했지만 매우 미미하다
자바에서 array 는 객체에 해당한다. 그래서 JVM의 배열도 oop로 표현된다
배열에도 metadata, mark, klass word가 있으며, 배열은 klass 뒤에 length 워드가 붙어 배열의 길이를 명시한다
jvm 에서 java reference 는 instanceOop 를 제외한 거떤 것도 point at 할 수 없다.
이것이 row level 에선 이러한 의미를 갖는다
java 값은 primitive 또는 instanceOop 주소(reference)에 대응되는 비트 패턴이다
모든 자바 reference 는 heap 주 영역(main part of the Java heap)에 있는 주소를 가리키는 pointer 라고 볼 수 있다
java reference가 가리키는 주소에 Mark word + Klass word가 들어 있다.
klassOop 와 Class<?> 의 instance 는 다르며, klassOop를 자바 변수 안에 넣을 수 없다.
runtime 에 oop 구조체를 이용해서 pointer 하나는 class metadata를, 다른 pointer 는 instance metadata 를 가리켜 나타내는 것은 드문 방식이 아니다(다른 JVM도 유사하다)

GC Roots and Arenas

Root

GC의 루트는 상당히 자주 나오는 단어이다.
메로리의 고정점(anchor point)로 memory pool 외부에서 내부를 가리키는 pointer 이다
내부 포인터(internal pointer): 메모리 pool 내부에서 같은 메모리 pool 내부의 다른 메모리를 가리킨다
외부 포인터(external pointer): 내부 포인터와 정 반대
GC root는 다음과 같이 종류가 다양하다
stack frame
JNI
register(hoisted variable)
JVM 캐시에서 code root
전역 객체
metadata of loaded class

Arenas

hotspot GC는 arena(무대) 라는 메모리 영역에서 동작한다
GC는 매우 저수준 장치이기 때문에 작동 원리를 숙지할 필요는 없지만, 성능 엔지니어에겐 필수적인 영역이다
hotspot 은 자바 heap 을 관리할 때 system call 을 하지 않는다
지금까지 hotspot에서 자바 객체를 어떻게 표현하는지 다뤘는데 이제는 GC가 무엇 때문에 일어나는지 알아본다

Allocation and Lifetime

GC가 일어나는 것은 크게 두 가지 원인이다
할당률 - allocation rate
직접 기록하진 않지만, 새로운 객체가 특정 시간동안 얼마나 새롭게 생성되었는가
객체 수명 - object lifetime
실제로는 측정이 매우 어렵다. 그래서 할당률 보다 더 예민한 요인이 된다
GC의 핵심은 객체 생성이 매우 잠시 존재하고, 그 상태를 보관하는 데 사용한 메모리를 다시 회수한다 이다.
만약 회수가 되지 않고 그 메모리를 재사용할 수 없다면 GC는 무용지물이다
GC의 trade off 는 할당 및 수명과 연관되어 있다

Weak generational Hypothesis 약한 세대별 가설

system runtime 작용을 관찰하며 알게된 경험과 지식으로 JVM이 메모리를 관리하는 이론적 근간이 있다
1.
거의 대부분의 객체는 아주 짧은 시간만 살아 있다
2.
나머지 객체는 기대 수명이 훨씬 길다
이러한 경험과 지식에 따라
1.
garbage 를 수집하는 heap 은 단명 객체를 쉽고 빠르게 수집해야 한다
2.
장수 객체와 단명 객체를 완전히 분리하는 것이 가장 좋다
(그래서 young, old 가 존재하는 듯)
이것에 따라 가설을 사용하여 다음과 같이 설계되었다
객체는 generational count를 센다
큰 객체를 제외한 나머지 객체는 eden 공간에 생성하고 여기서 살아남으면 다른 곳으로 옮긴다
장수 객체로 구분되면 old 또는 tenuerd에 보관한다
세대별 가비지 수집
generational 별 메모리를 다른 영역으로 나누면 mark and sweep 수집에 따라 결과가 세분화 된다
여기서 중요한건 외부에서 young 세대 내부를 가리키는 pointer 를 계속 추적하는 것이다.
이렇게 추적하는 목적은 일찍 죽은 객체를 찾아내기 위해서 객체 그래프를 전부 확인할 필요가 없다

Card table

늙은 객체가 젊은 객체를 참조할 일이 거의 없다 → 약한 세대별 가설의 두번째
hotspot 에는 card table 이라는 자료구조가 있다.
이것은 old 객체가 young 객체를 참조하는 경우 사용하는데, JVM이 관리하는 byte 배열로 각 element 는 old generation 공간의 512 byte 를 참조한다

핵심로직

늙은 객체 o 에 있는 참조 필드가 변경되면 instanceOop 가 들어있는 카드를 찾아 dirty marking 한다
reference 필드를 갱신할 때 마다 단순 쓰기 배리어를 이용하는데 필드 저장이 끝나면 다음을 실행한다
cards[*instanceOop >> 9] = 0;
JSON
2^9 = 512
하지만 이제 G1(garbage first) 로 변경 되었고 오라클은 이것을 더 굳힐 예정이기 때문에 7.3 에서 공부할 내용이 더 중요해 진다

Garbage collection in Hotspot

C/C++: OS를 이용해 동적으로 memory 를 관리하지 않는다
java: JVM이 메모리를 할당하고 유저 공간에서 연속된 단일 메모리 풀을 관리한다
객체는 보통 eden 에 생성되며 GC가 계속 객체를 이동시키는데 이것을 방출이라고 한다
Hotspot 수집기는 대부분 방출 수집기 이다.

Thread-local allocation

eden 에서 객체가 생성되고, 단명 객체는 다른 영역에 들어가지 못하고 eden 에서 수집된다.
따라서 eden 은 가장 관리가 잘 되어야 하는 영역이다.
JVM은 eden 을 여러 buffer 로 나누어 application thread 가 새 객체를 할당하는 구역으로 활용하도록 한다
그러면 각 thread 는 다른 thread 에 의해 침범 당할 염려를 하지 않아도 된다
이러한 구역을 thread-local allocation buffer (TLAB) 라고 한다
application 은 thread-local 에 각각 할당한다
버퍼라고 해서 특정 영역인줄 알았는데 하나의 공간을 말하는듯

Hemispheric collection 반구형 수집

반구형 (방출) 수집기 (survival 영역을 의미하는듯)
두 공간을 사용하는 특이한 수집기 이다
장수하지 못할 객체를 임시 수용하는 아이디어로 단명 객체가 old 영역을 어지럽히지 않게 하고 full GC 의 발생 빈도를 줄일 수 있다.
수집기는 live 반구를 수집할 때 객체를 다른 반구로 압착시켜 옮기고 수집된 반구는 비워서 재사용한다
절반의 공간은 항상 완전히 비워둔다
실제로 공간을 2배로 사용하기 때문에 다소 비효율적 이지만, 공간이 너무 크지 않다면 상당히 효율적이다
hotspot 에서 young heap 을 survival 공간이라 한다
이 공간을 튜닝하는 것은 9장에서 다룬다
visual GC를 통해서 확인해볼 수 있다

The Parallel Collectors

java8 까지의 default GC 이다.
처리율에 최적화 되었고, young, old full GC 모두에서 STW를 일으킨다
thread 를 모두 중단시키고 가용 CPU core 를 총동원해 가능한 재빨리 memory 를 수집한다

Parallel GC

가장 단순한 young 세대용 병렬 수집기

ParNew GC

CMS 수집기와 함께 사용할 수 있도록 Parallel GC를 변형했다

ParallelOld GC

old(tenured) 수집기
종류를 각각 다르지만, 빠른 시간에 살아 있는 객체를 식별하여 기록 작업을 최소화하도록 설계된 점이 비슷하다

Young parallel Collections

young generation에 수집이 발생하는 것은 eden 에 어떤 객체를 할당하려는데 TLAB 공간이 부족하여 JVM이 새로운 TLAB 공간을 할당할 수 없을 때 young GC가 발생한다
이때 전체 thread를 중단시키는데 어떤 thread 에서 객체를 할당할 수 없다면 다른 thread 도 같은 처지가 된다
1.
전체 application thread 가 중단되면 hotspot 은 young generation을 뒤져서 garbage 가 아닌 객체를 골라낸다
2.
이때 root를 명렬 마킹 스캔 작업의 출발작업으로 시작한다
3.
parallel gc는 살아남은 객체를 비어있는 survivor 공간으로 방출한다
4.
eden 과 막 객체를 방출시킨 survivor 공간을 재사용할 빈 공간으로 표시한다
5.
application thread 를 재시작해 TLAB를 application thread에 배포하는 process로 재개한다
young 수집
young 방출
살아있는 객체만 건들여 STW를 최대한 줄이는 세대별 가설의 장점을 최대한 사용한다
이때 가용 cpu core를 총동원한다

old parallel collections

parallel GC와 상당히 비슷하지만 parallel old gc 는 하나의 연속된 메모리 공간에서 압착한다
old generation 에 GC가 필요하면 old generation 을 뒤져 죽은 객체를 찾아내 버려진 공간을 회수하고자 하며 살아남은 객체는 재배치 하기 때문에 메모리 단편화 걱정을 할 필요가 없다
방출 vs 압착
young 은 단명 객체를 처리하기 때문에 변화가 많지만, old parallel gc 는 큰 변화가 없는 것 처럼 보인다
가끔 큰 객체가 old 에 생성되는 것 말고는 수집이 일어나 재배치 하는 것 말고는 큰 변화가 없다

Limitations of Parallel Collectors 병렬 수집기의 한계

병렬 수집기는 전체를 대상으로 한번에, 가능한 효율적으로 GC를 수행함을 목적으로 한다
이러한 parallel gc 의 단점은 full GC가 일어난다는 점이다
young 에서는 극 소수의 객체만 살아남기 때문에 STW가 별 문제는 없지만, 이것은 중단시간이 매우 짧다는 가정이다
하지만, old 에선 이야기가 다르다
old의크기=young7old 의 크기 = young * 7
따라서 STW의 시간도 훨씬 길어진다(살아있는 객체를 마킹 하는 시간도 훨씬 길어진다)
old 는 대체로 GC가 실행되도 살아남아 있는 확률이 많다
메모리에 비례하여 STW 시간이 늘어난다
parallel GC는 40년 넘게 사용되었기 때문에 이렇게 해볼까? 수준으론 절대 개선되지 않는다
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(); }
Java
asInt 는 int 한 개인 배열인데 thread 가 생성되면서 다른 thread 에도 할당된다
TLAB에 할당되자마자 새로운 thread 로 넘어가는 것이다
즉, 최초에만 하나의 thread 에 할당되고 시간이 지나면서 TLAB 의 원칙이 깨지게 된다
손쉽게 thread 를 생성하는 java의 능력은 강점이지만, 새 thread 는 실행 stack을 의미하고 GC 루트가 되므로 GC 관점에서는 복잡해진다

The Role of Allocation 할당의 역할

GC process는 유입된 메모리 할당 요청을 수용하므로 메모리가 부족할때 필요한 만큼의 메모리를 공급한다
즉, GC가 규칙적으로 예측 가능한 시점에 일어나는 것이 아니라 필요할 때 발생한다
이 불규칙적 발생이 GC의 가장 중요한 특징이다
할당의 중요성을 설명하기 위해 다음과 같이 가정해보자 (실제로는 더 복잡함)
eden은 4초만에 채워진다
4초마다 GC 발생
eden의 객체는 대부분 사망하지만, 살아남으면 survival 1 (SS1)로 방출된다
마지막 200ms 이내에 생성된 객체는 사망할 시간조차 없으므로 무조건 살아남는다
??
4초뒤 20MB 가 살아남았다
4초 뒤에 eden 은 다시 꽉 채워지고 SS2로 방출시켜야 하지만, GC0 에서 살아남아 SS1로 승격된 객체는 하나도 생존하지 못한다 → 200ms 인데 4초가 지났으니 살아남은 객체가 없음
4초뒤 SS2로 이동
GC1 이후 ss2 는 에덴에 새로 도착한 객체들로만 채워지고 generation 1 은 없다
여기서 한번 더 수집하면 패턴이 더 분명해진다
4초뒤 eden → ss1 로 이동
즉 survival 에서 old로 승격이 일어나지 않는다
이것은 굉장히 이상적인 상황이다 실제로는 할당률이 상당히 치솟기도 한다
1.
처음 2초동안 200MB가 eden 에 할당되었다
2.
장수 객체가 없다면, 이 객체의 수명은 100ms 이다
3.
0.2초 만에 200MB가 다시 eden 에 추가 할당된다고 하자
4.
100ms 이하인 객체는 다 합쳐 100MB 이다
5.
살아남은 객체 용량이 survival 보다 크기 때문에 JVM 은 old 로 보낸다
100MB가 생존했지만 모두 금새 죽을 객체이기 때문에 old(tenured)는 지저분해진다 Full GC가 일어나기 전 까진 회수되지 않기 때문이다
몇번 더 수집을 진행해보자
살펴보면 일정하게 실행된 것이 아니라 GC는 필요할 때 마다 실행된다
할당률이 높다면 GC는 더 자주 발생하고, 너무 높다면 객체는 tenured(old)로 승격된다
이러한 현상을 조기 승격 이라 한다