Garbage Collection
Java의 Garbage Collection이란 사용하지 않는 객체를 자동으로 회수하는 기능입니다.
때문에 개발자가 명시적으로 객체를 회수할 필요는 없지만 동작에 대한 관여(튜닝)는 할 수 있기 때문에 여러 GC의 동작에 대해 이해할 필요가 있습니다.
GC에 대해서 자세하게 알아보기 전에 'stop-the-world'라는 용어에 대해 알아야합니다.
'stop-the-world'란 GC을 실행하기 위해 JVM(Java Virture Machine)이 애플리케이션 실행을 멈추는 것입니다.
어떤 GC 알고리즘을 사용하더라도 'stop-the-world'는 발생하며 대개의 경우 GC 튜닝은 'stop-the-world' 시간을 줄이는 것입니다.
기본적으로 JVM 메모리는 총 5가지 영역(Method, Heap, Stack, PC Register, Native Method Stack)으로 나뉘는데, GC는 Heap 영역의 메모리에 대해서만 관여합니다.
Java의 Garbage Collector는 여러 종류가 있지만 공통적으로 크게 다음의 2가지 작업을 수행한다고 볼 수 있습니다.
1. Heap 영역 내의 객체 중에서 Garbage를 찾아낸다.
2. 찾아낸 Garbage를 처리해서 Heap 영역 내 할당된 메모리를 회수한다.
일반적으로 다음과 같은 경우에 Garbage가 됩니다.
1. 객체가 NULL인 경우 (ex. String str = null)
2. 블럭 실행 종료 후, 블럭 안에서 생성된 객체
3. 부모 객체가 NULL인 경우, 포함하는 자식 객체
GC는 Weak Generational Hypothesis에 기반합니다.
신규로 생성한 객체는 대부분 금방 사용하지 않는 상태가 되고, 오래된 객체에서 신규 객체로의 참조는 매우 적게 존재한다는 가설입니다.
이 가설에 기반하여 자바는 Young 영역과 Old 영역으로 메모리를 분할했습니타.

Young 영역
새롭게 생성한 객체의 대부분이 여기에 위치합니다.
대부분의 객체가 금방 접근 불가능 상태가 되기 때문에 매우 많은 객체가 Young 영역에 생성되었다가 사라집니다.
이 영역에서 객체가 사라질 때 Minor GC가 발생한다고 말합니다.
Old 영역
접근 불가능 상태로 되지 않아 Young 영역에서 살아남은 객체가 복사되는 공간입니다.
대부분 Young 영역보다 크게 할당하며, 크기가 큰 만큼 Young 영역보다 GC는 적게 발생합니다.
이 영역에서 객체가 사라질 때 Major GC(혹은 Full GC)가 발생한다고 말합니다.
Permanent 영역
JVM이 클래스들과 메소드들을 설명하기 위해 필요한 메타데이터들을 포함하고 있습니다.
ex) class meta / method meta
Young 영역의 구성
Young 영역은 총 3개의 영역(Eden 영역, Survivor 영역 2개)으로 나뉩니다.
처리 절차는 다음과 같습니다.
1. 새로 생성한 대부분의 객체는 Eden 영역에 위치한다.
2. Eden 영역에서 GC가 한 번 발생한 후 살아남은 객체는 Survivor 영역 중 하나로 이동한다.
3. Eden 영역에서 GC가 발생하면 이미 살아남은 객체가 존재하는 Survivor 영역으로 객체가 계속 쌓인다.
4. 하나의 Survivor 영역이 가득 차게 되면 그 중에서 살아남은 객체를 다른 Survivor 영역으로 이동한다.
그리고 가득 찬 Survivor 영역은 아무 데이터도 없는 상태로 된다.
5. 이 과정을 반복하다가 계속해서 살아남아 있는 객체는 Old 영역으로 이동한다.
절차를 보듯 Survivor 영역 중 하나는 반드시 비어 있는 상태로 남아 있어야 합니다.
그런데 Survivor 영역을 둘로 나누어 이동시키는 것에 의문점이 듭니다.
Survivor 영역을 둘로 나눈 이유는 메모리의 외부 단편화 발생을 방지하기 위함입니다.
외부 단편화란, 메모리가 할당되고 해제되기를 반복하다보면 메모리 공간은 남지만 파편화되어 있어 메모리를 할당할 수 없는 현상을 의미합니다.
그렇기 때문에 두 개의 Survivor 영역끼리 번갈아가며 메모리를 할당하여 이를 방지합니다.
Old 영역에 대한 GC
Old 영역은 기본적으로 데이터가 가득 차면 GC를 실행합니다.
GC 방식에 따라 처리 절차가 달라지기 때문에, GC 방식에 대해 이해할 필요가 있습니다.
GC 방식으로는 Serial, Parallel, Parallel Old(Parallel Compacting), Concurrent Mark & Sweep(CMS), Garbage First(G1), Z가 있습니다.
이 중 운영서버에서 절대 사용하면 안되는 방식은 Serial GC입니다.
Serial GC는 CPU 코어가 하나만 있을 때 사용하기 위해서 만든 방식입니다.
이제 각 GC 방식에 대해서 알아보겠습니다.
Serial GC
Young 영역에서의 GC는 앞 부분에서 설명한 방식을 사용합니다.
Old 영역에서의 GC는 mark-sweep-compact라는 알고리즘을 사용합니다.
이 알고리즘의 첫 단계는 Old 영역에 살아있는 객체를 식별하는 것입니다.(Mark)
그 다음에는 Heap의 앞 부분부터 확인하여 살아있는 것만 남깁니다.(Sweep)
마지막 단계에서는 각 객체들이 연속되게 쌓이도록 힙의 가장 앞 부분부터 채워서 객체가 존재하는 부분과 없는 부분으로 나눕니다.(Compaction)
Parallel GC
Parallel GC는 Serial GC와 기본적인 알고리즘은 같습니다.
하지만 Serial GC는 처리하는 스레드 하나인 것에 비해, Parallel GC는 GC를 처리하는 스레드가 여러 개입니다.
때문에 Serial GC보다 빠르게 객체를 처리할 수 있습니다.
Parallel GC는 메모리가 충분하고 코어의 갯수가 많을 때 유리합니다.
Serial GC와 Parallel GC의 스레드를 비교한 그림은 다음과 같습니다.

Parallel Old GC
Parallel Old GC는 JDK 5 update 6부터 제공한 GC 방식입니다.
Parallel GC와 비교하여 Old 영역의 GC 알고리즘만 다릅니다.
이 방식은 Mark-Summary-Compaction 단계를 거칩니다.
Summary 단계는 GC를 수행한 영역에 대해서 별도로 살아있는 객체를 식별한다는 점에서 Sweep 단계와 다르며, 약간 더 복잡한 단계를 거칩니다.
CMS GC
다음 그림은 Serial GC와 CMS GC의 절차를 비교한 그림입니다.
그림에서 보듯이 CMS GC는 지금까지 설명한 GC 방식들보다 더 복잡합니다.

초기 Initial Mark 단계에서는 클래스 로더에 가장 가까운 객체 중 살아있는 객체만 찾는 것으로 끝냅니다.
따라서, 멈추는 시간은 매우 짧습니다.
그리고 Concurrent Mark 단계에서는 방금 살아있다고 확인한 객체에서 참조하고 있는 객체들을 따라가면서 확인합니다.
이 단계의 특징은 다른 스레드가 실행 중인 상태에서 동시에 진행된다는 것입니다.
그 다음 Remark 단계에서는 Concurrent Mark 단계에서 새로 추가되거나 참조가 끊긴 객체를 확인합니다.
마지막 Concurrent Sweep 단계에서는 쓰레기를 정리하는 작업을 실행합니다.
이 작업도 다른 스레드가 실행되고 있는 상황에서 진행합니다.
이러한 단계로 진행되는 GC 방식이기 때문에 'stop-the-world' 시간이 매우 짧습니다.
모든 애플리케이션의 응답 속도가 매우 중요할 때 CMS GC를 사용합니다.
그런데 CMS GC는 'stop-the-world' 시간이 짧다는 장점에 비해 다음과 같은 단점이 존재합니다.
1. 다른 GC 방식보다 메모리와 CPU를 더 많이 사용한다.
2. Compaction 단계가 기본적으로 제공되지 않는다.
따라서, CMS GC를 사용할 때에는 신중히 검토한 후 사용해야 합니다.
그리고 조각난 메모리가 많기 때문에 Compaction 작업을 실행하면 다른 GC 방식의 'stop-the-world' 시간보다 더 오래 걸리기 때문에 Compaction 작업이 얼마나 자주, 오랫동안 수행되는지 확인해야 합니다.
G1 GC
G1 GC를 이해하려면 지금까지의 Young 영역과 Old 영역에 대해서 잊는 것이 좋습니다.
다음 그림에서 보다시피, G1 GC는 바둑판의 각 영역에 객체를 할당하고 GC를 실행합니다.
그러다가 해당 영역이 꽉 차면 다른 영역에서 객체를 할당하고 GC를 실행합니다.
즉, 지금까지 설명한 Young의 세가지 영역에서 데이터가 Old 영역으로 이동하는 단계가 사라진 GC 방식으로 이해하면 됩니다.
G1 GC는 CMS GC를 대체하기 위해 만들어 졌습니다.

그림에서 볼 수 있듯이 앞서 언급했던 Eden, Survivor, Old 영역이 존재하지만, 해당 영역은 고정된 크기가 아니며 전체 Heap 메모리 영역을 Region 이라는 특정한 크기로 나눈 것이고 Region의 상태에 따라 그 Region의 역할(Eden, Survivor, Old)가 동적으로 변동합니다.
새롭게 보이는 영역들도 존재합니다.
Humonogous 영역은 Region 크기의 50%를 초과하는 큰 객체를 저장하기 위한 공간입니다.
Available/Unused 영역은 아직 사용되지 않은 Region을 의미하는 공간입니다.
G1 GC의 가장 큰 장점은 성능입니다.
지금까지 설명한 어떤 GC 방식보다 빠릅니다.
G1 GC는 Java 9부터는 G1이 기본 가비지 컬렉터로 설정되었습니다.
G1 GC의 절차는 다음과 같습니다.

Initial Mark 단계에서는 'stop-the-world'가 발생하며 Old Region에 존재하는 객체들이 참조하는 Survivor Region을 찾습니다.
Root Region Scan 단계에서는 위에서 찾은 Survivor 객체들에 대한 스캔 작업을 실시합니다.
Concurrent Mark 단계에서는 전체 Heap의 스캔 작업을 실시하고, GC 대상 객체가 발견되지 않은 Region은 이후 단계를 제외합니다.
Remark 단계에서는 'stop-the-world'가 발생하며 최종적으로 GC 대상에서 제외할 객체를 식별합니다.
CleanUp 단계에서도 'stop-the-world'가 발생하며 살아있는 객체가 가장 적은 Region에 대한 미사용 객체를 제거합니다.
Copy 단계에서도 'stop-the-world'가 발생하며 Cleanup 과정에서 완전히 비워지지 않은 Region의 살아남은 객체들을 새로운 Region(Avaliable/Unused)에 복사하여 Compaction을 수행합니다.
ZGC
ZGC Java 11부터 실험적으로 도입되었습니다.
G1보다 짧은 지연 시간을 가지면서 G1보다 크게 뒤쳐지지 않는 처리율을 갖는 것을 목표로 등장했습니다.
G1GC와 메모리 구조가 유사한 편이며 'stop-the-world' 시간을 줄이기 위해서 Marking 시간에만 'stop-the-world'가 발생합니다.

Large 타입 페이지는 하나의 객체만 저장합니다.
따라서 Medium 타입 페이지보다 작은 사이즈가 될 수도 있습니다.
예) 6M 크기의 객체가 들어오면 4M 이상이므로 Large 타입 페이지에 저장해야하며,
이 때 생성된 Large 타입 페이지는 6M 가 될 것이므로 Medium (32M) 보다 작은 사이즈입니다.
ZGC의 핵심은 Colored pointers와 Load barriers 라는 2가지 알고리즘입니다.
Colored pointers
Concurrent GC 를 사용해서 객체의 GC 메타데이터를 객체 주소에 저장합니다.

객체를 가리키는 변수의 포인터에서 64bit를 활용하여 Marking 하고 있습니다.
Finalizable: finalizer을 통해서만 참조되는 Object의 Garbage
Remapped: 재배치 여부를 판단하는 Mark
Marked 1 / 0 : Live Object
그렇기 때문에 ZGC는 반드시 64bit 운영체제에서만 사용이 가능합니다.
Load Barriers
JIT를 사용해 GC 를 돕기 위해 작은 코드를 주입합니다.
ZGC는 G1 GC와는 다르게 메모리를 재배치하는 과정에서 'stop-the-world'가 발생하지 않습니다.

이때 Remap Mark와 Relocation Set을 확인하면서 참조와 Mark를 업데이트하게 됩니다.
그래서 ZGC는 아래와 같은 Flow를 따르게 됩니다
Mark Start : ZGC의 Root에서 가리키는 객체 Mark 표시
Concurrent Mark/Remap: 객체의 참조를 탐색하면서 모든 객체에 Mark 표시
Mark End STW : 새롭게 들어온 객체들에 대해 Mark 표시
Concurrent Pereare for Relocate: 재배치하려는 영역을 찾아 Relocation Set에 배치
Relocate Start STW : 모든 Root 참조의 재배치를 진행하고 업데이트
Concurrent Relocate: 이후 Load Barriers 를 사용하여 모든 객체를 재배치 및 참조 수정
G1 GC와의 차이점은 Pointer를 이용해서 객체를 Marking하고 관리하는 것입니다.
어떠한 Heap 메모리 사이즈가 오더라도 각각의 'stop-the-world' 시간을 10ms 이하로 줄이는 것이 ZGC의 궁극적인 목표입니다.

사진으로 볼 수 있듯이 ZGC는 큰 메모리에 적합한 GC 방식으로 볼 수 있습니다.
출처
https://johngrib.github.io/wiki/java-gc-tuning/#java-8-1
https://d2.naver.com/helloworld/1329
https://huisam.tistory.com/entry/jvmgc
https://gyoogle.dev/blog/computer-language/Java/Garbage%20Collection.html
'Java' 카테고리의 다른 글
자바의 Collection Framework - 동기화, 병렬 처리 (0) | 2022.08.09 |
---|---|
자바의 Generic이란? (0) | 2022.05.01 |
Mockito를 사용해서 자바 애플리케이션을 테스트 하는 방법 (0) | 2022.03.20 |
JUnit5를 사용해서 자바 애플리케이션을 테스트 하는 방법 (0) | 2022.03.18 |
자바 Object 클래스의 equals, hashCode, toString 메서드 (0) | 2022.03.16 |