내가 이해한 Garbage Collection
kindof
·2023. 6. 5. 22:44
자바 개발자라면 Garbage Collection(이하 GC)에 대해 당연히 들어보고 한 번쯤은 공부해봤으리라 생각합니다.
하지만 어느 수준 이상의 메모리를 사용하는 애플리케이션 환경이 아니고서는 GC에 직접적인 관심을 가지기는 어려운 것 같은데요.
저 역시 얼마 전 회사에서 운영하고 있는 PM 서버를 VM으로 이사하면서 JVM 메모리 할당과 GC 옵션에 대해 들여다볼 기회가 있었고, 이번 기회에 GC의 동작 방식과 여러 가지 GC 방법에 대해 나름대로의 이해를 정리해보려고 합니다.
1. GC, Stop the word!
GC는 JVM에서 제공하는 메모리 관리 메커니즘입니다. Heap 영역에서 더 이상 참조되지 않는 객체를 메모리에서 제거하여 앞으로 애플리케이션 동작에 있어 필요한 메모리를 확보하는 데 목적이 있습니다.
조금 더 구체적으로 보면, GC는 아래 그림처럼 Heap 영역을 크게 Young, Old, Permanent Generation으로 나누어서 바라봅니다(G1GC, ZGC 등에서는 메모리 구조가 다르지만 기본적인 구조를 먼저 설명합니다).
Young 영역은 모든 새로운 객체가 할당되는 영역입니다. 그만큼 Young 영역은 빠르게 포화되며 영역이 가득차게 될 때 Minor GC가 발생하게 됩니다. 이 때, 살아남는(참조 가능한) 객체들은 Survivor Space(S0, S1)으로 반복적으로 이동하며 Age가 증가하고, 특정 임계값을 넘을 때 Old 영역으로 이동합니다.
Old 영역은 Young 영역에서 살아남은 객체를 저장하는 데 사용됩니다. Young 영역보다 큰 공간을 차지하고 있기 때문에 Minor GC보다는 비교적 덜한 빈도로 GC가 수행되지만 한 번 발생하면 더 오랜 시간이 소요됩니다. 이를 Major GC(Full GC)라고 하며 애플리케이션의 성능은 Major GC의 방식에 많이 의존하게 됩니다.
한편, GC 수행을 위해 JVM은 일시적으로 애플리케이션 동작을 위해 할당한 쓰레드의 작업을 멈추고 GC만을 위한 쓰레드를 사용하게 됩니다.
그리고 이 순간을 바로 'Stop-the-world' 라고 표현하는데요. 결국 여러가지 GC 방식을 이해하고 GC 옵션을 조정한다는 것은 Stop-the-world 시간을 최소화하고 불필요한 객체의 메모리 해제는 성실히 수행하게 하는 데 목적이 있겠습니다.
2. Old 영역에서의 Major GC
기본적인 Major GC의 절차는 Mark, Sweep, Compaction을 기반으로 합니다.
Mark는 JVM의 Garbage Collector가 스택의 모든 변수를 스캔하면서 각각 어떤 객체를 레퍼런스하고 있는지 찾는 과정입니다. 이 때, 도달 가능한 객체가 레퍼런스하고 있는 객체 또한 마킹합니다.
그리고 나서 마킹되어있지 않은 모든 오브젝트들을 Heap에서 제거하는 과정이 Sweep입니다.
마지막으로 메모리 영역을 다시 앞에서부터 채워나가는 작업이 Compaction입니다.
3. 여러가지 GC 알고리즘
GC 알고리즘은 크게 Concurrent, Non-Concurrent 방식으로 나뉘는데요. Concurrent GC의 기본 패러다임은 애플리케이션의 동작과 GC를 동시에 수행한다는 데 있습니다.
Serial, Parallel, Parallel Old GC 3가지 방식은 Non-Concurrent GC 방식에 속하고, 그 외 GC는 Concurrent 방식에 속합니다.
3-1. Serial GC(-XX:+UseSerialGC)
Serial GC는 하나의 쓰레드로 GC 작업을 수행합니다. 애플리케이션 서버가 적은 메모리와 적은 수의 코어 위에서 동작할 때 사용하는 방식이기 때문에, 최근의 멀티 프로세서 기반 애플리케이션에서는 잘 사용하지 않습니다.
3-2. Parallel GC(-XX:+UseParallelGC), Parallel Old GC(-XX:+UseParallelOldGC)
Parallel GC는 메모리가 충분하고 코어의 개수가 많을 때, 다수의 쓰레드를 활용해 Minor GC 작업을 수행합니다.
Java 8 버전부터 Default GC로 선택되었고, Minor GC를 병렬로 처리하기 때문에 Serial GC보다 Stop the world 시간이 단축됩니다.
한편, Parallel GC가 Minor GC를 병렬로 처리했다면 Parallel Old GC는 Major GC까지 병렬로 처리합니다. Java 7 Update Release Note에서는 -XX:+UseParallelGC를 사용할 때 -XX:+UseParallelOldGC 역시 default로 사용된다고 합니다.
Parallel GC에서 몇 개의 쓰레드를 사용할 것인가에 대해서는 아래 Oracle 문서에서 언급하고 있습니다. CPU의 개수가 8개 이하라면 CPU의 수만큼, 8개 이상일 때는 코어 개수 * 5/8 값으로 설정한다고 말합니다.
다만, 애플리케이션만을 동작시키기 위한 전용 서버를 두는 상황이라면, 다른 작업을 위한 쓰레드 할당을 고려하지 않아도 되기 때문에 위 공식의 값에 몇 개의 쓰레드를 더해서 사용할 수도 있습니다.
3-3. G1 GC
Java9 버전에서 기본 GC 전략을 ParallelGC에서 G1GC(Garbage First Garbage Collector)로 가져갔습니다.
G1GC는 이전의 GC와 달리 전체 Heap 영역을 탐색하지 않고, 메모리가 많이 차있는 Region을 우선적으로 인식하여 GC를 진행하고 Region 별로 Compaction을 진행하기 때문에 더 많은 쓰레기들을 빠르게 청소할 수 있습니다.
위 그림처럼 G1GC는 맨 처음에 소개했던 Young, Old Generation 기반으로 메모리를 관리하지 않습니다.
대신, 오른쪽 그림처럼 영역(Region) 이라는 개념을 도입하여 Eden, Survivor, Old 역할을 동적으로 부여하고 있습니다.
하나의 영역은 기본적으로 Heap Size / 2048MiB 만큼의 크기를 가지며, 2의 제곱수로 결정됩니다. 예를 들어, Heap Size가 8GiB라면 한 영역의 크기는 8GiB / 2048Mib = 4MiB가 됩니다. -XX:G1HeapRegionSize를 통해 직접 영역의 크기를 정할수도 있지만 2048개의 영역을 가지도록 하는 것이 힙 영역을 세밀하게 조정하여 GC의 성능을 향상시키기 때문에 기본값을 사용하는 것이 권장된다고 합니다.
위 그림을 잘 보면 Humonogous 라는 영역이 존재하는데요. 이는 거대 객체를 저장하기 위한 영역을 말합니다. 거대 객체에 대해서는 GC를 먼저 설명하고 이야기하겠습니다.
G1GC에서의 Minor GC
G1GC의 Young Region에서 일어나는 Minor GC는 멀티 쓰레드 환경에서 병렬적으로 실행되며 살아있는 객체는 Survivor, Old 영역으로 Evacuation됩니다.
일반적인 GC 매커니즘과 크게 다르지 않습니다.
G1GC에서의 Major GC
가장 처음, Initial Marking 단계에서는 Survivor 영역에서 Old 영역을 참조하는 객체가 있는지 파악해서 마킹합니다.
Concurrent Marking 단계에서는 Old 영역에서 생존한 객체를 마킹합니다. 이 단계에서는 애플리케이션과 쓰레드가 동시에 돌기 때문에 Stop-the-world 시간이 발생하지 않습니다.
Remarking 단계에서는 SATB(Snapshot-at-the-beginning)라는 기법을 사용하는데요. SATB는 마킹 주기의 시작 시점에 Heap 내의 살아있는 객체 집합의 스냅샷을 가져와 사용하는 방식입니다.
Concurrent Marking은 애플리케이션이 실행되고 있는 상황에서 진행되기 때문에 Heap의 상태가 동시에 변경될 수 있습니다. 따라서, SATB 기법은 최초에 마킹 주기 이전에 할당된 모든 객체를 버퍼에 기록하고, 이후에 버퍼의 내용을 분석하여 이전 마킹 주기 이후에 할당된 객체를 탐색합니다.
여기까지 작업을 통해 전체적인 Marking Phase가 종료됩니다.
마지막으로 Copy 단계에서는 멀티 쓰레드 하에서 이전에 마킹 단계를 통해 식별된 살아있는 객체를 새로운 영역으로 복사합니다. 이 단계에서는 애플리케이션 쓰레드가 일시 중지되지 않기 때문에 Stop-the-world가 발생하지 않습니다.
Clean 단계에서는 Copy 이후 남은 빈 공간을 정리합니다. 이 과정은 멀티 쓰레드 하에서 Compaction이 일어나고 Stop-the-world를 수반합니다.
한편, G1GC는 Concurrent GC 방식이며 아래 오라클 문서에서 ConcGCThreads 값은 Parallel GC 쓰레드의 1/4로 계산하라고 말합니다.
이 값 역시, 애플리케이션만을 위한 전용 서버를 두는 상황이라면 조금 더 여유롭게 설정해도 됩니다.
거대 객체(Humonogous Object)
한편, 한 영역 크기의 절반을 넘는 객체를 거대 객체(Homonogous Object)라고 합니다. 거대 객체는 하나의 영역보다 사이즈가 작을 때는 해당 영역을 전체 점유하고, (N, N+1) 영역만큼의 사이즈라면 항상 N+1개의 공간을 점유하게 됩니다.
이로 인해 거대 객체는 G1GC의 큰 약점이 되었습니다.
만약 한 영역의 크기가 16MB라 하고, 거대 객체의 크기가 17MB라 하면 해당 영역에서 약 47%의 공간이 낭비됩니다. 또한, 거대 객체의 크기가 33MB라면 두 개의 영역을 차지하기 때문에 약 48%의 공간 낭비가 발생합니다.
한편, 서두에 말했던 것처럼 G1GC는 Garbage가 가장 많이 존재하는 영역(Collection Set)을 대상으로 GC를 수행합니다. 그리고 거대 객체가 특정 영역에 할당될 때, 이 영역은 곧바로 Collection Set에 포함되는데요.
거대 객체는 일반적인 객체보다 GC 작업에 필요한 시간이 오래 걸리고, 이 과정에서 이동도 하지 않기 때문에 결과적으로 Major GC의 성능 저하로 인한 애플리케이션의 성능 저하와 메모리 낭비를 야기합니다.
따라서, 애플리케이션에서 거대 객체 할당으로 인한 Major GC의 성능 저하가 나타난다면 -XX:G1HeapRegionSize 값을 조절하거나 객체 할당 자체를 조금 더 작은 단위로 하는 방식을 고민해봐야 합니다.
4. ZGC(-XX:+UseZGC)
ZGC는 JDK 11 버전에서 실험적으로 도입되었고 JDK15버전부터 Production Ready로 사용할 수 있게 되었습니다.
OpenJDK Wiki에서 설명하듯 ZGC는 Concurrent, Region-based Garbage Collector이며 애플리케이션의 성능을 대폭 향상시켜준다고 하는데요.
어떤 메커니즘을 가지고 ZGC가 동작하는지 살펴보겠습니다.
4-1. ZGC Concepts
먼저, ZGC에 대해 이해하기 위해 몇 가지 개념에 대해 짚고 넘어가겠습니다.
ZPages
ZPage는 G1GC가 고정적인 크기로 영역을 할당한 것과 다르게 2MB의 배수 크기의 영역을 동적으로 생성하고 소멸시킵니다.
위 그림에서 Small, Medium, Large 타입의 페이지를 볼 수 있는데요. 작은 객체 (최대 256KB 크기)는 Small 페이지에 할당되며, 중간 크기의 객체 (최대 4MB)는 Medium 페이지에 할당됩니다. 4MB보다 큰 객체는 Large 페이지에 할당됩니다.
Large 페이지에는 Small이나 Medium 페이지와 달리 정확히 하나의 객체만 저장될 수 있습니다. 약간 헷갈릴 수 있지만, Large 페이지는 실제로 Medium 페이지보다 작을 수도 있습니다. 예를 들어, 6MB 객체는 Large page에 할당되기 때문이죠.
ZPages의 도입으로 위에서 설명했던 G1GC 거대 객체의 메모리 낭비 문제를 해결할 수 있게 되었습니다.
Multi-Mapping
운영체제 시간에 배웠던 것처럼, 물리 메모리(Physical Memory)는 하드웨어 중에서 램(RAM) 자체를 사용합니다. 그리고 가상 메모리(Virrtual Memory)는 프로세스 관점에서 자신이 가지고 있는 메모리를 의미합니다.
프로세스가 실행되면 해당 프로세스가 가진 명령어(Instruction)들과 데이터를 메모리에 올려 사용하게 되고, 프로세스가 종료되면 메모리가 해제되어 다른 프로세스들이 메모리를 사용할 수 있는 것이죠.
Multi-Mapping이란 물리 메모리의 같은 주소를 가리키는 여러 개의 매핑 주소가 가상 메모리에 존재하는 상황을 말합니다.
이러한 매핑 방식이 ZGC에서 어떻게 활용되는지는 아래에서 더 구체적으로 설명하겠습니다.
Colored Pointers
기본적으로 ZGC는 64비트 OS에서만 사용 가능한데요. ZGC는 객체의 메모리 주소를 표시할 때 64비트 중에서 4비트를 GC에 필요한 데이터를 저장하는 데 사용합니다.
다시 말해, 과거의 GC는 전체 힙 영역을 스캔하여 GC 대상을 탐색했다면 ZGC는 즉각적인 객체의 참조를 통해 GC를 대상 여부를 파악하는 것입니다.
위 그림에서 Finalizable, Remapped, Marked1, Marked0, 총 4가지 값에 대한 비트가 있습니다.
- Finalizable : 객체가 finalize() 메서드를 가지고 있는지 여부를 나타내며, finalize()는 GC 직전에 호출되는 메서드입니다.
- Remapped : 해당 객체의 재배치 여부를 판단하는 값으로, Bit의 값이 1이면 최신 참조 상태임을 의미합니다.
- Marked0 : 해당 비트가 설정되어 있으면 참조 가능한 객체라는 것을 의미합니다.
- Marked1 : 객체의 마킹 상태를 보조하는 비트입니다. 마킹 작업 중에 다른 쓰레드와의 동기화를 수행하기 위해 필요합니다.
하지만 객체에 대한 참조 상태를 비트로 저장하게 되면, 여러 개의 참조가 동일한 객체를 가리키고 있을 때 어떤 값이 정확한 값인지 정하기 어렵습니다.
이 때, 위에서 설명한 MultiMapping은 ZPage를 할당할 때 동일한 페이지를 3개의 다른 가상 메모리에 할당해버립니다. 그리고 해당 메모리가 매핑되는 물리 메모리 주소는 모두 동일하게 설정하죠.
이렇게 하면 가상 메모리 위치는 다르지만 동일한 물리 메모리를 바라보게 되고, 각 비트가 변경되어도 실제 다루는 객체는 동일하게 유지됩니다.
Load barriers
애플리케이션의 쓰레드가 스택에서 힙 영역에 있는 객체를 참조할 때, JIT에 의해 Load barrier 코드가 주입됩니다.
Load barrier 코드는 해당 객체의 메모리 주소에 기록해둔 GC 관련 데이터(Colored Point) Bit를 체크하는데요.
해당 객체의 참조 상태가 Bad color인지 확인하고, 그렇다면 Slow Path로 진입하게 합니다. 자세한 내용은 아래에서 설명하겠습니다.
4-2. ZGC Works
ZGC는 아래와 같이 Marking, Relocating 단계를 Cycle로 수행합니다.
[1] Mark start
Stop-the-world가 발생합니다.
애플리케이션의 각 쓰레드에서 힙 영역의 객체를 참조하고 있다면 해당 참조의 시작 포인트를 GC Root Set으로 정의합니다. GC Root Set은 local variable, static field 등으로 구성되고 이 과정은 그렇게 오래 걸리지 않습니다.
[2] Concurrent Marking
GC root set에서 시작하여 객체 그래프를 탐색합니다. 실제로 참조되고 있는 객체를 살아있는 객체로 마킹하고, 도달하지 못하는 객체를 GC 대상으로 판별합니다.
이 때, ZGC는 각 페이지의 Livemap이라는 공간에 살아있는 객체 정보를 저장합니다. Livemap은 주어진 객체의 strongly-reachable, final-reachable 정보를 저장하고 있습니다.
여기서 Strongly-reachable 객체란 힙에서 다른 객체로의 직접적인 참조를 가지고 있는 객체를 의미하여 GC 대상이 되지 않습니다. 한편, Final-reachable 객체란 GC 대상이지만 GC 되기 직전에 호출되는 메서드인 finalize() 메서드가 실행되지 않은 객체를 의미합니다.
이 단계에서 애플리케이션 쓰레드의 경우 Load barrier를 통해 객체 참조에 대한 테스트를 진행하고, 참조가 Bad color(GC 대상)라면 slow_path로 진입하여 이후 마킹을 위한 쓰레드 로컬의 마킹 큐에 추가합니다. 이 버퍼가 가득차면 GC 쓰레드가 버퍼에서 도달할 수 있는 객체를 탐색하고 Livemap을 업데이트합니다.
지금까지의 설명을 종합해보면, Load barrier는 런타임에 다른 쓰레드에 의한 참조 변경 사항을 확인하고 최신 상태를 얻을 수 있도록 합니다. 또한, 쓰레드 로컬의 마킹 큐를 이용하여 동시성을 유지하면서 객체 참조를 처리할 수 있도록 돕는 역할을 합니다.
[3] Mark end
STW가 발생하고, 쓰레드 로컬의 마킹 큐를 탐색하여 클리어합니다.
[4] Concurrent Processing
Relocation Set이란 GC 대상 페이지들의 집합을 의미합니다. 이 단계에서 해당 페이지들은 GC 대상이 되므로 이후 재배치되기 위한 주소를 제공하는 Forwarding Table을 부여받습니다.
[5] Relocation Start
Stop-the-world가 발생합니다. Relocation Set의 모든 참조를 재배치하고 참조를 업데이트합니다.
[6] Concurrent Relocation
GC 쓰레드는 살아있는 객체를 탐색하고 아직 재배치되지 않은 객체를 새로운 ZPage로 재배치시킵니다.
Remapping, Load Barriers
위의 GC 과정에서 재배치(Relocation) 단계에서는 재배치된 주소로 GC Root Set의 참조를 수정하지 않았습니다.
이 때, ZGC는 위에서 언급했던 Load barrier를 활용하는데요. Load barrier는 Remapping이라는 기술을 통해 재배치된 객체를 가리치는 참조를 수정합니다.
그 과정을 보면 아래와 같습니다.
[1] Colored Pointers의 Remapped bit가 1로 설정되어 있는지 확인하고, 1이라면 참조가 최신 상태임을 확인하고 리턴합니다.
[2] 해당 참조가 Relocation set에 속해 있는지 확인합니다. 속해있지 않다면, 해당 객체를 재배치하지 않았던 것이라고 판단합니다. 그리고 다음에 같은 일을 반복하지 않기 위해 Remapped bit를 1로 설정하고 리턴합니다.
[3] 아직도 리턴받지 못한 상태라면, 해당 객체는 재배치 대상이었음을 알 수 있습니다. 이제, 실제로 재배치가 이루어졌는지를 확인하고 재배치되었다면 다음 단계로 넘어가고 그렇지 않다면 객체를 재배치한 후, 새로운 주소를 저장하는 Forwarding table에 항목을 생성합니다.
[4] 이 단계까지 오면 객체는 재배치된 상태이기 때문에, 참조를 리턴합니다.
이러한 Load barrier의 작업으로 객체에 접근할 때마다 가장 최신의 참조를 얻을 수 있습니다.
5. 정리
이번 글에서는 GC의 전반적인 개념과 각 GC의 동작에 대해 정리해봤습니다. 많은 레퍼런스를 보면서 공부했음에도 GC는 정말 어려운 주제라는 생각이 듭니다.
그리고 글을 쓰다보니 내용이 너무 길어진 것 같아 여러 편으로 나눠쓸까 생각도 해봤는데, 전반적인 GC의 개념과 동작 패러다임의 변화를 보기 위해서는 하나의 글로 정리하는게 나을 것 같다고 생각했습니다.
하나의 GC 방법에 대한 좀 더 자세한 내용, 성능 튜닝 방식 등에 대해서는 다른 글로 따로 정리해보겠습니다.
감사합니다.
6. Reference
https://wiki.openjdk.org/display/zgc/Main#Main-SettingHeapSize
https://openjdk.org/projects/zgc/
https://www.alibabacloud.com/blog/alibaba-dragonwell-zgc-part-2-the-principles-and-tuning-of-zgc-%7C-a-new-garbage-collector_598851
https://d2.naver.com/helloworld/1329
https://www.baeldung.com/jvm-zgc-garbage-collector
'Java & Kotlin' 카테고리의 다른 글
[코틀린 인 액션] 1장, 코틀린이란 무엇이며 왜 필요한가? (0) | 2023.08.13 |
---|---|
ExecutorService? Thread Pool Size는 어떻게 정해야할까 (1) | 2023.08.06 |
Thread 클래스를 상속하는 것보다 Runnable 인터페이스를 구현해야 하는 이유 (1) | 2023.05.09 |
인터페이스의 메서드가 각기 다른 리턴 타입과 파라미터를 필요로 한다면? (0) | 2023.05.08 |
[Java] Thread 안에서 발생하는 예외는 어떻게 처리되나 (0) | 2023.03.31 |