이 글은 원 저자이자 팀 동료인 Per Liden의 동의하에 한국어 번역을 했습니다. 원문은 여기 입니다.
JDK 16 이 출시됐습니다. 새로운 릴리스마다 새로운 기능, 개선 사항 및 버그 수정이 함께 제공되는데, ZGC 는 46 개의 개선 사항과 25 개의 버그 수정을 했습니다. 이번 글에서는 몇 가지 흥미로운 개선 사항을 다룰 것입니다.
밀리 초 미만의 최대 pause 시간
(일명, concurret 스레드 스택 처리)
ZGC 프로젝트를 시작했을 때 우리의 목표는 GC pause 시간(역자주: GC가 메모리를 정리할 때 멈추는 시간, pause 시간)이 10ms 이상 걸리지 않도록 하는 것이었습니다. 당시 10ms는 야심 찬 목표처럼 보였습니다. HotSpot의 다른 GC는 일반적으로 특히 큰 힙을 사용할 때 최대 pause 시간이 그 목표보다 몇 배 더 길었습니다. 이 목표에 도달하는 것은 재배치, 참조 처리 및 클래스 언로딩과 같은 시간이 많이 걸리는 작업을 Stop-The-World 단계가 아닌 concurrent(역자주: 자바 어플리케이션과 GC를 수행하는 스레드가 동시에 실행된다는 의미) 단계에서 모두 하도록 하는 것이 관건이었습니다. 당시 HotSpot은 이러한 작업들을 concurret하게 수행하는 데 필요한 인프라가 많지 않았기 때문에, 이를 달성하는 데 몇 년이 걸렸습니다.
초기 목표인 10ms 도달 한 후 우리는 더 야심 찬 목표를 다시 정했습니다. 그것은 바로, GC pause는 1ms 보다 길어서는 안된다는 것입니다. JDK 16에 그 목표를 이뤘다고 알리게 되어 기쁩니다. 이제 ZGC에는 O(1) 의 pause만 있습니다. 즉, 자바 힙 크기, 라이브 셋 또는 루트 셋 크기 (또는 관련있는 그 어떤 것에도) 등이 늘어난다 해도 영향 받지 않고 일정한 시간으로 실행하게 됩니다. 물론, 우리는 여전히 GC 스레드 CPU 시간을 제공하는 운영 체제 스케줄러에 영향을 받습니다. 그러나 시스템이 과도하게 provisioning 되지 않는 한 평균 GC pause 시간은 약 0.05ms (50µs)이고 최대 pause 시간은 약 0.5ms (500µs)입니다.
그러면 지금에 이르기까지 어떤 변화가 있었는지 살펴 볼까요? JDK 16 이전까지는 ZGC pause 시간은 (일부) 루트 셋의 크기에 영향을 받았습니다. 좀 더 정확히 말하자면, Stop-The-World 일때 스레드의 스택을 스캔(검사)했습니다. Pause 시간은 이 스레드들이 복잡한 콜 스택을 가질 경우 더 많이 늘어날 수 있습니다. JDK 16 부터는 스레드 스택을 concurret하게 스캔합니다. 즉, 자바 어플리케이션도 계속 실행됩니다.
예상할 수 있듯이, 스레드가 실행중일 때, 스레드 스택을 둘러 보려면 약간의 마법이 필요합니다. 그것은 바로 Stack Watermark Barrier 라는 것입니다. 간단히 말해서, 이것은 자바 스레드에서 그렇게 해도 괜찮은지를 확인하지 않고 스택 프레임 반환하는 문제를 방지 해주는 기법입니다. 이것은 메소드 반환시 세이프-포인트 확인을 하는 이미 존재하는 저렴한 검사에 포함됩니다. 개념적으로 보면 스택 프레임에 대한 로드 베리어라고 생각할 수 있으며, 필요 여부에 따라 자바 스레드가 스택 프레임 반환 전에 안전한 상태로 변환되도록 하는 일종의 조치입니다. 각각의 자바 스레드는 하나 혹은 이상의 스택 워터마크를 가지고 있습니다. 워터마크는 특별한 조치 없이 얼마만큼의 스택을 안전하게 검사할 수 있는 지를 알려줍니다. 워터마크, 그 이상을 검사하려면, 느린 경로를 사용하여, 하나 이상의 프레임들을 현재의 안전한 상태로 만들고, 워터마크는 갱신됩니다. 모든 스레드 스택을 안전한 상태로 가져오기 위해서는 일반적으로 하나 이상의 GC 스레드에 의해 처리 됩니다. 또한 concurret하게 진행되기 때문에, 자바 스레드들이 돌아갈 프레임이 아직 GC 스레드들이 처리하기 전이라면, 가끔은 자신의 프레임을 고쳐야 할때도 있습니다. 만약 좀 더 자세한 사항이 궁금하다면, JEP 376: ZGC: Concurrent Thread-Stack Processing를 참고 하기 바랍니다.
JEP 376을 포함한 후에 ZGC는 Stop-The-World 단계에서 정확히 0개의 루트를 스캔합니다. JDK 16 이전에도 이미 많은 워크로드에서 상당히 낮은 최대 pause 시간을 보였습니다. 하지만, 대규모 시스템에서, 많은 숫자의 스레드들을 사용한다면 최대 pause 시간이 1ms를 넘는 것을 볼 수 있었습니다. 향상된 부분을 표현하기 위해 여기에 2천개의 자바 스레드가 있는 대규모 시스템에서 SPECjbb®2015를 실행하는 JDK 15와 JDK 16를 비교한 예가 있습니다.
내부 재배치
JDK 16은 내부 재배치를 지원합니다. 이 기능은 GC가 가비지를 모을때 이미 힙이 가득 차 있는 상태에서 발생하는 OutOfMemoryError를 방지하는데 도움을 줍니다. 일반적으로, ZGC는 덜 사용된 힙 영역에 있는 객체들을, 조밀하게 압축할 수 있는 하나 이상의 빈 힙 영역으로 옮기고, 이런 압축 동작을 통해 메모리를 확보합니다. 이 전략은 간단하며 병렬 처리에 적합 하지만 한가지 단점이 있습니다. 적어도 약간의 메모리 (각 크기 별로 적어도 하나의 빈 힙 영역)가 있어야 재배치 작업을 시작할 수 있다는 것입니다. 만약 힙이 꽉 차 있다면, 즉, 모든 힙 영역이 이미 사용중이라면, 객체들을 옮길 곳이 없게 됩니다.
JDK 16 이전의 ZGC는 힙 예약을 통해 이 문제를 해결 했습니다. 이 예약된 힙은 자바 스레드들의 일반적인 메모리 할당에는 사용되지 않도록 별도로 떼어 놓은 것입니다. 오직GC만이 재배치 시에 이 예약된 힙을 사용 하도록 되어 있습니다. 이것은 자바 스레드 관점에서는 힙이 꽉 차있지만, 재배치를 위한 힙은 항상 있도록 보장 해줍니다. 이 예약된 힙은 일반적으로 힙의 작은 부분이었습니다. 기존 블로그 글에서, 작은 힙을 더 잘 지원하도록 JDK 14에서 어떤 향상을 했는지에 대해 썼습니다.
여전히, 예약된 힙 사용은 몇가지 문제점들을 가지고 있습니다. 예를 들면, 자바 스레드들이 재배치를 수행시 이 예약된 힙을 사용할 수 없으므로, 재배치 작업이 무조건 완료된다는 보장이 없었습니다. 그러므로 GC는 충분한 메모리를 확보할 수 없습니다. 이 문제(결과적으로 이른 OutOfMemoryError를 야기할 수 있는)는 일반적인 워크로드에서는 문제가 되지 않지만, 테스트 중 이 문제를 프로그래밍적으로 발생 시킬수 있다는 것을 알게 됐습니다. 또한 비록 작은 메모리이고 재배치시 필요한 것이라도, 대부분의 워크로드에게는 메모리 낭비였습니다.
연속된 메모리 덩어리를 확보하는 또 다른 방법은 내부에서 확보하는 것입니다. Hotspot의 다른 컬랙터들 (G1, Parallel, Serial)은 소위 Full GC라고 불리는 것을 할때 유사한 방법을 씁니다. 이 방법의 장점은 메모리 확보를 위한 메모리가 필요하지 않다는 것입니다. 즉, 일종의 예약된 힙 없이 전체 힙을 충분히 압축할 수 있게 합니다.
그러나, 내부 힙 압축도 몇가지 문제가 있으며, 일반적으로 오버헤드가 야기됩니다. 예를 들면, 아직 움직이지 않은 객체를 덮어쓸 수 있기 때문에 객체를 옮기는 순서가 중요해집니다. 이것은 GC 스레드들 간에 협조를 더 요구하게 되며 병렬처리에 적합하지 않게 합니다. 또한 자바 스레드가 GC 스레드 대신 재배치시 할 수 있는 작업과 할 수 없는 작업에도 영향을 줍니다.
요약하자면, 각각의 장점이 있습니다. 비어 있는 힙 영역이 있을 때는 내부 재배치를 하지 않을 경우 일반적으로 나은 성능을 보입니다. 반면에 비어 있는 힙 영역이 없을 때는 내부 재배치 방법이 재배치의 성공을 보장합니다.
JDK 16부터, ZGC는 이제 두 가지 방법 모두를 최대한 사용 합니다. 이 전략은 더 이상 힙 예약을 하지 않아도 되게 해주며, 일반적인 상황에서는 더 나은 성능을 보이며, 최악의 경우에도 재배치의 성공을 보장합니다. 기본적으로 ZGC는 객체를 옮길 빈 힙 영역이 있다면, 내부 재배치를 하지 않습니다. 그렇지 않을 경우, ZGC는 내부에서 재배치를 합니다. 빈 힙 영역이 생기면, ZGC는 다시 내부 재배치를 하지 않도록 변경 합니다.
이 두 가지 재배치 모드의 전환은 원활하게 이루어지며, 필요 하다면, 1개의 GC 사이클에서도 여러 번 수행됩니다. 그러나 대부분의 워크로드는 처음부터 이러한 전환을 일으키지 않습니다. 그러나 ZGC는 이런 상황을 잘 대처하기에, 힙 압축 실패 때문에 야기될 조기 OutOfMemoryError가 절대 발생되지 않을 것이라는 것은, 사용자를 안심 시키기에 충분합니다.
ZGC의 로깅도 각 사이즈 그룹(Small/Medium/Large)에서 얼마 만큼의 힙 영역들(ZPages)이 내부 재배치가 되었는지 보여 주도록 변경되었습니다. 아래의 예는 54MB 상당의 작은 객체들이 재배치 될때, 3개의 작은 페이지들은 내부에서 재배치되었음을 보여줍니다.
포워딩 테이블의 할당 및 초기화
ZGC가 객체를 재배치하면, 그 객체의 새주소가 포워딩 테이블(자바 힙 외부에 생성되는 데이터 구조)에 기록됩니다. 재배치 세트(메모리 확보를 위해 압축 대상이 된 힙 영역들의 모음)에 선택된 각각의 힙 영역들은 포워딩 테이블을 갖게 됩니다.
JDK 16 이전에는 재배치 세트가 매우 클 경우, 포워딩 테이블의 할당과 초기화시 걸리는 시간이 전반적인 GC 사이클에서 큰 부분을 차지했습니다. 재배치 세트의 크기는 재배치 도중 옮긴 객체의 수와 관련 있습니다. 예를 들어 > 100GB의 힙에서 워크로드가 상당한 조각화를 힙 전반에 걸쳐 골고루 발생 시켰다면, 재배치 세트는 클 것이고 할당 및 초기화에 상당한 시간이 걸릴 것입니다. 물론, 이 작업은 항상 concurret하게 일어 났으므로 GC pause 시간에는 절대 영향을 주지 않았습니다만, 여전히 개선의 여지는 있었습니다.
JDK 16에서 ZGC는 이제 포워딩 테이블을 대량으로 할당 합니다. 각각의 테이블 할당을 위해서 여러번 malloc/new를 호출하는 대신, 단 한번의 호출로 모든 테이블에 필요한 메모리를 확보합니다. 이것은 할당 시의 부담과 잠재적인 록 경합 (lock contention)을 피하고 이 테이블들을 할당하는데 걸리는 시간을 크게 줄여주는데 도움이 됩니다.
이 테이블들을 초기화 하는것은 또다른 병목지점이었습니다. 해쉬 테이블인 포워딩 테이블을 초기화 한다는 것은 작은 헤더와 포워딩 테이블 엔트리의 (잠재적으로 큰) 배열을 제로화 하는 것을 의미합니다. JDK 16부터는 단일 스레드가 아닌 여러 개의 스레드들을 이용해 병렬로 처리합니다.
요약하자면, 이러한 변경 사항들은 특히 매우 커다른 힙에서 띄엄띄엄 힙을 사용된 경우, 포워딩 테이블을 할당하고 초기화 하는 시간을 크게 줄였습니다.
요약
-
Concurret 스레드-스택 검사로, ZGC는 이제 평균 ~50µs, 최대 ~500µs의 마이크로 세컨드 단위의 pause 시간에 진입했습니다. Pause 시간은 힙, 라이브-셋, 루트-셋의 크기에 영향을 받지 않습니다.
-
힙 예약은 이제 사라졌고, 필요하다면 내부 재배치가 일어나게 됩니다. 이로 인해 메모리 사용은 줄게 되었고, 모든 상황에서 성공적으로 힙을 압축하는 것을 보장 합니다.
-
포워딩 테이블은 보다 효율적으로 할당 및 초기화 하게 됩니다. 이로 인해 특히 큰 힙이 띄엄띄엄 사용된 경우, GC 사이클을 완료하는데 시간을 줄여 줍니다.
보다 자세한 사항은 OpenJDK Wiki나 Inside Java의 GC 섹션 혹은 원글의 블로그를 참조해 주세요.