ZGC, Shenandoal, G1의 향상으로 개발자들은 그 어느 때보다 중단 없는 Java에 더 가까워졌다
by. Raoul-Gabriel Urma and Richard Warburton
November 21, 2019
the Original Article (published in December 2019, JVM INTERNALS)
지난 6개월간 일어났던 일 중 가장 신나는 발전은 JDK의 가비지 컬렉터와 관련된 것이었다. 이 아티클은 JDK 12에 처음 등장했고 JDK13에서 이어지고 있는 여러 범위의 개선사항을 담고 있다. 먼저 우리는 애플리케이션과 거의 동시에 돌아가는 low-latency GC인 Shenandoah에 대해 알아볼 것이다. 또한 JDK12에 포함되어 릴리즈된 ZGC (Java11에서 소개된 low-latency concurrent GC)의 최신 개선사항에 대해서도 다룰 것이다. 마지막으로 Java 9부터 디폴트 GC인 Garbage First (G1) GC의 두가지 개선사항을 상세하게 설명하려고 한다.
GC 개요
더 오래된 C나 C++같은 언어와 비교했을 때 Java의 가장 큰 강점은 가비지 컬렉션을 사용하는 것이다. 자바 개발자인 여러분이 메모리 영역을 명시적으로 free하지 않는다면 여러분 대부분은 메모리 누수를 걱정하거나, 해당 영역을 사용하기 전에 free를 해서 발생하는 애플리케이션 crash를 걱정할 필요가 없을 것이다. 가비지컬렉션은 생산성 측면에서의 아주 큰 강점이지만 시간이 지나면서 개발자들은 가비지컬렉션의 성능에 대해 우려해왔다. 가비지컬렉션이 애플리케이션을 느리게 하지는 않을까? 애플리케이션 중단을 유발해서 사용자들의 경험을 저하시키지는 않을까?
많은 가비지 컬렉션 알고리즘들은 여러 해를 걸쳐 시도되고 테스트되면서 점진적인 성능 개선을 이루어 왔다. 이 알고리즘들에는 두가지 공통적인 성능상의 영역이 있다. 첫번째는 가비지 컬렉션 throughput으로, 애플리케이션의 CPU 시간 중 애플리케이션 코드를 동작시키는 데 걸리는 시간에 비해 가비지 컬렉션을 수행하는 시간이 얼마나 드는지를 나타내는 지표이다. 즉 CPU 사용 시간 중 GC에 드는 시간으로, 적을수록 GC에 할당되는 CPU 시간이 적다고 볼 수 있다. 두번째는 중단이 1회 발생했을 때의 지연 시간의 길이이다.
중단을 포함하는 많은 GC들을 사용할 때, 애플리케이션의 힙(Heap) 사이즈를 늘리면 throughput을 개선(애플리케이션 동작 시간 대비 가비지 컬렉션에 드는 시간)할 수 있지만 한번 중단될 때의 중단 지연 시간이 더 길어진다. 즉, 힙 사이즈가 커질수록 가비지 컬렉션 자체는 적게 발생해서 컬렉션 정리를 효율적으로 할 수 있지만, 하나의 가비지 컬렉션 사이클에서 할 일이 많아지기 때문에 개별 중단 시간이 길어지게 된다. 힙 사이즈로 Parallel GC를 사용하는 경우를 예로 들어 보면, old generation에 할당된 객체를 수집하는 시간은 generation의 사이즈에 비례하게 된다. 그런데 generation의 크기는 힙 사이즈에 비례하게 되기 때문에 매우 긴 중단이 발생할 수 있다. 그러나 만약 여러분이 interactive하지 않은 batch job을 실행한면 Parallel GC는 효율적인 GC가 될 수 있을 것이다.
Java 9부터 G1 컬렉터는 OpenJDK, Oracle JDK 둘 모두에서 디폴트 GC가 되었다. G1의 가비지 컬렉션에 대한 전반적인 접근 방식은 사용자가 제공한 시간 목표에 따라 GC 중단 시간을 분할하는 것이다. 이는 만약 여러분이 더 짧은 중단 시간을 원한다면 목표를 짧게 잡고, GC에 의한 CPU 사용을 줄이고 애플리케이션에 의한 CPU 사용을 늘리고 싶다면 더 긴 목표를 설정하면 된다. Parallel GC는 throughput 지향적인 컬렉터인 반면, G1은 모든 것을 다 갖춘 수집기라고 볼 수 있다. 적은 throughput을 제공하면서도 중단시간도 짧기 때문이다.
그러나 G1이 중단 시간 측면에서의 마스터 키는 아니다. 매우 큰 heap 사이즈나 빠른 시간 내에 많은 객체들을 할당할 경우 가비지 컬렉션 사이클동안 정리가 필요한 작업량이 증가하면서 시분할 방식의 접근법은 한계에 부딪치게 되기 때문이다. 비유하자면, 커다란 피자를 작은 조각으로 만들면 한입에 먹기는 쉬워지지만 피자가 너무 크거나(heap 사이즈가 너무 큰 경우), 다 먹지 않았는데도 새 피자가 자꾸 나온다면(빠른 시간 내에 많은 객체를 할당할 경우) 다 먹는데 시간이 한참 걸리게 되는 것이다. 가비지 컬렉션도 마찬가지이다.
이 문제점이 바로 JDK 12의 Shenandoah GC가 해결하고자 한 것이다. (Shenandoah GC는 지연 해결 전문가이다) Shenandoah GC는 힙 사이즈가 크더라도 지속적으로 짧은 지연 시간을 달성한다. 이 과정에서 Parallel GC에 비해 CPU 시간을 조금 더 소비할 수는 있지만, 지연 시간은 엄청나게 줄어들게 된다. 이는 금융, 도박, 광고업계, 또는 중단에 예민한 사용자를 보유한 웹사이트의 low-latency 시스템에 안성맞춤이다.
이 아티클에서 우리는 GC의 최신 버전들에 대해 설명하고 G1의 최신 업데이트에 대해 설명할 것이다. 이 아티클이 여러분의 애플리케이션에서 기능적으로 균형을 맞추기 위한 가이드로서 도움이 되면 좋겠다.
Shenandoah
Shenandoah는 JDK 12에 포함된 새로운 GC이다. Shenandoah는 개선사항을 JDK 8u와 11u 릴리즈에서도 사용할 수 있게 백포트(backport)하기 때문에 JDK 12로 업그레이드하지 못하는 상황이라도 괜찮다.
어떤 경우에 Shenandoah로 바꿀지, 왜 바꿔야 할 지에 대해 알아보자. 우리는 이 아티클에서 Shenandoah가 어떻게 동작하는지에 대해 상세하게 다루지는 않을 것이다. 하지만 만약 여러분이 이 기술에 관심이 있다면 관련 아티클(역주: 원본은 2016년 1월 아티클이지만 현재 제공되지 않고 있으며, 2019년 11월에 해당 아티클을 업데이트 및 요약한 아티클이 있어서 해당 페이지로 링크를 추가함)과 OpenJDK 위키의 Shenandoah 페이지를 참고하기를 바란다.
G1에 비해 Shenendoah가 핵심적으로 향상된 것은 가비지 컬렉션 사이클이 애플리케이션 스레드와 동시에(역주: concurrently를 동시로 해석하였으며, 해당 아티클에서 사용되는 동시라는 단어는 애플리케이션 스레드를 중단시키지 않고 동시에 동작한다는 것을 의미함) 동작한다는 것이다. G1은 애플리케이션이 중단된 경우에만 힙 영역, 즉 객체를 이동할 수 있는 반면, Shenandoah는 애플리케이션 실행을 중단시키지 않으면서 객체를 이전(relocation)할 수 있다. 이와 같은 동시 이전(concurrent relocation)을 달성하기 위해서 Shenandoah는 Brooks pointer라고 알려진 것을 사용한다. 이 포인터는 Shenandoah 힙에 있는 개별 오브젝트에 있는 추가적인 필드로, 오브젝트 자신을 가리키는 포인터이다.
Shenandoah는 객체를 옮길 때, 그 객체의 참조값을 가지고 있는 힙 안의 모든 객체를 수정해줘야 하기 때문에 이런 처리를 한다. Shenandoah는 객체를 새 위치로 옮기면서 기존 Brooks pointer를 남겨둔 뒤, 객체의 새 위치로 참조값을 전달한다. 객체가 참조되면 애플리케이션은 새 위치로 전달된 포인터(forwarding pointer)를 따라가게 된다. forwarding pointer를 가지고 있는 기존 객체는 결과적으로는 정리가 되어야 하지만, 우선은 실제 객체 자체를 옮기는 작업에서 정리 작업을 분리함으로써 Shenandoah는 애플리케이션 실행과 동시에 객체를 이동하는 작업을 보다 쉽게 수행할 수 있다.
Shenandoah를 여러분의 Java 12 이후 버전 애플리케이션에서 사용하려면 아래 옵션으로 enable하면 된다.
-XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC
만약 여러분이 아직 Java 12를 사용하지는 않지만 Shenandoah를 사용하는 것에 관심이 있다면 백포트가 되어 있는 Java 8과 Java 11에서 사용할 수 있다. Oracle에서 제공하는 JDK 빌드에서는 Shenandoah를 사용할 수 없지만 OpenJDK 배포판에서는 기본적으로 Shenandoah를 사용하도록 설정되어 있다. Shenandoah에 대한 자세한 내용은 JEP 189에서 확인할 수 있다.
Shenandoah는 동시 GC의 유일한 옵션은 아니다. 또다른 GC인 ZGC 역시 JDK 12에 향상된 버전으로 OpenJDK 및 Oracle 빌드에 포함되어 있다. 만약 여러분의 애플리케이션에 가비지 컬렉션으로 인한 중단 시간으로 인한 이슈가 있다면 Shenandoah를 시도해 봐도 좋고, 지금부터 설명할 ZGC를 살펴봐도 좋다.
동시 클래스 로드 해제(Concurrent Class Unload)를 지원하는 ZGC
ZGC의 가장 중요한 목표는 낮은 지연시간, 확장성, 사용 용이성이다. 이 목표를 달성하기 위해 ZGC는 스레드 스택을 스캔하지 않으면서 가비지 컬렉션을 진행하여 자바 애플리케이션이 지속적으로 동작할 수 있도록 한다. ZGC는 수백 MB에서 TB 크기의 Java 힙으로 확장되며, 일반적으로 2ms 이내의 매우 낮은 중단 시간을 지속적으로 유지한다.
중단 시간이 예측할 수 있는 정도로 짧다는 점은 애플리케이션 개발자와 시스템 아키텍트 모두에게 엄청난 영향을 줄 수 있다. 개발자들은 더이상 가비지 컬렉션으로 인한 중단을 피하기 위한 정교한 방법을 설계하는 것에 대해 걱정하지 않아도 될 것이다. 시스템 아키텍트들은 수많은 사용자들에게 매우 중요한 신뢰할 수 있는 낮은 중단 시간을 달성하기 위해 가져야 했던 GC 성능 튜닝에 대한 전문성의 필요성이 낮아질 것이다. 따라서 ZGC는 빅데이터와 같은 많은 양의 메모리를 요구하는 애플리케이션에 좋은 대안이 될 수 있도록 한다. 또한 매우 짧은 중단 시간이 필요한 소규모 힙 사이즈의 애플리케이션에도 좋은 대안이 될 수 있다.
ZGC는 JDK 11에 실험적으로 포함된 GC였다. JDK 12에서 ZGC는 자바 애플리케이션이 사용하지 않는 클래스를 제외하는 작업을 애플리케이션 중단 없이 실행할 수 있도록 동시 클래스 로드 해제 기능에 대한 지원을 추가했다.
동시 클래스 로드 해제는 복잡한 작업이기 때문에 클래스 로드 해제는 전통적으로 stop-the-world 중지가 된 상태에서 이루어져 왔다. 더이상 사용되지 않는 클래스를 식별하는 것은 참조 프로세싱이 선행되어야 한다. 그 후 Object.finalize() 메소드의 구현을 참조하는 finalizer의 프로세스가 이루어진다. finalizer는 끝없는 링크 체인을 통해 클래스가 살아있도록 유지하기 때문에 참조 처리(reference processing)의 일부로 finalizer로부터 도달할 수 있는 객체들은 가비지 수집으로부터 제외되어야 한다. 그러나 finalizer로부터 도달할 수 있는 모든 객체를 방문하는 데는 매우 긴 시간이 걸린다. 최악의 경우 모든 자바 힙이 하나의 finalizer로부터 도달할 수 있을 수도 있다. ZGC는 JDK 11에 처음 소개되었을 때부터 참조 처리를 자바 애플리케이션과 동시에 처리한다.
참조 처리가 끝나면 ZGC는 어떤 클래스가 더이상 필요하지 않은지 알게 된다. 다음 단계는 이 클래스들이 제거되면서 필요없게 되는 데이터들을 가지고 있는 데이터 구조(data structure)들을 제거하는 것이다. 현재 살아 있는 데이터 구조와, 유효하지 않거나 제거된 데이터 구조의 링크 역시 제거된다. 이런 연결 해제 작업을 위해 이동해야 하는 데이터 구조는 코드 캐시(모든 JIT 컴파일 코드 포함), 클래스 로더 데이터 그래프, string 테이블, symbol 테이블, profile 데이터 등과 같은 몇가지 내부 JVM 데이터 구조가 포함된다. 비활성 데이터 구조로부터의 링크 해제 작업이 종료된 후에 이 데이터 구조들은 삭제를 위해 이동하게 되고, 그 후 메모리는 최종적으로 회수된다.
오늘날까지 JDK의 모든 GC는 자바 애플리케이션에 지연 이슈를 유발하는 stop-the-world 처리를 해 왔다. 낮은 대기시간을 가지는(low-latency) GC에게 이 부분은 문제 상황이었다. 따라서 ZGC는 이제 이 모든 것들을 자바 애플리케이션과 동시에 처리하기 때문에 클래스 로드 해제를 지원하기 위해 지연 이슈를 발생시키지 않는다. 실제로 동시 클래스 로드 해제를 위해 도입된 매커니즘은 더욱 낮은 수준의 지연 시간이 발생한다. 가비지 컬렉션을 위해 발생하는 stop-the-world 중단에 드는 시간은 이제 애플리케이션 내부 스레드 개수에만 비례한다. 이 접근법이 중단 시간에 미치는 중요한 영향은 Figure 1과 같다.
ZGC는 현재 Linux/x86 64-bit 플랫폼과, Java 13에서 사용 가능한 실험적인 GC이다. 아래 커맨드라인 옵션으로 활성화 할 수 있다.
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
ZGC에 관한 더 많은 정보들은 OpenJDK wiki에서 확인할 수 있다.
G1 개선사항
어떤 조직들은 실험적인 GC를 사용하기 위해 런타임 시스템을 변경할 수 없을 수도 있다. 그런 경우 G1의 여러 개선사항들이 아주 반가운 소식이 될 것 같다. G1 컬렉터는 가비지 컬렉션 주기를 여러 다른 중단으로 시분할 처리한다.
객체들은 할당된 후 처음에는 "젊은(young)" 제너레이션의 일부로 간주된다. 그리고 객체들이 여러 가비지 컬렉션 사이클 과정에서 살아남게 되면 결국 "오래된(old)" 제너레이션으로 간주된다. G1의 여러 다른 리전(region)들은 한 가지 제너레이션의 객체들만 가지고 있고, 이는 곧 해당 리전을 영 리전(young region) 또는 올드 리전(old region)으로 칭할 수 있다.
G1은 중단 시간 목표를 달성하기 위해서 시간 목표 내에 달성할 수 있는 작업 단위를 식별한 뒤, 목표 시간 내에 작업을 완료할 수 있어야 한다. G1은 적절한 크기의 작업 단위를 식별하기 위한 복잡한 휴리스틱을 가지고 있는데, 이 휴리스틱은 요구되는 작업 시간을 예측하는 데는 효율적이지만 늘 정확한 것은 아니다. 게다가 더 복잡한 점은 G1이 하나의 가비지 컬렉션 사이클에서 영 리전의 일부만 수집할 수는 없고, 모든 영 리전에 대해서만 수집할 수 있다는 것이다.
Java 12에서는 G1 컬렉션을 일시중지하기 위해 작업을 중단하는 기능을 추가하면서 이 상황을 개선했다. G1은 여전히 휴리스틱이 얼마나 정확하게 컬렉션 대상 리전의 수를 예측하고 있는지 추적하고 있고, 필요할 경우 중단되어도 괜찮은 가비지 컬렉션만 진행한다. 이때 컬렉션 대상 집합(하나의 사이클에서 가비지 컬렉션이 수행될 리전들의 집합)을 필수(mandatory) 리전과 선택(optional) 리전 두개의 그룹으로 구분한다.
필수 리전은 GC 사이클에서 항상 수집된다. 선택 리전은 허용된 시간 내에서만 수집이 이루어지고, 선택 리전을 수집하지 못했는데 제한 시간이 지났다면 작업이 중단된다. 필수 영역에는 모든 영 리전, 일부 올드 리전이 포함된다. 올드 제너레이션 리전들은 두가지 기준에 의해 필수 영역에 포함된다. 일부는 객체 제거를 계속 할 수 있게 하기 위해 포함되고, 또 일부는 예상되는 중단 시간을 마저 채우기 위해 포함된다.
휴리스틱은 컬렉션 대상 집합 후보 리전의 개수를 -XX:G1MixedGCCountTarget 값으로 나누어서 얼마나 많은 리전들이 추가되어야 할지 계산한다. 만약 G1이 올드 제너레이션 리전을 더 수집할 수 있는 시간이 남을 것이라고 예측한다면 가능한 중단 시간의 80%에 도달할 것이라고 예측될 때까지 필수 리전에 더 많은 리전을 추가한다.
이 작업의 결과는 G1이 여러 리전이 혼합된 GC 사이클을 중단 또는 종료할 수 있다는 것을 의미한다. 따라서 G1은 중지 대기 시간이 짧아지고, 중단 시간 목표를 더 자주 달성할 가능성이 높아진다. 이 개선 사항은 JEP 344에 자세히 설명되어 있다.
사용하지 않고 커밋된 메모리의 빠른 반환
자바에 대한 가장 흔한 비판 중 하나는 자바가 메모리 중독이라는 것이다. (더이상은 아니지만!) 물론 가끔 JVM이 커맨드라인 옵션을 통해 필요한 것 이상으로 메모리를 할당할 때가 있기는 하다. 그리고 메모리 관련 커맨드라인 옵션이 제공되지 않았다면 JVM은 필요한 것 이상으로 메모리를 할당할 것이다. 램을 필요 이상으로 할당하면 모든 리소스가 계량되고 비용이 부과되는 클라우드 환경에서는 비용이 낭비될 수 있다. 하지만 이 상황을 해결하기 위해서 무엇을 할 수 있을까? 자바는 리소스 사용 측면에서 개선될 수 있을까?
일반적으로 JVM은 시간에 따른 변화들을 다루게 되기 때문에 상황에 따라 메모리가 더 필요해지기도 하고, 덜 필요해지기도 한다. 그러나 실제로 JVM은 시작할 때 많은 양의 메모리를 할당하는 경향이 있고, 메모리가 필요하지 않을 때에도 보유하고 있기 때문에 이 문제는 관계가 없는 경우가 많다(이미 많은 양의 메모리를 보유하고 있기 때문이다). 그러나 JVM에서 사용되지 않는 메모리가 운영 체제로 반환되어 다른 애플리케이션이나 컨테이너가 사용할 수 있게 된다면 가장 이상적인 케이스일 것이다. Java 12부터는 이렇게 사용되지 않는 메모리 반환이 가능해진다.
G1은 이미 미사용 메모리를 반환할 수 있는 기능을 가지고 있었지만, 이는 Full GC 과정에서만 일어났다. 문제는 Full GC는 긴 애플리케이션 중단이 발생하기 때문에 비교적 드물게 일어나고 예상하지 못한 시점에 일어난다는 것이다. 그러나 JDK 12에서 G1에는 GC와 동시에 미사용 메모리를 반환하는 기능이 추가되었다. 이 기능은 대부분의 공간이 비어있는 힙에 특히 유용하다. 힙이 대부분 비어있다면, GC 사이클이 메모리를 가져다 반환하는 데에는 다소 시간이 걸리게 된다. Java 12에서 G1은 만약에 GC 사이클이 일정 기간동안 발생하지 않은 경우 동시 GC를 트리거할 것이다. 이 기간은 커맨드라인에서 G1PeriodicGCInterval 인수를 통해 지정할 수 있다. 이 동시 GC 사이클은 GC 사이클이 종료될 때 운영체제로 메모리를 반환한다.
이런 주기적인 동시 GC는 CPU 오버헤드를 발생시키지 않게 하기 위해 시스템이 부분적으로 유휴 상태일 때 실행된다. 동시 GC 사이클 실행 여부를 트리거하는 데 사용되는 측정치는 평균 1분 정도의 시스템 로드 값으로, G1PeriodicGCSystemLoadThreshold에 의해 지정된 값보다는 작어야 한다.
자세한 내용은 JEP 346에서 확인할 수 있다.
결론
이 아티클은 GC로 인한 애플리케이션의 중단 시간을 걱정하는 여러분을 돕기 위한 여러가지 방법을 제안했다. G1이 지속적으로 개선되고는 있지만, 힙 사이즈가 증가하게 되고 더 짧은 중단 시간이 필요하게 된다면 Shenandoah와 ZGC같은 새로운 GC들이 확장 가능하고 중단 시간을 더 짧게 할 수 있다는 점을 알아두는 것도 좋겠다.