목차
- 들어가기 전에
- Stop the world 란?
- GC의 종류
- Shenandoah GC
- 개요
- Jdk Support
- Shenandoah GC Cycle
- Shenandoah Core Concepts
- Performance Guidelines and Diagnostics
- ZGC
- 개요
- Jdk Support
- ZGC Cycle
- ZGC Core Concepts
- 마치며
들어가기 전에
우선 Shenandoah GC로 바로 들어가기 전에, GC 에 대한 간략한 이해(Stop the World)와 다른 어떤 GC들이 존재하는지 살펴보고 나서 들어가도록 하겠습니다.
Stop the world 란?
Stop the world(이하 STW)는 JVM이 Garbage collection(이하 GC)을 수행하기 위해 애플리케이션의 실행을 잠깐 중단하는 것을 의미합니다. STW가 발생할 때, GC를 실행하는 스레드를 제외한 모든 나머지 스레드는 작업을 일시 중지합니다. 그리고 GC가 끝난 후에야 중지되었던 작업이 다시 시작됩니다. 다만, 어떠한 GC 알고리즘을 사용하든 STW는 필연적입니다.
그렇기 때문에 일반적으로 GC 튜닝은 이 STW 시간을 최대한 줄이는 것을 목표로 합니다.
GC의 종류
1. Serial GC
Serial GC는 가장 단순한 형태의 가비지 컬렉터입니다.
Old 영역의 GC는 mark-sweep-compact을 사용하고, 마지막 단계에서 Compaction을 수행하는것이 주요 특징입니다.
이 GC는 적은 메모리와 CPU 코어 개수가 적을 때 적합한 방식입니다.
2. Parallel GC
Parallel GC는 Serial GC와 비슷하지만, 여러 스레드를 사용해 병렬로 GC를 수행합니다. 이는 멀티 코어 및 멀티 프로세서 환경에서 이점을 제공합니다. (Mark-Summary-Compaction를 가진다는 것도 Serial GC 와의 주요 차이점)
3. Concurrent Mark Sweep (CMS) GC
초기 Initial Mark 단계에서는 클래스 로더에서 가장 가까운 객체 중 살아 있는 객체만 찾는 것으로 끝냅니다. (즉, pause가 매우 짧음)
그리고 Concurrent Mark 단계에서는 방금 살아있다고 확인한 객체에서 참조하고 있는 객체들을 따라가면서 확인합니다. 이 단계의 특징은 다른 스레드가 실행 중인 상태에서 동시에 진행된다는 것이다.
그다음 Remark 단계에서는 Concurrent Mark 단계에서 새로 추가되거나 참조가 끊긴 객체를 확인합니다. 마지막으로 Concurrent Sweep 단계에서는 가비지를 정리하는 작업을 실행합니다. 이 작업도 다른 스레드가 실행되고 있는 상황에서 진행합니다.
이러한 단계로 진행되는 GC 방식이기 때문에 stop-the-world 시간이 매우 짧다는 장점에 반해 다음과 같은 단점이 존재합니다.
- 다른 GC 방식보다 메모리와 CPU를 더 많이 사용합니다.
- Compaction 단계가 기본적으로 제공되지 않습니다.
- 메모리 단편화 문제 발생 가능
4. G1 GC
위 그림처럼, G1 GC는 2차원 매트릭스의 각 영역에 객체를 할당하고 GC를 실행합니다. 실행 도중, 해당 영역이 꽉 차게 되면 다른 영역(region)에서 객체를 할당하고 GC를 실행합니다.
즉, 메모리를 여러 동일한 크기의 영역으로 나누고, 이 영역들을 병렬로 정리하여 작업을 수행했기에 엄청난 성능 향상을 가져온 GC이며, STW도 보다 예측 가능해졌습니다.
5. Shenandoah GC 및 Z Garbage Collector (ZGC)
Shenandoah 및 ZGC는 GC STW를 최소화하기 위해 설계되었습니다.
먼저, Shenandoah는 메모리를 여러 영역으로 나누는 것은 G1과 비슷하지만, 대부분의 GC 작업을 애플리케이션과 병렬로 수행함으로써 STW를 더욱 단축시킵니다.
그리고 ZGC는 애플리케이션 실행과 병렬로 메모리를 회수하고 압축하여 STW 시간을 대폭 줄입니다. 이 GC는 테라바이트 이상의 힙 크기를 처리할 수 있으며, STW pause 시간은 대부분 10 밀리초 이내로 유지됩니다. 즉, 추가적인 CPU 리소스를 사용해서 STW를 줄였다고 볼 수 있습니다.
(Shenandoah와 거의 비슷한 메커니즘을 가지고 있지만 Shenandoah는 OpenJdk 12에 도입되었고 jdk11, jdk8까지 backporting 지원. 반면, ZGC는 jdk11 이상의 버전만 지원)
지금까지 여러 종류의 기존 GC들을 가볍게 살펴봤는데 (이번 포스팅의 주목적은 Shenandoah와 ZGC를 살펴보는 것이기에)
혹시 기존 GC가 익숙하지 않으신 분들을 위해 좋은 포스팅이 있어서 링크를 남겨놓겠습니다 :)
자, 이제 지금부터 이번 포스팅의 메인 컨텐츠인 Shenandoah GC를 같이 살펴봅시다!
Shenandoah GC
Shenandoah GC는 OpenJDK 12에서 처음 도입된 GC입니다.
Shenandoah GC는 전체 heap 영역을 대상으로 하는 큰 GC를 적은 횟수로 수행하는 것보다 작은 GC(young generation)를 여러 번 수행하는 전략과 Concurrnt GC 를 통해 CPU를 더 사용하면서 pause(STW) 시간을 줄이고 보다 일관된 STW 를 제공합니다.
JDK Support
Shenandoah는 OpenJdk 12에 등장하여 jdk11, jdk8까지 backporting 지원하고 있습니다.
Shenandoah GC Cycle
GC(3) Pause Init Mark 0.771ms
GC(3) Concurrent marking 76480M->77212M(102400M) 633.213ms
GC(3) Pause Final Mark 1.821ms
GC(3) Concurrent cleanup 77224M->66592M(102400M) 3.112ms
GC(3) Concurrent evacuation 66592M->75640M(102400M) 405.312ms
GC(3) Pause Init Update Refs 0.084ms
GC(3) Concurrent update references 75700M->76424M(102400M) 354.341ms
GC(3) Pause Final Update Refs 0.409ms
GC(3) Concurrent cleanup 76244M->56620M(102400M) 12.242ms
1. Init Mark
Shenandoah 가비지 컬렉션(GC) 사이클의 첫 단계는 'Init Mark' 단계입니다. 이 단계는 STW를 동반합니다. 이 STW는 최대한 짧게 유지되며, 이 단계의 목적은 GC 사이클을 시작하기 위한 준비 작업을 수행하는 것입니다.
이 단계에서 가비지 컬렉터는 루트 집합(root set)을 표시합니다. 루트 집합은 GC가 라이브 객체를 추적하기 시작하는 출발점으로, 보통 JVM 스택과 메소드 영역 등에서 찾아낼 수 있는 객체들을 포함합니다.
이 단계에서 식별한 라이브 객체는 후속 단계에서 처리됩니다. 루트 집합을 통해 식별한 라이브 객체들은 참조 체인을 따라 다른 라이브 객체를 추적하게 됩니다. 바로 아래에서 설명할 'Concurrent Marking' 단계에서 이 작업이 병렬로 이루어집니다.
이 단계가 끝나면, 가비지 컬렉터는 라이브 객체 추적을 가능해집니다. (즉, 라이브 객체와 가비지를 구분 가능) 이 정보는 후속 단계에서 메모리 관리를 위해 사용됩니다.
2. Concurrent Makring
이 단계는 라이브 객체를 추적하고 이들이 차지하는 메모리 영역을 식별합니다. (이 단계는 애플리케이션 스레드와 동시에 실행)
가비지 컬렉터는 애플리케이션 스레드가 실행 중인 동안에도 메모리 힙을 스캔하며 라이브 객체를 추적합니다.
Concurrent Marking 단계가 끝나면, 가비지 컬렉터는 메모리의 어느 부분이 여전히 사용 중인지, 어느 부분이 더 이상 사용되지 않는지에 대해 정확하게 알게 됩니다. 이 정보를 바탕으로 가비지 컬렉터는 다음 단계인 Evacuation에서 더 이상 사용되지 않는 메모리를 해제하고, 필요에 따라 라이브 객체를 다른 위치로 이동시킬 수 있습니다.
3. Final Mark
이 단계에서 보류 중인 marking 및 update queue를 비우고 root set을 다시 탐색하여 Concurrent Marking을 완료한 후, 이동 가능한 영역을 파악하고, Evacuation 단계를 위한 초기화 작업을 수행합니다.
뿐만 아니라 일부 root 객체를 이동시키는 작업도 포함됩니다. 이 모든 작업은 다음 단계를 위한 준비를 동시에 수행하는 것이며, 일부는 Concurrent Pre-cleaning 단계에서 병행 처리될 수 있습니다.
또한 이 단계는 GC 사이클에서 두 번째 STW를 발생시킵니다. 이 단계의 대부분의 시간은 queue를 비우는 작업과 root set을 스캔하는 작업에 소비됩니다. 이 과정을 통해 GC는 메모리에서 더 이상 접근되지 않는 객체를 효과적으로 식별하고, 이동해야 하는 객체를 결정합니다.
4. Concurrent Cleanup
이 단계에서 가비지 컬렉터는 모든 영역을 스캔하여 사용되지 않는 영역을 식별합니다. 이용되지 않는 영역은 라이브 객체를 포함하고 있지 않는 영역을 말하며, 이들 영역은 '빈' 상태로 표시됩니다. 이런 방식으로 가비지 컬렉터는 이후 단계에서 더 이상 사용되지 않는 객체들을 제거하고 메모리를 해제할 수 있도록 준비합니다.
Concurrent Cleanup 단계는 애플리케이션이 계속 실행되는 동안 수행됩니다. 이는 Shenandoah GC의 주요 특징 중 하나로, 애플리케이션의 실행을 가능한 한 적게 방해하면서 GC를 수행합니다.
5. Concurrent Evacuation
Shenandoah GC는 객체 이동을 위해 Brooks Forwarding Pointer 방식을 사용합니다. 이 방식은 각 객체에 추가적인 필드인 'forwarding pointer'를 부여하는데, 이 포인터는 객체가 이동할 때 해당 객체의 새로운 위치를 가리킵니다.
이 개념과 함께, Concurrent Evacuation을 객체 식별, 이동, 참조 업데이트 3단계로 나눠서 살펴보겠습니다.
- 객체 식별: GC는 이동할 객체를 식별합니다. 이 객체들은 대개 'from' 영역에 위치해 있으며, 이 영역은 이후에 비워질 예정입니다.
- 객체 이동: GC는 식별된 객체를 'to' 영역으로 이동시킵니다. 이 영역은 이전에 비워진 상태에서 준비된 영역입니다. 이동하는 과정에서 해당 객체의 forwarding pointer가 업데이트되어 새로운 위치를 가리키게 됩니다.
- 참조 업데이트: 애플리케이션 스레드가 객체에 접근하려 할 때, 이 포워딩 포인터를 통해 객체의 현재 위치를 찾을 수 있습니다. 이는 GC가 진행되는 동안도 애플리케이션 스레드가 올바르게 객체에 접근할 수 있도록 보장합니다.
앞 단계와 마찬가지로, CPU 자원을 추가로 사용하게 되지만, 이를 통해 STW 시간을 줄일 수 있습니다.
6. Init Update Refs
이 단계는 Concurrent Evacuation 단계에서 이동된 객체에 대한 참조를 업데이트하기 위한 초기 설정 단계입니다.
객체가 이동되면, 해당 객체를 가리키는 모든 참조가 새로운 객체 위치로 업데이트되어야 합니다. 이 과정을 수행하기 위해 이 단계에서 root set(즉, GC root에서 접근 가능한 모든 객체)을 스캔하여 참조를 업데이트합니다.
이 단계는 대체로 짧은 STW를 동반합니다. (모든 애플리케이션 스레드가 멈춰있을 때만 root set을 안전하게 스캔하고 업데이트할 수 있기 때문)
따라서, 이 단계는 메모리의 일관성을 유지하고 객체 참조의 유효성을 보장하는 데 중요한 역할을 합니다. 또한, 이 단계를 통해 다음 'Concurrent Update References' 단계에서 참조 업데이트를 병렬로 수행할 수 있는 기반을 준비합니다.
7. Concurrent Update References
이 단계는 애플리케이션 실행과 concurrent 하게 참조 업데이트를 수행하는 단계입니다.
이전 단계인 'Concurrent Evacuation'에서는 객체들이 새로운 메모리 위치로 이동되었습니다. 따라서 이제 이동된 객체들을 가리키는 모든 참조들이 새로운 객체 위치로 업데이트되어야 합니다.
따라서 이 단계에서 GC가 JVM 힙을 스캔하면서 이 참조 업데이트를 수행합니다. 이 과정에서는 각 객체가 포함하고 있는 모든 참조 필드를 확인하고, 필요한 경우 해당 참조를 새로운 객체 위치로 업데이트합니다. 이 작업은 애플리케이션이 계속 실행되는 동안 병렬로 수행됩니다.
8. Concurrent Cleanup
앞에서 살펴보았기에 생략하겠습니다.
자, 지금까지 shenandoah GC Cycle에 대해 살펴보았습니다.
같이 살펴보다 보면 Concurrent 하게 수행된다는 표현을 자주 접할 수 있는데, 사실 Concurrency를 보장하는 것은 정말 쉽지 않은 문제입니다.(더군다나 shenandoah는 성능도 아주 훌륭하기에)
그래서 이번엔 Shenandoah에서 어떻게 이 고민을 해결했는지 같이 살펴봅시다!
Shenandoah Core Concepts
사실 Shenandoah는 내부 구현은 상당히 복잡하고 다양한 개념들이 존재하는데,
그 여러 가지 개념들 중 저희는 가장 핵심적인 개념인 Snapshot-at-the-beginning(이하 SATB)와 Load Reference Barriers(이하 LRB)를 차례로 살펴보도록 하겠습니다.
1. SATB
SATB는 Shenandoah GC에서 사용하는 마킹 알고리즘입니다. 이름에서 알 수 있듯 "마킹이 시작될 때의 메모리 상태를 스냅샷으로 찍어 둔다"는 의미를 담고 있습니다. 이것은 Concurrent Marking 단계가 애플리케이션 스레드와 동시에 진행될 수 있게 하는 중요한 메커니즘이며, 이를 통해 애플리케이션의 작동을 최대한 방해하지 않습니다.
SATB를 사용하면, 가비지 컬렉터는 객체가 라이브인지 아닌지를 결정하는 동안에도 애플리케이션 스레드가 계속 실행될 수 있습니다. 이는 애플리케이션 스레드가 가비지 객체를 참조하거나 참조를 끊는 동안 발생하는 문제를 처리하기 위해 SATB 큐를 사용합니다. 애플리케이션 스레드가 참조를 변경하면, 이전 참조는 SATB 큐에 들어가고 가비지 컬렉터가 이를 처리하게 됩니다.
2. LRB
LRB는 Shenandoah GC에서 라이브 객체를 옮기는 동안 발생할 수 있는 문제를 처리하는 메커니즘입니다. Shenandoah는 라이브 객체를 다른 곳으로 이동시킬 수 있으며, 이는 힙 공간을 최적화하는 데 도움이 됩니다. 그러나 이로 인해 애플리케이션 스레드가 옮겨진 객체에 접근하는 문제가 발생할 수 있습니다.
이 문제를 해결하기 위해 Shenandoah는 LRB를 사용합니다. 애플리케이션 스레드가 객체에 접근할 때마다 LRB가 이 객체가 옮겨진 객체인지 확인하고, 만약 그렇다면 이동된 새 위치를 반환합니다. 이를 통해 Shenandoah는 애플리케이션의 동작을 방해하지 않으면서도 라이브 객체를 안전하게 이동시킬 수 있습니다.
// The pseudo-code of the pre-write barrier for an assignment of the form x.f = y is:
if (marking_is_active) {
pre_val = x.f;
if (pre_val != NULL)
satb_enqueue(pre_val);
}
(코드 출처 : C. Hunt, M. Beckwith, P. Parhar, B. Rutisson. Java Performance Companion)
Performance Guidelines and Diagnostics (Link)
이 내용은 위 openjdk diagnostics 공식 문서를 번역한 내용이기에 혹시 어색한 부분이 있다면 위 링크를 참조하시면 좋을 것 같습니다 :)
General Ideas
Heap sizes
Shenandoah는 다른 GC와 마찬가지로 힙 사이즈의 영향을 받습니다. Concurrent 단계가 실행되는 동안 할당을 수용할 수 있는 Heap 공간이 충분한 경우 성능이 더 우수합니다(아래 Failure Modes 섹션 참고). Concurrent 단계의 소요시간은 live data set(LDS -- live data에 의해 할당에 공간)의 크기와 연관이 있습니다. 따라서 LDS와 할당 pressure에 따라 적절한 heap size가 달라집니다.
주어진 할당 속도에 대해 LDS가 클수록 그에 비례하여 더 큰 힙 크기가 필요하고, 주어진 할당 속도가 클수록 더 큰 힙 크기가 필요합니다.
평균적인 workload, 작은 Live Data Set, 보통 수준의 pressure인 경우 1~2GB의 힙이 적합합니다.
다만, 우리는 4~128GB 힙이 80% 이상의 LDS를 가진 상태에 대해 다양한 workload를 지속적으로 테스트했습니다.
따라서, 어떤 heap size가 workload에 적합한지 다양하게 시도해 보세요.
Pauses
Shenandoah의 pause 동작은 root set 작업(root 스캔 및 업데이트)에 의해 결정됩니다.
Root Set은 로컬 변수, generated 된 코드 내부 참조, interned string, classloader의 참조(static final과 같은 참조), JNI 참조, JVMTI 참조 등을 포함합니다. root set이 크다는 것은 일반적으로 특정 JDK 버전에 해당 작업의 일부를 동시에 수행할 수 있는 기능이 있고 Shenandoah가 이를 사용할 수 있는 경우를 제외하고는 Shenandoah에서 STW가 길어진다는 것을 의미합니다
2차 효과는
a) 처리가 필요한 참조에서만 동작하는 weak reference 처리(Final Mark Pause에서 발생합니다)
b) class unloading과 기타 JDK 정리(이 역시 Final Mark Pause에서 발생) 등입니다.
이러한 2차 효과는 처리 빈도를 제어하는 추가 옵션을 구성하거나(완전히 비활성화 포함) 애플리케이션이 좀 더 원활하게 재생되도록 수정하여 완화할 수 있습니다.
Throughput
Shenandoah는 barrier를 사용하여 collection cycle간 불변을 유지하는 concurrent GC입니다. 이 barriers은 측정가능한 처리량 손실을 발생시킬 수 있습니다. 아래 diagnostic 섹션을 참고하여 어떤 일이 일어나는지 확인해 보세요. brrier 사용으로 인한 처리량 손실은 Concrruent GC 작업이 idle 혹은 spare core로 자연히 넘어감으로써 보상된다고 몇몇 사용자들은 보고 합니다. 어떤 경우에는 높은 애플리케이션 처리량을 위해 애플리케이션 + JVM의 사용률을 높입니다.
대부분의 경우, Pause 시간은 0~10ms 이내이며 처리량 손실은 0~15% 이내입니다. 실제 성능 수치는 애플리케이션, 부하 profile 등에 크게 의존합니다.
root가 많지 않거나 weak reference, 혹은 class churn 일 경우 pause는 밀리초 미만일 수 있습니다. 애플리케이션의 heap이 크게 변치 않거나 컴파일러에 알맞게 최적화되어있다면, barrier의 overhead는 거의 없을 수 있습니다.
나머지 섹션에서는 Shenandoah를 통해 성능 동작을 테스트하고 진단하는 방법을 설명합니다. 만약 사용 케이스에 구체적으로 의심 가는 것이 있다면 개발자들에게 알려주십시오. 다루기 쉬운 버그이거나 일시적인 버그일 수 있습니다.
Shenandoah는 concurrent GC이므로 수집 주기 동안 불변성을 유지하기 위해 barrier를 사용합니다.
이러한 barrier는 측정 가능한 처리량 손실을 유발할 수 있습니다. (이 문제를 분석하는 방법은 아래 diagnostic 섹션을 참조)
일부 사용자는 barrier로 인한 처리량 손실이 Concrruent GC 작업을 여유 코어나 spare core로 자연스럽게 오프로드함으로써 보상된다고 보고합니다.
즉, 경우에 따라 더 높은 애플리케이션+JVM 사용률을 더 높은 애플리케이션 처리량과 trade-off 하는것입니다.
대부분의 경우 Pause 시간은 0~10ms 이내이며 처리량 손실은 0~15% 이내입니다. 실제 성능 수치는 애플리케이션, 부하 profile 등에 크게 의존합니다.
root가 많지 않거나 weak reference, 혹은 class churn일 경우 pause는 밀리초 미만일 수 있습니다. 애플리케이션의 heap이 크게 변치 않거나 컴파일러에 최적화되어있다면, barrier의 overhead는 거의 없을 수 있습니다.
나머지 섹션에서는 Shenandoah를 통해 성능 동작을 테스트하고 진단하는 방법을 설명합니다. 만약 사용 케이스에 구체적으로 의심 가는 것이 있다면 개발자들에게 알려주십시오. 대부분 다루기 쉬운 버그이거나 일시적인 버그일 수 있습니다.
Basic configuration
Basic configuration and command line options:
- -Xlog:gc (since JDK 9) or -verbose:gc (up to JDK 8) 개별 GC의 시간을 기록합니다.
- -Xlog:gc+ergo (since JDK 9) or -XX:+PrintGCDetails (up to JDK 8) 이상 징후를 밝힐 수 있는 heuristics의 내용을 출력합니다.
- -Xlog:gc+stats (since JDK 9) or -verbose:gc (up to JDK 8) 실행이 끝났을 때 Shenandoah의 내부 타이밍에 대한 요약 테이블을 출력합니다.
언제나 로깅이 가능할 때 사용하는 것이 좋습니다. 이 요약 테이블은 GC의 성능에 관한 중요한 정보를 전달합니다. 그리고 이를 버그 리포트에 사용할 수 있습니다. Heuristics 로깅은 문제점을 파악하는데 유용합니다.
Other recommended JVM options are:
- -XX:+AlwaysPreTouch
Heap 페이지를 메모리에 커밋하면 지연 시간을 줄이는 데에 도움을 줍니다. - -Xms and -Xmx
-Xms = -Xmx 사용하여 Heap의 사이즈를 조절 불가하게 하면 heap 관리의 지연을 줄일 수 있습니다. AlwaysPreTouch와 마찬가지로 시작 시 -Xms = -Xmx는 모든 메모리를 커밋하여 최종적으로 메모리가 사용될 때 지연을 피할 수 있습니다.
-Xms는 낮은 수준의 메모리 커밋에 한도를 결정하므로 -Xms = -Xmx는 모든 메모리가 커밋된 것으로 유지됩니다. 만약, 낮은 수준의 footprint를 원한다면 -Xms 세팅을 낮게 설정하는 것이 좋습니다. commit/uncommit의 오버헤드와 메모리 footprint 사이에서 얼마나 낮은 수준의 -Xms를 설정할지 결정해야 합니다. 대부분의 경우 -Xms를 낮게 설정하는 것이 좋습니다. - 큰 페이지를 사용하는 것은 큰 heap에서 큰 성능 개선을 보여줍니다. 여기 두 가지 설정 방법이 있습니다. -XX:+UseLargePages는 hugetlbfs (Linux)와 Windows (적절한 권한 필요) 지원을 가능케 합니다. -XX:+UseTransparentHugePages는 이를 유용하게 사용할 것입니다. transparent huge pages는 "madvise"를 위해 /sys/kernel/mm/transparent_hugepage/enabled 그리고 /sys/kernel/mm/transparent_hugepage/defrag를 "madvise"로 세팅하길 권장합니다. 이는 AlwaysPreTouch가 실행될 때 단편화에 대한 비용을 미리 지불할 것입니다.
- -XX:+UseNUMA
아직 Shenandoah가 NUMA를 지원하지만, 다중 소켓 호스트에서 NUMA interleaving을 사용하기 위해서는 활성화하는 것이 좋습니다. AlwaysPreTouch와 마찬가지로 제공되는 기본 구성보다 더 나은 성능을 보입니다. - -XX:-UseBiasedLocking
비경쟁의 locking 처리량과 필요에 따라 JVM을 활성화/비활성화 safepoints는 사이에는 trade-off가 존재합니다. 지연 지향 workload의 경우 비경쟁(biased) locking을 해제하는 것이 좋습니다. - -XX:+DisableExplicitGC
사용자 코드에서 강제로 System.gc()를 발동하는 것은 Shenandoah의 추가적인 GC cycle을 수행시킵니다. System.gc()가 악용되는 것으로부터 보호하기 위해 이를 비활성화하는 것이 유리할 수 있습니다. -XX:+ExplicitGCInvokesConcurrent 가 default로 활성화되어, stop-the-world full GC가 아닌 cocurrent GC cycle이 발동되므로 보통은 치명적이지 않습니다.
Modes
- normal/satb (product, default)
이 mode는 Snapshot-At-The-Beginning (SATB) marking을 사용하여 concurrent GC를 실행합니다. 이 mode는 write와 mark를 previouse object를 통해 가로채는데, 이는 G1의 방식과 비슷합니다. - iu (experimental)
이 mode 는 Incremental Update (IU) marking을 사용하여 concurrent GC를 실행합니다. 이 marking mode는 SATB mode를 미러링 하는데 : write, mark 행위를 "new" object를 통해 가로챕니다. 특히 weak references 접근하는 것에 적극적일 수 있습니다. - passive (diagnostic)
이 mode는 stop-the-world GC입니다. 이 mode는 기능 테스트에 사용될 수 있습니다. 때때로 GC barrier로 인한 성능 변화를 분별해내거나 애플리케이션 내에 실제 live 데이터의 양을 확인할 수 있습니다.
Snapshot-At-The-Beginning (SATB)
SATB는 앞에서 살펴보았기에 생략하겠습니다.
Heuristics
- adaptive (default)
이 heuristics는 이전 GC cycle을 관측하고 다음 GC를 시작하여 Heap이 소진되기 전에 완료될 수 있도록 합니다. - static (previously and ironically known as dynamic)
이 heuristics는 heap 점유율에 따라 GC cycle을 시작합니다. 다음 튜닝 값들은 heuristics에 유용하게 작용합니다 : - compact (previously erroneously known as continuous)
이 heuristics는 영역 할당이 일어나는 이상, 이전 GC cycle이 끝나자마자 계속해서 실행합니다. 이 heuristics는 보통 처리량 부하를 일으키지만, 즉각적으로 공간 확보를 제공할 수 있습니다. - aggressive (diagnostic)
이 heuristics는 GC가 완전히 활성화 됨을 이야기 합니다. compact와 같이 이전 GC가 끝나는대로 새로운 GC cycle을 시작합니다. (이는 모든 live object를 evacuation)
이 heuristics는 GC 자체의 기능 테스트에 유용하지만 꽤 많은 성능 패널티를 일으킵니다.
Failure Modes
Shenandoah와 같은 Cocurrent GC는 암묵적으로 애플리케이션 할당보다 빠른 수집에 의존합니다. 할당 pressure가 높고 GC가 실행되는 동안 할당량을 수행할 공간이 충분하지 않으면 결국 할당 실패가 발생할 수 있습니다. Shenandoah에는 이와 같은 경우를 극복하는 데 도움이 되는 몇가지 아래와 같은 스텝이 있습니다.
- Pacing (-XX:+ShenandoahPacing, enabled by default)
GC가 실행 중일 때는 얼마나 많은 GC 작업이 필요한지, 애플리케이션에 사용할 수 있는 여유 공간이 얼마나 되는지 파악합니다. GC 진행 속도가 충분히 빠르지 않으면 Pacer는 스레드 할당을 중단하려고 시도합니다. 정상적인 조건에서는 GC가 애플리케이션이 할당하는 것보다 빠르게 수집되므로 페이싱은 자연스럽게 멈추지 않습니다. 페이싱은 일반적인 프로파일링 툴에서는 볼 수 없는 로컬 스레드당 지연 시간을 발생시킨다는 점에 유의하세요. 이것이 바로 지연이 무한정 지속되지 않는 이유이며,
지연은 -XX:ShenandoahPacingMaxDelay=#ms로 제한됩니다. 최대 지연이 만료되면 어쨌든 할당이 이루어집니다.
대부분의 경우 가벼운 할당 급증은 페이서에 의해 흡수됩니다. 할당 압력이 매우 높으면 페이서가 감당할 수 없어 성능 저하가 다음 단계로 넘어갑니다.
일반적인 지연 시간: <10ms - Degenerated GC (-XX:+ShenandoahDegeneratedGC, enabled by default) :
애플리케이션에 할당 실패가 발생하면 Shenandoah는 세계 일시 중지로 들어가 전체 애플리케이션을 중지하고 일시 중지 상태에서 사이클을 계속합니다. 저하된 GC는 진행 중인 concurrent cycl을 중지 상태에서 계속 진행합니다. 대부분의 경우 할당 실패는 이미 대부분의 GC 작업이 완료된 후 발생합니다. 그렇기 때문에 STW pause가 일반적으로 크지 않습니다. 이는 GC 로그, 일반적인 모니터링 및 하트비트 스레드에서 GC pause로 보고됩니다. 실제로 STW pause를 유도하는 이유 중 하나는 동시 모드 실패를 명확하게 관찰할 수 있도록 하기 위해서입니다. GC 주기가 너무 늦게 시작되었거나 할당 스파이크가 매우 크게 발생한 경우 Degenerated GC가 발생할 수 있습니다.
Degenerated 주기는 리소스를 놓고 애플리케이션과 경합하지 않으며 스레드 풀 크기 조정에 -XX:ConcGCThreads가 아닌 -XX:ParallelGCThreads를 사용하기 때문에 동시 주기보다 빠를 수 있습니다.
일반적인 지연 시간: <100ms, 하지만 Degenerated 지점에 따라 더 길어질 수 있습니다. - Full GC
예를 들어, Degenerated GC가 충분한 메모리를 확보하지 못한 경우, 풀 GC 사이클이 발생하여 힙을 최대로 압축합니다.
비정상적으로 조각난 힙과 구현 성능 버그 및 간과와 같은 특정 시나리오는 전체 GC로만 해결될 수 있습니다.
이 최후의 수단인 GC는 사용 가능한 메모리가 일부라도 있는 경우 애플리케이션이 OOM으로 인해 실패하지 않도록 보장합니다.
일반적인 지연 시간 발생: >100ms 이상이지만, 특히 사용량이 많은 힙에서는 더 길어질 수 있습니다.
마치며
Shenandoah GC와 ZGC도 여타 다른 GC와 마찬가지로 단점이 존재합니다.
Concurrent GC를 수행하기 위해 더 많은 CPU 리소스를 사용(Brooks Forwarding Pointer 등)하기에 CPU 리소스 제한이 있는 환경에서는 부적합할 수 있고, throughput이 낮아질 수 있습니다.
다만, STW pause time에 민감하고 CPU 오버헤드를 감당할 수 있는 환경이라면 일관되고 뛰어난 성능을 보장하기에 아주 매력적인 선택지일 것입니다.
참고자료
- https://developers.redhat.com/blog/2020/03/09/shenandoah-gc-in-jdk-14-part-2-concurrent-roots-and-class-unloading#concurrent_roots_processing
- https://developers.redhat.com/articles/2021/09/16/shenandoah-openjdk-17-sub-millisecond-gc-pauses
- https://2018.javazone.no/program/28f62968-0713-4bda-8a37-8b38c9f280ee
- https://blogs.oracle.com/javamagazine/post/understanding-the-jdks-new-superfast-garbage-collectors
- https://openjdk.org/jeps/404
- https://openjdk.org/jeps/439
- https://assets.ctfassets.net/oxjq45e8ilak/709UsobBpBGHxaZ0z6MNvH/1d75677b26f1b7c9a71150c372645ad8/100746_367617808_Simone_Bordet_Concurrent_Garbage_collectors_ZGC__Shenandoah.pdf
- https://jaxlondon.com/wp-content/uploads/2019/11/OpenJDK_-_in_the_new_Age_of_concurrent_Garbage_Collectors.pdf
- https://d2.naver.com/helloworld/1329
- https://www.youtube.com/watch?v=E1M3hNlhQCg
- https://www.youtube.com/watch?v=3ipzHViqzRs
- https://www.youtube.com/watch?v=o4Qf-uMrDBI
혹시 틀린 부분이 있다면, 아래 연락처로 언제든 편하게 지적해주세요!
이메일 ian.ilminmoon@gmail.com
'Presentation' 카테고리의 다른 글
Inside CockroachDB (0) | 2021.08.19 |
---|---|
Inside Etcd (0) | 2021.08.19 |