JVM 시리즈 - 신세대 가비지 컬렉터의 종류
신세대 가비지 컬렉터 정리
신세대 가비지 컬렉터라 할 수 있는 ZGC 와 셰넌도어에 대해서 정리해보겠다. 가비지 컬렉터를 측정하는 가장 중요한 지표는 3가지이다.
- 지연 시간
- 처리량
- 메모리 사용량
이 3가지를 모두 충족하는 완벽한 컬렉터는 없으며, 일반적으로 좋은 컬렉터는 3가지 중 2가지의 기준을 충족할 것이다. 요즘에는 이 3가지 중 지연 시간의 중요성이 점점 커지고 있다. 하드웨어의 성능 향상으로 처리량과 메모리 사용량은 감당할 수 있기 때문이다.
셰년도어
레드햇이 독립적으로 시작한 프로젝트로, OpenJDK 에 기증됐다. 가비지 컬렉션으로 인한 일시 정지를 10 밀리초 이내로 묶는 것이 셰넌도어의 목표다.
개선 사항
G1 과 유사하게 작동한다. 힙을 리전들로 쪼개 처리하고, 큰 객처 전용의 거대 리전을 지원한다. 셰넌도어의 동작 방식은 다음과 같다.
- 최초 표시 : GC 루트에서 직접 참조하는 객체를 표시한다.
- 동시 표시 : 객체 그래프를 타고 힙을 탐색하며 도달 가능한 모든 객체를 표시한다.
- 최종 표시 : 보류 중인 모든 표시를 완료하고, GC 루트 집합을 다시 스캔한다.
- 동시 청소 : 살아 있는 객체가 하나도 없는 리전을 청소한다.
- 동시 이주 : (핵심) 사용자 스레드를 멈추지 않고 회수 집합 안에 살아 있는 객체를 다른 빈 리전으로 복사한다. 이동 후, 참조 주소를 수정하기 위해서 읽기 장벽과 포워딩 포인터를 사용한다.
- 최초 참조 갱신 : 힙에서 옛 객체를 가리키는 모든 참조를 새로운 주소로 수정한다. stop the world 가 발생한다.
- 동시 참조 갱신 : 참조 갱신을 실제로 시작한다.
- 최종 참조 갱신 : 힙의 참조를 모두 갱신한 후, GC 루트 집합의 참조를 갱신한다. stop the world 가 발생한다.
- 동시 청소 : 회수 집합의 모든 리전에는 살아 있는 객체가 없기에 다시 청소를 수행한다.
동시 이주 - 포워딩 포인터
개념을 소개한 사람의 이름을 따 브룩스 포인터라고도 한다. 기존 방법은 사용자가 옛 객체가 저장된 메모리에 접근하는 순간, 해당 부분에 설치한 트랩이 발생하여 예외 처리기를 실행시키고, 처리기에서 새로운 객체를 사용하게 하는 것이다. 이 방법은 사용자 모드와 커널 모드를 전환해야 하기에 비용이 크다.
포워딩 포인터 방식은, 객체 레이아웃 구조 상단에 참조 필드를 하나 추가하는 방식이다. 이주가 아닌 경우에는 객체 자신을 가리키고, 이주인 경우에는 새로운 객체를 가리키게 하는 것이다. 포인터만큼 오버헤드가 모든 객체에 더해지지만, 방식이 간단하다.
포인터를 새로 갱신하는 경우, 스레드간 경쟁이 일어날 수 있다. 따라서 포워딩 포인터에 대한 스레드 접근은 동기적이어야 한다. 이는 낙관락 방식을 사용하여 해결했다.
JDK 13 에서는 포워딩 포인터를 객체 헤더에 아에 통합했다. 객체 헤더의 마지막 2비트를 락 플래그로 사용하는 방법이다. 이로 인해서 셰넌도어는 다른 가비지 컬렉터보다 메모리를 5-10% 더 사용하지만, 가지비 컬렉션의 성능 자체는 10-15% 높은 성능을 보인다.
실전 성능
출처: lonut Balosin
일시 정지 시간에 있어서는 다른 GC 보다 우위를 보인다.
ZGC
ZGC 는 오라클이 개발한 저지연 가비지 컬렉터다. ZGC 의 목표는 처리량에 미치는 영향을 최소화하면서, 일시 정지 시간을 10 밀리초 안쪽으로 줄이는 것인데, 이는 셰넌도어와 유사하다.
ZGC 의 주요 특성을 요약하자면 다음과 같다.
세대 구분 없는 리전 기반 메모리 레이아웃을 사용한다. 낮은 지연 시간을 위해 동시 마크-컴팩트 알고리즘을 구현한다. 이를 위해서 읽기 장벽, 컬러 포인터, 메모리 다중 매핑 기술을 이용한다.
리전 기반 메모리 레이아웃
ZGC 의 리전은 동적으로 생성/파괴되며, 크기도 동적으로 달라진다. 소(2mb), 중(32mb), 대(N * 2mb 의 동적 크기)의 크기를 가진 리전들로 구성된다. 대리전의 객체는 재할당되지 않는다. 이는 큰 객체의 복사 비용을 줄이기 위함이다.
병렬 모으기와 컬러 포인터
추가 데이터를 객체에 저장하기 위해서 다른 컬렉터는 객체 헤더에 필드를 추가했다. 그런데 객체가 이동하다보면 이 정보가 정확하지 않을 수도 있고, 이 정보 자체에 접근하지 못할 수도 있다. ZGC 의 컬러 포인터는 객체를 가리키는 포인터에 객체의 도달 가능성을 표시한다. 이 정보를 통해서, 객체에 대한 회수 여부 및 상태를 확인할 수 있다.
이 방법의 이점은 3가지가 있다.
- 한 리전 안의 생존 객체들이 이동하면, 힙에서 해당 리전에 대한 참조의 수정을 기다릴 필요 없이 즉시 재활용할 수 있다.
- 가지비 컬렉션 과정에서 메모리 장벽의 수를 줄일 수 있다. 객체 참조를 변경하는 데, 쓰레드의 간섭을 방지하기 위해서 메모리 장벽을 활용하는데, 이를 사용하지 않고 포인터 자체를 확장해서 사용하기 때문이다.
- 컬러 포인터를 객체 표시 및 재배치와 관련해 더 많은 정보를 담을 수 있는 확장 가능한 구조로 사용할 수 있다.
컬러 포인터를 사용하는 점에는 분명 이점이 있지만, 자바 가상 머신의 메모리 포인터를 임의로 확장한 구조이다. 프로세서는 포인터에서 어느 부분이 플래그 비트이고, 진짜 주소인지 모를 수 있다. 이를 해결하기 위해서 가상 메모리 매핑 기술을 사용한다. 즉, 논리적인 주소 하나가 물리적 위치와 일대 다 관계로 매핑이 되는 것이다. 포인터의 컬러가 변하면, 그에 따라서 주소 공간에서 다른 주소를 제공하는데, 이 모든 주소가 실제로는 힙 메모리에 있는 동일한 객체를 가리키고 있는 것이다. 따라서, 색상이 변하고, 포인터 주소가 변하더라도 같은 객체를 참조할 수 있는 것이다.
ZGC 의 동작 방식
- 동시 표시 : 객체 그래프를 탐색하여 도달 가능성을 분석한다. 짧은 일시 정지가 발생하며, 표시가 포인터에 이루어진다.
1
2
3
4
5
6
7
8
9
+-----------------+
| 힙 메모리 |
+-----------------+
| Object A | Gray |
| Object B | White|
| Object C | White|
+-----------------+
- 동시 재배치 준비 : 청소해야 할 리전을 선정하여 재배치 집합을 만든다. ZGC 는 가비지 컬렉션 때마다 모든 리전을 스캔하는데, 생존 객체를 다른 리전으로 복사한 후, 리전 전체를 회수할지 결정한다.
- 동시 재배치 : 생존 객체들을 새로운 리전으로 복사한다. 사용자 스레드가 재배치 집합에 포함된 객체에 접근하면, 참조 단계에서 미리 설정해둔 메모리 장벽에 따라서 새로운 객체로 포워드된다. 그와 동시에 해당 참조의 값도 새로운 객체를 가리키도록 갱신된다.
- 동시 재매핑 : 재매핑이란 힙 전체에서 재배치 집합에 있는 옛 객체들을 향하는 참조 전부를 갱신한다. 이는 어차피 낡은 참조에 대해 스레드가 접근하는 순간에, 해당 참조가 업데이트 되기 때문에 재매핑 자체가 우선 순위가 높은 작업은 아니다. zgc 는 이 단계를 다음 가비지 컬렉션의 동시 표시 단계와 통합했다. 어차피 객체를 모두 탑색해야 하기 때문에, 객체 그래프 탐색 부하를 줄인 셈이다
다른 컬렉터와 비교
ZGC 는 다른 컬렉터와 다르게 기억 집합을 사용하지 않으며, 세대를 구분하는 카드 테이블도 없다. 따라서 사용자 애플리케이션에 주는 부담이 매우 적다. 그러나 객체 할당 속도를 제한한다는 단점이 있다. 객체의 회수가 진행되는 속도보다 생성되는 속도가 빠르다면 결국에 메모리 공간은 부유 쓰레기로 가득찰 것이다. 세대 구분을 사용하는 컬렉터는 새로 생성되는 객체에 대해서는 별도 처리를 하기 때문에 이 문제를 방지할 수 있다. 어줄의 C4 컬렉터를 예로 들면, 세대 단위 컬렉션을 지원하지 않는 PGC 컬렉터보다 객체 할당 속도가 10배나 빠르다.
그럼에도 불구하고 ZGC 컬렉터는 개선을 거듭해서 처리량 점수에서 ZGC 컬렉터는 좋은 성적을 거뒀다.
처리량 극대화가 목표인 패러렐의 성능을 ZGC 가 넘어서는 것을 볼 수 있다. 참고 : https://kstefanj.github.io/2021/11/24/gc-progress-8-17.html
세대 구분 ZGC
세대 구분 ZGC 는 ZGC 를 확장하여 구세대와 신세대 객체를 구분하고, 수명이 짧은 객체들을 더 자주 회수할 수 있게 구현됐다. 이는 JDK 21 에 정식으로 추가됐다.
1
2
3
java --XX:UseZGC -XX:+ZGenerational
커맨드를 사용하면 세대구분 ZGC 를 사용할 수 있다. 세대구분 ZGC 는 자원 사용량이 적고, 처리량이 높아졌다. NoSQL 인 카산드라 벤치마크에서는 ZGC 의 4 배에 달하는 처리량을 보여줬다.