가비지 수집 고급

7장: 고급 가비지 컬렉션

지난 장에서는 Java 가비지 컬렉션의 기본 이론을 소개했습니다. 이를 출발점으로 삼아, 이번 장에서는 현대 Java 가비지 컬렉터의 이론을 소개할 것입니다. 이는 엔지니어가 컬렉터를 선택할 때 불가피한 트레이드오프를 가지고 있는 영역입니다.

우선, HotSpot JVM이 제공하는 다른 컬렉터들을 소개하고 자세히 살펴보겠습니다. 여기에는 초저지연, 대부분 동시성의 컬렉터인 CMS와 현대적인 범용 컬렉터인 G1이 포함됩니다.

또한, 좀 더 드물게 사용되는 몇몇 컬렉터들도 고려할 것입니다. 이들은 다음과 같습니다:

  • Shenandoah

  • C4

  • Balanced

  • Legacy HotSpot 컬렉터들

이들 컬렉터 중 일부는 HotSpot 가상 머신에서 사용되지 않으며, 대신 IBM J9(과거에는 폐쇄형 소스 JVM이었으나 현재는 오픈 중)와 Azul Zing(상용 JVM)의 컬렉터들에 대해서도 논의할 것입니다. 이 두 VM은 이전 장인 “JVM을 만나다”에서 이미 소개한 바 있습니다.

트레이드오프와 플러그인 가능한 컬렉터

초보자들이 즉시 인식하지 못하는 Java 플랫폼의 한 측면은, Java가 가비지 컬렉터를 가지고 있지만, 언어 및 VM 사양에서 GC가 어떻게 구현되어야 하는지를 명시하지 않는다는 점입니다. 사실, 일부 Java 구현체(예: Lego Mindstorms)는 어떠한 종류의 GC도 구현하지 않았습니다!

Sun(현재는 Oracle) 환경 내에서 GC 서브시스템은 플러그인 가능한 서브시스템으로 취급됩니다. 이는 동일한 Java 프로그램이 컬렉터의 구현을 변경하지 않고도 다른 가비지 컬렉터와 함께 실행될 수 있음을 의미하며, 프로그램의 성능은 사용되는 컬렉터에 따라 상당히 달라질 수 있습니다.

플러그인 가능한 컬렉터를 사용하는 주된 이유는 GC가 매우 일반적인 컴퓨팅 기법이기 때문입니다. 특히, 동일한 알고리즘이 모든 워크로드에 적합하지 않을 수 있습니다. 결과적으로, GC 알고리즘은 경쟁하는 여러 가지 고려사항들 간의 절충이나 트레이드오프를 나타냅니다.

참고 사항: 모든 GC 고려사항을 동시에 최적화할 수 있는 단일 범용 GC 알고리즘은 존재하지 않습니다.

개발자가 컬렉터를 선택할 때 고려해야 할 주요 사항들은 다음과 같습니다:

  • 일시 정지 시간 (pause time, 또는 일시 정지 길이)

  • 처리량 (throughput, GC 시간의 애플리케이션 런타임 비율)

  • 일시 정지 빈도 (컬렉터가 애플리케이션을 정지시켜야 하는 빈도)

  • 회수 효율성 (단일 GC 주기에서 얼마나 많은 가비지를 수집할 수 있는지)

  • 일시 정지 일관성 (모든 일시 정지가 대략 같은 길이인지 여부)

이 중, 일시 정지 시간은 종종 불균형적으로 많은 관심을 받습니다. 많은 애플리케이션에 중요한 요소이지만, 단독으로 고려되어서는 안 됩니다.

TIP: 많은 워크로드에서 일시 정지 시간은 효과적이거나 유용한 성능 특성이 아닙니다. 예를 들어, 매우 병렬적인 배치 처리 또는 빅데이터 애플리케이션은 일시 정지 시간보다는 처리량에 더 신경을 쓸 가능성이 높습니다. 많은 배치 작업에서는 수십 초의 일시 정지 시간도 실제로는 큰 문제가 되지 않으므로, GC의 CPU 효율성과 처리량을 우선시하는 컬렉터 알고리즘이 어떤 비용을 들이더라도 저지연을 목표로 하는 알고리즘보다 훨씬 더 선호됩니다.

성능 엔지니어는 트레이드오프와 컬렉터 선택과 관련된 몇 가지 다른 고려사항들도 있을 수 있음을 주목해야 합니다. 그러나 HotSpot의 경우, 사용 가능한 컬렉터로 선택이 제한됩니다.

Oracle/OpenJDK에서는, 버전 10 기준으로, 일반적인 프로덕션 사용을 위한 세 가지 주류 컬렉터가 있습니다. 우리는 이미 병렬(throughput) 컬렉터들을 만났으며, 이들은 이론적이고 알고리즘적인 관점에서 이해하기 가장 쉽습니다. 이번 장에서는 두 가지 다른 주류 컬렉터를 만나고, 이들이 Parallel GC와 어떻게 다른지를 설명할 것입니다.

장 말미에서는 "Shenandoah"와 그 외의 컬렉터들도 만나볼 텐데, 이들은 모두 사용 가능하지만 모든 컬렉터가 프로덕션 사용에 권장되는 것은 아니며, 일부는 이제 더 이상 지원되지 않습니다. 또한, 다른 HotSpot이 아닌 JVM에서 사용 가능한 컬렉터들에 대한 간단한 논의도 제공할 것입니다.

동시 GC 이론

그래픽 또는 애니메이션 디스플레이 시스템과 같은 특수 시스템에서는 고정된 프레임 속도가 존재하여, GC가 수행될 정기적이고 고정된 기회를 제공합니다.

그러나 범용 용도로 의도된 가비지 컬렉터는 이러한 도메인 지식을 가지고 있지 않으며, 따라서 일시 정지의 결정론을 향상시킬 수 없습니다. 더 나아가, 비결정론적 특성은 할당 행동에 의해 직접적으로 유발되며, Java가 사용되는 많은 시스템은 매우 가변적인 할당을 보입니다.

이러한 배치는 작은 단점으로는 계산의 지연을 초래하지만, 주요 단점은 이러한 가비지 컬렉팅 간헐적인 지연의 예측 불가능성입니다.

— 에드거 다이크스트라

현대 GC 이론의 출발점은 다이크스트라의 통찰력을 해결하려는 것입니다. 이는 STW 일시 정지의 비결정적 특성(길이와 빈도 모두)이 GC 기법 사용의 주요 불편함이라는 점입니다.

한 가지 접근 방식은 컬렉터가 동시성(또는 적어도 부분적, 대부분 동시성)을 가지도록 하여, 애플리케이션 스레드가 실행되는 동안 컬렉션에 필요한 일부 작업을 수행함으로써 일시 정지 시간을 줄이는 것입니다. 이는 실제 작업에 사용 가능한 처리 능력을 줄이고, 컬렉션을 수행하는 데 필요한 코드를 복잡하게 만듭니다.

동시 컬렉터에 대해 논의하기 전에, 중요한 GC 용어 및 기술 중 하나를 다루어야 합니다. 이는 현대 가비지 컬렉터의 특성과 동작을 이해하는 데 필수적입니다.

JVM 세이프포인트 (Safepoints)

HotSpot의 병렬 컬렉터와 같은 STW 가비지 컬렉션을 수행하기 위해, 모든 애플리케이션 스레드가 정지되어야 합니다. 이는 거의 자명한 이야기처럼 들리지만, JVM이 이를 어떻게 달성하는지에 대해 아직 논의하지 않았습니다.

JVM은 실제로 완전히 선점적인 멀티스레딩 환경이 아닙니다.

— 비밀

이는 순전히 협력적인 환경이라는 것을 의미하지 않습니다—오히려 그 반대입니다. 운영 체제는 여전히 언제든지 스레드를 선점(preempt)할 수 있습니다. 예를 들어, 스레드가 타임슬라이스를 소진했거나 wait() 상태로 들어갔을 때처럼요.

이 핵심 운영 체제 기능 외에도, JVM은 동기화된 행동을 수행해야 합니다. 이를 촉진하기 위해, 런타임은 각 애플리케이션 스레드가 특별한 실행 지점, 즉 세이프포인트(safepoints)를 가지도록 요구합니다. 이 지점에서는 스레드의 내부 데이터 구조가 알려진 좋은 상태에 있어야 합니다. 이때 스레드는 조정된 행동을 위해 일시 중지될 수 있습니다.

참고 사항: 세이프포인트의 효과는 STW GC(고전적인 예)와 스레드 동기화에서 볼 수 있지만, 그 외에도 다른 경우가 있습니다.

세이프포인트의 목적을 이해하기 위해, 완전히 STW 가비지 컬렉터의 예를 생각해 보겠습니다. 이를 실행하기 위해서는 안정적인 객체 그래프가 필요합니다. 이는 모든 애플리케이션 스레드가 정지되어야 함을 의미합니다. GC 스레드가 애플리케이션 스레드에 이를 강제할 방법이 없으므로, 애플리케이션 스레드(즉, JVM 프로세스의 일부로 실행되는 스레드)가 이를 협력하여 달성해야 합니다. JVM의 세이프포인트 접근 방식에 관한 두 가지 주요 규칙이 있습니다:

  • JVM은 스레드를 세이프포인트 상태로 강제할 수 없습니다.

  • JVM은 스레드가 세이프포인트 상태를 벗어나지 못하도록 할 수 있습니다.

이는 JVM 인터프리터의 구현이 장벽에서 세이프포인트가 필요할 때 포기를 해야 한다는 것을 의미합니다. JIT 컴파일된 메서드의 경우, 생성된 머신 코드에 동등한 장벽을 삽입해야 합니다. 세이프포인트에 도달하는 일반적인 경우는 다음과 같습니다:

  • JVM이 전역 "세이프포인트 시간" 플래그를 설정합니다.

  • 개별 애플리케이션 스레드가 이 플래그를 감지하고 정지하여 다시 깨어나기를 기다립니다.

이 플래그가 설정되면 모든 애플리케이션 스레드는 정지해야 합니다. 빠르게 정지하는 스레드는 느린 정지 스레드를 기다려야 하며, 이는 일시 정지 시간 통계에 완전히 반영되지 않을 수 있습니다.

정상적인 애플리케이션 스레드는 이 폴링 메커니즘을 사용합니다. 그들은 인터프리터에서 두 개의 바이트코드 사이를 실행할 때마다 항상 확인합니다. 컴파일된 코드에서는, JIT 컴파일러가 세이프포인트 폴링을 삽입한 가장 일반적인 경우는 컴파일된 메서드를 종료하거나 루프가 뒤로 분기할 때(예: 루프 상단으로)입니다.

스레드가 세이프포인트에 도달하는 데 오랜 시간이 걸리거나, 이론적으로는 절대로 정지하지 않을 수도 있지만, 이는 의도적으로 유발해야 하는 병리적인 경우입니다.

참고 사항: 모든 스레드가 STW 단계가 시작되기 전에 완전히 정지되어야 한다는 아이디어는 java.util.concurrent 라이브러리의 CountDownLatch와 같은 래치를 사용하는 것과 유사합니다.

세이프포인트 조건의 몇 가지 특정 사례는 여기에서 언급할 가치가 있습니다:

  • 스레드가 모니터에 의해 블록된 경우

  • 스레드가 JNI 코드를 실행 중인 경우

다음과 같은 경우에는 스레드가 반드시 세이프포인트에 있는 것은 아닙니다:

  • 스레드가 바이트코드를 실행 중인 경우 (인터프리터 모드)

  • 스레드가 OS에 의해 인터럽트된 경우

세이프포인트 메커니즘은 JVM 내부의 중요한 동작 부분이므로, 이후에 다시 만나게 될 것입니다.

삼색 마킹(Tri-Color Marking)

다이크스트라와 램포트의 1978년 논문은 삼색 마킹 알고리즘을 설명하면서 동시성 알고리즘의 정확성 증명과 가비지 컬렉션에 대한 획기적인 발전을 이루었으며, 그 기본 알고리즘은 여전히 가비지 컬렉션 이론의 중요한 부분입니다.

알고리즘의 작동 방식은 다음과 같습니다:

  • GC 루트는 회색(gray)으로 색칠됩니다.

  • 다른 모든 객체는 흰색(white)으로 색칠됩니다.

  • 마킹 스레드는 임의의 회색 노드로 이동합니다.

  • 노드에 흰색 자식이 있다면, 먼저 그들을 회색으로 색칠한 다음 노드를 검은색(black)으로 색칠합니다.

  • 이 과정을 회색 노드가 없을 때까지 반복합니다.

모든 검은색 객체는 도달 가능함이 증명되었으며 살아있어야 합니다. 흰색 노드는 수집 대상으로 간주되며 도달 불가능한 객체를 나타냅니다.

이 알고리즘은 약간의 복잡성이 있지만, 알고리즘의 기본 형태는 이렇습니다. 그림 7-1에서 예를 볼 수 있습니다.

동시 수집은 종종 스냅샷 at the beginning (SATB)라는 기법을 사용합니다. 이는 컬렉터가 컬렉션 사이클 시작 시점에 도달 가능했던 객체이거나 그 이후에 할당된 객체는 살아있는 것으로 간주함을 의미합니다. 이는 알고리즘에 약간의 변형을 추가하며, 예를 들어, 컬렉션이 실행 중일 때는 뮤테이터 스레드가 새 객체를 검은색 상태로 생성하고, 컬렉션이 진행되지 않을 때는 흰색 상태로 생성해야 합니다.

삼색 마킹 알고리즘은 컬렉션 사이클 동안 뮤테이터 스레드가 객체 그래프를 변경하는 동시 컬렉터의 특성으로 인해, 라이브 객체를 여전히 수집하지 않도록 하기 위해 추가적인 작업이 필요합니다. 예를 들어, 객체가 마킹 스레드에 의해 이미 검은색으로 색칠된 후, 뮤테이터 스레드가 그 객체를 흰색 객체를 가리키도록 업데이트하면, 이는 삼색 마킹을 무효화할 수 있습니다.

이 문제는 여러 가지 방법으로 해결할 수 있습니다. 예를 들어, 검은색 객체의 색을 다시 회색으로 변경하여, 컬렉터가 이 객체를 다시 처리하도록 추가할 수 있습니다. 이 접근 방식은 컬렉팅 사이클 전체에서 삼색 불변을 유지하는 알고리즘적 속성을 제공합니다.

다른 접근 방식은 레퍼런스 변경을 기록하는 큐를 유지하고, 컬렉션 주기가 끝난 후에 이를 수정하는 보정(fixup) 단계를 두는 것입니다. 다양한 컬렉터는 성능이나 필요한 락의 양과 같은 기준에 따라 삼색 마킹 문제를 해결하는 다양한 방법을 가질 수 있습니다.

다음 섹션에서는 저지연 컬렉터인 CMS를 먼저 소개할 것입니다. 이는 적용 범위가 제한된 컬렉터이지만, 많은 개발자들이 GC 튜닝이 트레이드오프와 절충을 필요로 한다는 사실을 인식하지 못하기 때문에 먼저 소개합니다. CMS를 먼저 고려함으로써, 성능 엔지니어들이 가비지 컬렉션을 고려할 때 알아야 할 실질적인 문제들을 보여줄 수 있습니다. 이는 보다 근거 기반의 튜닝 접근 방식을 촉진하고, 전통적인 미신 기반 튜닝을 줄이는 데 도움이 될 것입니다.

CMS (Concurrent Mark and Sweep)

Concurrent Mark and Sweep (CMS) 컬렉터는 Tenured(또는 old generation) 공간을 위해 매우 저지연의 컬렉터로 설계되었습니다. 이는 젊은 세대를 수집하기 위해 약간 수정된 병렬 컬렉터인 ParNew와 일반적으로 함께 사용됩니다.

CMS는 GC 작업을 가능한 한 애플리케이션 스레드와 동시에 수행하여 일시 정지 시간을 최소화하려고 합니다. 사용되는 마킹 알고리즘은 삼색 마킹의 형태이며, 이는 컬렉터가 힙을 스캔하는 동안 객체 그래프가 변할 수 있음을 의미합니다. 따라서 CMS는 기록을 수정하여 가비지 컬렉터의 두 번째 규칙인 살아있는 객체를 절대 수집하지 않는 규칙을 위반하지 않도록 해야 합니다.

이로 인해 CMS는 병렬 컬렉터보다 더 복잡한 일련의 단계를 거치게 됩니다. 이러한 단계는 일반적으로 다음과 같이 언급됩니다:

  • 초기 마크(Initial Mark) (STW)

  • 동시 마크(Concurrent Mark)

  • 동시 프리클린(Concurrent Preclean)

  • 리마크(Remark) (STW)

  • 동시 스윕(Concurrent Sweep)

  • 동시 리셋(Concurrent Reset)

대부분의 단계에서, GC는 애플리케이션 스레드와 함께 실행됩니다. 그러나 초기 마크와 리마크 두 단계에서는 모든 애플리케이션 스레드가 정지되어야 합니다. 전체 효과는 하나의 긴 STW 일시 정지를 두 개의 매우 짧은 STW 일시 정지로 대체하는 것입니다.

초기 마크 단계의 목적은 GC의 시작점이 되는 안정적인 포인트 세트를 제공하는 것입니다. 이는 내부 포인터라고 하며, 컬렉션 사이클 동안 GC 루트와 동일한 역할을 합니다. 이 접근 방식의 장점은 마킹 단계를 컬렉션 사이클의 목적 영역에 집중할 수 있다는 것입니다.

초기 마크가 끝난 후, 동시 마크 단계가 시작됩니다. 이는 기본적으로 힙에서 삼색 마킹 알고리즘을 실행하여, 나중에 수정이 필요할 수 있는 변경 사항을 추적합니다.

동시 프리클린 단계는 STW 리마크 단계의 길이를 가능한 한 줄이기 위해 시도하는 단계로 보입니다. 리마크 단계에서는 카드 테이블을 사용하여 동시 마크 단계 동안 뮤테이터 스레드가 영향을 준 마킹을 수정합니다.

CMS를 사용할 때 대부분의 워크로드에서 관찰할 수 있는 효과는 다음과 같습니다:

  • 애플리케이션 스레드가 정지되지 않습니다.

  • 전체 GC 사이클은 더 오래 걸립니다(실제 시간 기준).

  • CMS GC 사이클이 실행되는 동안 애플리케이션 처리량이 감소합니다.

  • 객체 추적을 위해 더 많은 메모리를 사용합니다.

  • GC를 수행하기 위해 전체적으로 더 많은 CPU 시간이 필요합니다.

  • CMS는 힙을 컴팩트하지 않기 때문에 Tenured가 단편화될 수 있습니다.

주의 깊게 읽는 사람은 이 모든 특성이 긍정적이지 않다는 것을 알게 될 것입니다. GC는 단일 해법이 없으며, 각 워크로드에 적합하거나 허용 가능한 선택들이 있을 뿐입니다.

CMS의 동작 방식

CMS의 가장 종종 간과되는 측면 중 하나는, 역설적으로, 그것의 큰 강점입니다. CMS는 대부분 애플리케이션 스레드와 동시적으로 실행됩니다. 기본적으로 CMS는 사용 가능한 스레드의 절반을 GC의 동시 단계를 수행하는 데 사용하고, 나머지 절반은 애플리케이션 스레드가 Java 코드를 실행하도록 남겨두며, 이는 새 객체를 할당하는 것을 포함합니다.

이는 직관적이지만, 즉각적인 결과를 초래합니다. Eden이 CMS가 실행되는 동안 가득 차면 어떻게 될까요?

답은 놀랍지 않게, 애플리케이션 스레드가 계속할 수 없기 때문에 정지하고, CMS가 실행되는 동안 (STW) 젊은 세대 GC가 실행됩니다. 이 젊은 GC 실행은 병렬 컬렉터의 경우보다 더 오래 걸리는 경우가 많습니다. 왜냐하면 젊은 세대 GC에 사용할 수 있는 코어가 절반이기 때문입니다 (나머지 절반의 코어는 CMS를 실행 중).

이 젊은 컬렉션이 끝난 후, 일부 객체는 보통 Tenured로 승격될 자격이 있습니다. 이러한 승격된 객체는 CMS가 아직 실행 중인 동안 Tenured로 이동해야 하므로, 두 컬렉터 간의 약간의 조정이 필요합니다. 이것이 CMS가 약간 다른 젊은 컬렉터를 필요로 하는 이유입니다.

일반적인 상황에서는 젊은 컬렉션이 Tenured로 승격할 수 있는 객체의 양이 적고, CMS의 오래된 컬렉션이 정상적으로 완료되어 Tenured에 공간을 다시 확보합니다. 애플리케이션은 정상 처리로 돌아가며, 모든 코어는 애플리케이션 스레드를 위해 해제됩니다.

그러나 매우 높은 할당률, 예를 들어 젊은 컬렉션에서 조기 승격(premature promotion)을 유발할 정도로 할당 압력이 높을 경우, 젊은 컬렉션에서 승격할 수 있는 객체가 Tenured에 충분한 공간이 없어지는 상황이 발생할 수 있습니다. 이는 그림 7-3에서 볼 수 있습니다.

이는 concurrent mode failure (CMF)로 알려져 있으며, JVM은 ParallelOld를 사용하여 컬렉션을 폴백할 수밖에 없습니다. 이는 fully STW 컬렉션입니다. 본질적으로, 할당 압력이 너무 높아 CMS가 Tenured를 처리하기 전에 모든 "헤드룸" 공간이 채워져 버렸기 때문입니다.

빈번한 concurrent mode failure를 피하기 위해, CMS는 Tenured가 완전히 가득 차기 전에 컬렉션 사이클을 시작해야 합니다. Tenured의 힙 점유 수준은 힙의 관찰된 동작에 의해 제어됩니다. 이는 스위치를 통해 영향을 받을 수 있으며, 기본값은 Tenured의 75%입니다.

또 다른 상황은 힙 단편화(heap fragmentation)입니다. ParallelOld와는 달리, CMS는 실행 중에 Tenured를 컴팩트하지 않습니다. 이는 CMS가 완료된 후, Tenured의 자유 공간이 단일 연속 블록이 아니며, 승격된 객체는 기존 객체 사이의 틈새를 채워야 한다는 것을 의미합니다.

어떤 젊은 컬렉션이 충분한 연속 공간이 없어져 객체를 Tenured로 승격할 수 없는 상황을 만나게 될 수 있습니다. 이는 그림 7-4에서 볼 수 있습니다.

이는 힙 단편화로 인한 concurrent mode failure이며, 앞서 언급한 것과 마찬가지로, 해결책은 fully STW ParallelOld 컬렉션으로 폴백하는 것입니다. 이는 힙을 컴팩트하므로, 객체를 승격할 수 있는 충분한 연속 공간을 확보할 수 있습니다.

힙 단편화 경우와 젊은 컬렉션이 CMS를 따라잡지 못하는 경우 모두, fully STW ParallelOld 컬렉션으로 폴백해야 하는 것은 애플리케이션에게 큰 이벤트가 될 수 있습니다. 실제로, CMS를 사용하는 저지연 애플리케이션을 튜닝하여 CMF를 피하는 것은 자체적으로 중요한 주제입니다.

내부적으로, CMS는 자유 공간을 관리하기 위해 메모리 청크의 자유 목록을 사용합니다. 마지막 단계인 Concurrent Sweep 동안, 청소 스레드는 자유 공간을 컴팩트하기 위해 인접한 자유 블록을 병합합니다. 이는 더 큰 자유 공간 블록을 제공하고, 단편화로 인한 CMF를 피하기 위해서입니다.

하지만, 청소 스레드는 뮤테이터와 동시에 실행됩니다. 따라서, 청소 스레드와 할당 스레드가 적절하게 동기화되지 않으면, 새로 할당된 블록이 잘못 청소될 수 있습니다. 이를 방지하기 위해, 청소 스레드는 스윕하는 동안 자유 목록을 잠급니다.

CMS를 위한 기본 JVM 플래그

CMS 컬렉터는 다음 플래그로 활성화됩니다:

  • -XX:+UseConcMarkSweepGC

현대 HotSpot 버전에서는, 이 플래그는 ParNewGC(병렬 젊은 컬렉터의 약간 변형된 형태)도 활성화합니다.

일반적으로, CMS는 조정할 수 있는 플래그가 매우 많으며(60개 이상), 성능을 최적화하기 위해 다양한 옵션을 신중하게 조정하려는 벤치마킹 시도가 유혹적일 수 있습니다. 이는 대부분의 경우, 실제로는 미싱 더 비거 픽쳐(Missing the Bigger Picture) 또는 Tuning by Folklore(전통적 튜닝)의 안티패턴에 해당하므로 이를 자제해야 합니다.

CMS 튜닝에 대한 자세한 내용은 “Parallel GC 튜닝”에서 다룰 것입니다.

G1 (Garbage First)

G1(Garbage First) 컬렉터는 병렬 컬렉터나 CMS와는 매우 다른 스타일의 컬렉터입니다. 이는 Java 6에서 매우 실험적이고 불안정한 형태로 처음 도입되었지만, Java 7 동안 광범위하게 재작성되었고, Java 8u40 릴리스에서야 안정적이고 프로덕션 준비가 되었다고 할 수 있습니다.

TIP: G1은 Java 8u40 이전의 어떤 Java 버전과도 함께 사용하지 않는 것이 권장됩니다, 워크로드 유형과 관계없이.

G1은 원래 다음과 같은 특징을 가진 저지연 컬렉터의 대체물로 의도되었습니다:

  • CMS보다 조정이 훨씬 쉬움

  • 조기 승격에 덜 민감함

  • 큰 힙에서 더 나은 확장성(특히 일시 정지 시간)

  • 전체 STW 컬렉션의 필요성을 제거하거나 크게 줄일 수 있음

그러나 시간이 지나면서, G1은 보다 범용적인 컬렉터로 인식되기 시작했으며, 더 큰 힙에서 더 나은 일시 정지 시간을 제공하는 것으로 여겨졌습니다 (이는 점점 “새로운 표준”으로 인식되고 있습니다).

참고 사항: Oracle은 Java 9에서 G1이 병렬 컬렉터를 대체하도록 강력히 주장했으며, 이는 최종 사용자에게 미치는 영향과 관계없이 진행되었습니다. 따라서, 성능 분석가들이 G1을 잘 이해하고, Java 8에서 9로 이동하는 모든 애플리케이션이 이동의 일환으로 적절하게 재테스트되어야 하는 것이 매우 중요합니다.

G1 컬렉터는, 우리가 지금까지 접한 세대 개념을 재고한 설계를 가지고 있습니다. 병렬 또는 CMS 컬렉터와는 달리, G1은 세대별로 전용의 연속적인 메모리 공간을 가지고 있지 않습니다. 또한, 우리가 본 헤미스피어컬 힙 레이아웃을 따르지 않습니다.

G1 힙 레이아웃과 영역(Regions)

G1 힙은 영역(Regions) 개념을 기반으로 합니다. 기본적으로 각 영역은 1MB 크기이며 (더 큰 힙에서는 더 큰 크기도 가능), 이 영역을 사용함으로써 세대별로 분리된 메모리 공간을 가지지 않고도, 모든 가비지를 각 실행 시마다 수집할 필요 없이 컬렉션을 수행할 수 있습니다.

참고 사항: G1 힙은 여전히 메모리 내에서 연속적이지만, 각 세대를 구성하는 메모리는 더 이상 반드시 연속적일 필요는 없습니다.

G1 힙의 영역 기반 레이아웃은 그림 7-5에서 볼 수 있습니다.

G1의 알고리즘은 1, 2, 4, 8, 16, 32 또는 64MB 크기의 영역을 허용합니다. 기본적으로, 힙에서는 2,048개에서 4,095개 사이의 영역을 예상하며, 이를 달성하기 위해 영역 크기를 조정합니다.

영역 크기를 계산하기 위해 다음 값을 계산합니다:

  • / 2048

그런 다음, 가장 가까운 허용된 영역 크기 값으로 내림합니다. 그런 다음, 영역의 수는 다음과 같이 계산됩니다:

  • 영역 수 = /

보통 런타임 스위치를 적용하여 이 값을 변경할 수 있습니다.

G1 알고리즘 설계

컬렉터의 고수준적인 그림은 G1이 다음과 같은 특징을 가진다는 것입니다:

  • 동시 마킹 단계를 사용합니다.

  • 이베큐다잉(evacuating) 컬렉터입니다.

  • "통계적 컴팩션(statistical compaction)"을 제공합니다.

워밍업 중에, 컬렉터는 각 GC 주기에서 "일반적인" 영역을 얼마나 많이 수집할 수 있는지의 통계를 추적합니다. 만약 마지막 GC 이후에 할당된 새 객체를 균형 잡을 만큼 충분한 메모리를 수집할 수 있다면, G1은 할당률에 따라 지체하지 않습니다.

TLAB 할당, survivor 공간으로의 이베큐다잉, Tenured로의 승격 개념은 우리가 이미 만난 HotSpot의 다른 GC들과 대체로 유사합니다.

참고 사항: 지역 크기의 절반보다 큰 공간을 차지하는 객체는 거대한 객체(humongous objects)로 간주됩니다. 이러한 객체는 특별한 거대한 영역(humongous regions)에 직접 할당되며, 이는 자유롭고 연속적인 영역으로 Tenured 세대의 일부로 즉시 포함됩니다 (Eden 대신).

G1은 여전히 Eden과 survivor 영역으로 구성된 젊은 세대 개념을 가지고 있지만, 물론 G1에서는 세대별로 구성된 영역이 연속적이지 않습니다. 젊은 세대의 크기는 전체 일시 정지 목표에 따라 적응적으로 조정됩니다.

우리가 ParallelOld 컬렉터를 만났을 때처럼, "오래된 세대에서 젊은 세대로의 참조가 적다"는 휴의 세대 가설을 다시 만납니다. HotSpot은 병렬 및 CMS 컬렉터에서 이 현상을 활용하기 위해 카드 테이블(card tables)을 사용하는 메커니즘을 유지합니다.

G1 컬렉터는 영역 추적을 돕기 위해 연관된 기능을 가지고 있습니다. Remembered Sets (보통 RSets라 부름)는 힙 영역으로 가리키는 외부 참조를 추적하는 영역별 항목들입니다. 이는 특정 영역에 포인터가 있는 참조를 찾기 위해 전체 힙을 순회하는 대신, G1이 RSets를 검토하고 해당 영역에서 참조를 스캔할 수 있게 합니다.

그림 7-6은 RSets가 G1의 컬렉션 작업을 할당자와 컬렉터 간에 나누는 방식을 구현하는 방법을 보여줍니다.

RSets와 카드 테이블은 모두 가비지 컬렉션 문제인 플로팅 가비지(floating garbage)를 해결하는 데 도움이 되는 접근 방식입니다. 이는 도달 불가능한 객체가 죽은 객체에서 가리키는 참조 때문에 살아있는 것으로 간주되는 문제로, 전역 마크에서는 죽은 객체이지만, 제한된 로컬 마킹에서는 살아있는 것으로 잘못 보고 수집될 수 있습니다.

G1 단계

G1 컬렉터는 CMS와 유사한 단계의 컬렉션 단계를 가지고 있습니다:

  • 초기 마크(Initial Mark) (STW)

  • 동시 루트 스캔(Concurrent Root Scan)

  • 동시 마크(Concurrent Mark)

  • 리마크(Remark) (STW)

  • 클린업(Cleanup) (STW)

동시 루트 스캔은 초기 마크의 survivor 영역을 스캔하여 오래된 세대로의 참조를 찾는 동시 단계입니다. 이 단계는 다음 젊은 GC가 시작되기 전에 완료되어야 합니다. 리마크 단계에서는 마킹 사이클이 완료됩니다. 이 단계는 참조 처리(약한 참조, 부드러운 참조 포함) 및 SATB 접근 방식을 구현하기 위한 클린업을 수행합니다.

클린업은 대부분 STW이며, 회계 및 RSet "스크러빙"을 포함합니다. 회계 작업은 이제 완전히 자유로운 영역을 식별하고 재사용 준비를 합니다 (예: Eden 영역).

G1을 위한 기본 JVM 플래그

G1을 활성화하려면 (Java 8 이전의 경우), 다음 스위치를 사용합니다:

  • -XX:+UseG1GC

G1은 일시 정지 목표(pause goals) 기반으로 동작합니다. 이는 애플리케이션이 각 가비지 컬렉션 사이클에서 일시 정지하는 최대 시간을 지정할 수 있게 해줍니다. 이는 목표로 설정된 값이며, 컬렉터가 이를 반드시 충족할 것이라는 보장은 없습니다. 이 값이 너무 낮게 설정되면, GC 서브시스템은 목표를 달성할 수 없게 됩니다.

참고 사항: 가비지 컬렉션은 할당에 의해 주도되며, 많은 Java 애플리케이션에서 할당은 매우 예측 불가능할 수 있습니다. 이는 G1이 일시 정지 목표를 달성하는 능력을 제한하거나 파괴할 수 있습니다.

컬렉터의 핵심 동작을 제어하는 스위치는 다음과 같습니다:

  • -XX:MaxGCPauseMillis=200

이는 기본 일시 정지 목표 시간이 200ms임을 의미합니다. 실제로, 100ms 미만의 일시 정지 시간은 신뢰할 수 있게 달성하기 매우 어렵고, 컬렉터가 이를 충족하지 못할 수 있습니다. 또한, 영역 크기를 변경하여 기본 알고리즘을 재정의하는 옵션도 있습니다:

  • -XX:G1HeapRegionSize=<n>

여기서 <n>은 2의 거듭제곱이어야 하며, 1에서 64MB 사이의 값이어야 합니다. 우리는 G1 튜닝을 Chapter 8에서 논의할 때 다른 G1 플래그들을 다룰 것입니다.

전체적으로, G1은 알고리즘적으로 안정적이며 Oracle에 의해 완전히 지원되고 있습니다 (Java 8u40 이후 권장됨). 진정으로 저지연 워크로드에서는 대부분의 워크로드에 대해 CMS보다 저지연이 낮을 수 없으며, G1이 순수한 일시 정지 시간에서 CMS에 도전할 수 있을지는 불확실합니다. 그러나 컬렉터는 여전히 개선되고 있으며, Oracle의 JVM 팀 내에서 GC 엔지니어링 노력이 집중되고 있습니다.

Shenandoah

Oracle이 범용, 차세대 컬렉터를 생산하기 위한 노력을 진행하는 것 외에도, Red Hat은 OpenJDK 프로젝트 내에서 Shenandoah라는 자체 컬렉터를 개발하고 있습니다. 이는 여전히 실험적인 컬렉터이며, 현재는 프로덕션 용도로 준비되지 않았습니다. 그러나 몇 가지 유망한 특징을 보여주며, 최소한 소개할 가치가 있습니다.

G1과 마찬가지로, Shenandoah의 목표는 일시 정지 시간(특히 큰 힙에서)을 줄이는 것입니다. Shenandoah의 접근 방식은 동시 컴팩션을 수행하는 것입니다. Shenandoah의 컬렉션 단계는 다음과 같습니다:

  • 초기 마크(Initial Mark) (STW)

  • 동시 마킹(Concurrent Marking)

  • 최종 마크(Final Marking) (STW)

  • 동시 컴팩션(Concurrent Compaction)

이 단계들은 CMS와 G1에서 본 것과 유사해 보일 수 있으며, Shenandoah도 유사한 접근 방식(SATB)을 사용합니다. 그러나 몇 가지 근본적인 차이점이 있습니다.

Shenandoah의 가장 눈에 띄고 중요한 특징 중 하나는 Brooks 포인터의 사용입니다. 이 기술은 이전 단계에서 객체가 재배치되었는지 여부를 나타내는 추가 메모리 워드를 사용하고, 객체 내용의 새 버전 위치를 제공합니다.

Shenandoah에서 사용하는 힙 레이아웃은 그림 7-7에서 볼 수 있습니다. 이 메커니즘은 때때로 "forwarding pointer" 접근 방식이라 불립니다. 객체가 재배치되지 않은 경우, Brooks 포인터는 단순히 다음 메모리 워드를 가리킵니다.

참고 사항: Brooks 포인터 메커니즘은 forwarding address의 원자적 업데이트를 제공하기 위해 하드웨어의 compare-and-swap(CAS) 연산의 사용을 전제합니다.

Shenandoah의 동시 마킹 단계는 힙을 스캔하고 살아있는 객체를 마크합니다. 객체 참조가 forwarding 포인터를 가진 oop를 가리키는 경우, 참조는 새 oop 위치를 직접 가리키도록 업데이트됩니다. 이는 그림 7-8에서 볼 수 있습니다.

최종 마크 단계에서는 Shenandoah는 세상을 멈추고 루트 세트를 다시 스캔하며, evaculated 복사본을 가리키도록 루트를 복사하고 업데이트합니다.

동시 컴팩션(Concurrent Compaction)

GC 스레드(애플리케이션 스레드와 동시에 실행)는 다음과 같은 방식으로 이베큐다잉을 수행합니다:

  1. 객체를 TLAB에 복사합니다 (추측적으로).

  2. CAS 연산을 사용하여 Brooks 포인터를 추측된 복사본을 가리키도록 업데이트합니다.

  3. 성공하면, 컴팩션 스레드는 경쟁에서 이겼으며, 이 객체의 모든 미래 참조는 Brooks 포인터를 통해 이루어집니다.

  4. 실패하면, 컴팩션 스레드는 추측 복사본을 되돌리고, 승리한 스레드가 남긴 Brooks 포인터를 따릅니다.

Shenandoah는 동시 컬렉터이므로, 컬렉션 사이클이 실행되는 동안 애플리케이션 스레드는 더 많은 가비지를 생성하고 있습니다. 따라서, 애플리케이션 실행 중에 컬렉션이 할당을 따라잡아야 합니다.

Shenandoah 획득 방법

Shenandoah 컬렉터는 현재, Oracle Java 빌드의 일부로 제공되지 않으며, 대부분의 OpenJDK 배포판에도 포함되어 있지 않습니다. 일부 Linux 배포판, 예를 들어 Red Hat Fedora의 IcedTea 바이너리에 포함되어 있습니다. 다른 사용자들은 소스에서 컴파일해야 할 것입니다(작성 시점). 이는 Linux에서는 간단하지만, macOS 같은 다른 운영 체제에서는 컴파일러(gcc 대신 clang)를 비롯한 환경 차이로 인해 덜 간단할 수 있습니다.

작동하는 빌드를 얻으면, 다음 스위치로 Shenandoah를 활성화할 수 있습니다:

  • -XX:+UseShenandoahGC

Shenandoah의 일시 정지 시간을 다른 컬렉터들과 비교한 그림은 그림 7-9에서 볼 수 있습니다.

Shenandoah의 과소평가된 측면 중 하나는 그것이 세대별 컬렉터가 아니라는 점입니다. 프로덕션 준비가 가까워짐에 따라, 이는 디자인 결정의 결과가 성능 민감한 애플리케이션에 잠재적인 우려 사항이 될 수 있습니다.

C4 (Azul Zing)

Azul Systems는 두 가지 다른 Java 플랫폼을 제공합니다. 하나는 여러 플랫폼에서 사용할 수 있는 OpenJDK 기반의 FOSS 솔루션인 Zulu이고, 다른 하나는 Linux 전용의 상용 및 독점 플랫폼인 Zing입니다. Zing은 OpenJDK에서 파생된 Java 클래스 라이브러리를 사용하지만, 완전히 다른 가상 머신을 사용합니다.

참고 사항: Zing의 중요한 측면 중 하나는 처음부터 64비트 머신을 위해 설계되었으며, 32비트 아키텍처를 지원할 의도가 없었다는 점입니다.

Zing VM에는 몇 가지 새로운 소프트웨어 기술이 포함되어 있습니다. 여기에는 C4(Continuously Concurrent Compacting Collector) 가비지 컬렉터와 ReadyNow 및 Falcon이라는 컴파일러를 포함한 새로운 JIT 기술이 있습니다.

Shenandoah와 마찬가지로, Zing은 동시 컴팩션 알고리즘을 사용하지만, Brooks 포인터를 사용하지 않습니다. 대신, Zing의 객체 헤더는 단일 64비트 워드로 구성됩니다 (HotSpot이 사용하는 두 워드 헤더와는 달리). 단일 헤더 워드는 클래스 ID(kid)를 포함하고 있으며, 이는 클래스 포인터가 아니라 약 25비트 길이의 숫자입니다.

Shenandoah의 LVB(Loaded Value Barrier)와 관련된 동작을 피하고, 각 로드된 참조가 객체의 현재 위치를 바로 가리키도록 하는 아이디어가 포함됩니다.

Zing은 객체가 재배치되었을 경우, 애플리케이션 스레드는 참조를 새 위치로 업데이트하여 참조를 "힐링(healing)"합니다. 이는 각 참조가 최대 한 번만 업데이트되며, 참조가 다시 사용되지 않으면 추가적인 작업이 필요 없음을 의미합니다.

Zing의 참조 구조는 다음과 같습니다:

struct Reference {
    unsigned inPageVA : 21; 
    unsigned PageNumber : 21; 
    unsigned NMT : 1; 
    unsigned SpaceID : 2; 
    unsigned unused : 19; // bits 0-20
    // bits 21-41
    // bit 42
    // bits 43-44
    // bits 45-63
};

NMT(Not Marked Through) 비트는 컬렉션 사이클에서 객체가 이미 마크되었는지 여부를 나타내는 데 사용됩니다. C4는 살아있는 객체가 목표 상태로 마크되도록 유지하며, 컬렉션 사이클이 끝나면 타겟 상태 비트를 뒤집어, 살아남은 객체가 다음 사이클을 준비할 수 있도록 합니다.

C4의 GC 사이클 단계는 다음과 같습니다:

  • 마크(Mark)

  • 이베큐다잉(Relocate)

  • 리맵(Remapping)

이베큐다잉 단계에서는 G1과 유사하게 희박한 페이지를 집중적으로 처리합니다. 이는 C4가 evacuating 컬렉터이기 때문입니다.

C4는 Brooks 포인터 대신, 단일 헤더 워드를 사용하여 객체의 이동을 추적합니다. 이는 동시 컴팩션을 가능하게 합니다.

C4의 특징은 다음과 같습니다:

  • 무한한 동시 컴팩션을 제공

  • 객체 참조 업데이트는 CAS 연산을 사용

  • LVB(Loaded Value Barrier)를 통해 참조가 즉시 업데이트됨

Balanced (IBM J9)

IBM은 J9이라는 JVM을 생산합니다. 이는 역사적으로 독점적인 JVM이었지만, IBM은 이를 오픈소스화(OpenJ9)하고 있습니다. VM에는 병렬 컬렉터와 유사한 고처리량(high-throughput) 컬렉터를 포함하여 여러 가지 다른 컬렉터가 있습니다.

이 섹션에서는 Balanced GC 컬렉터에 대해 논의할 것입니다. 이는 64비트 J9 JVM에서 사용할 수 있는 영역 기반 컬렉터로, 4GB 이상의 힙을 위해 설계되었습니다. 주요 설계 목표는 다음과 같습니다:

  • 큰 Java 힙에서 일시 정지 시간의 확장성 개선

  • 최악의 일시 정지 시간 최소화

  • 비균일 메모리 접근(NUMA) 성능 인식 활용

첫 번째 목표를 달성하기 위해, 힙은 여러 영역으로 분할되며, 이들 영역은 독립적으로 관리되고 수집됩니다. G1과 마찬가지로, Balanced 컬렉터는 최대 2,048개의 영역을 관리하며, 이를 달성하기 위해 영역 크기를 선택합니다. 영역 크기는 2의 거듭제곱이어야 하며, Balanced는 최소 512KB 크기의 영역을 허용합니다.

세대별 영역 기반 컬렉터의 개념과 유사하게, 각 영역은 연령(age)을 가지며, 연령이 0인 영역(Eden)은 새 객체 할당에 사용됩니다. Eden 공간이 가득 차면, 컬렉션이 수행되어야 합니다. IBM에서는 이를 부분 가비지 컬렉션(Partial Garbage Collection, PGC)이라고 부릅니다.

PGC는 STW 작업으로, 모든 Eden 영역을 수집하며, 컬렉터가 수집할 가치가 있다고 판단하는 높은 연령의 영역도 선택적으로 수집할 수 있습니다. 이를 통해 PGC는 G1의 혼합 컬렉션과 유사합니다.

참고 사항: PGC가 완료되면, 생존 객체를 포함하는 영역의 연령이 1 증가합니다. 이를 세대별 영역(generational regions)이라고도 합니다.

다른 J9 GC 정책과 비교할 때, Balanced의 또 다른 장점은 클래스 언로딩(class unloading)을 점진적으로 수행할 수 있다는 점입니다. Balanced는 PGC 중에 현재 컬렉션 세트의 일부인 클래스 로더(classloaders)를 수집할 수 있습니다. 이는 다른 J9 컬렉터와 대조적으로, 다른 컬렉터에서는 클래스 로더를 전역 컬렉션 동안에만 수집할 수 있었습니다.

하나의 단점은 PGC가 수집할 영역만을 볼 수 있기 때문에, 이 유형의 컬렉션은 플로팅 가비지(floating garbage)를 겪을 수 있다는 점입니다. 이를 해결하기 위해, Balanced는 전역 마크 단계(Global Mark Phase, GMP)를 사용합니다. 이는 부분 동시 작업으로, 전체 Java 힙을 스캔하여 수집할 죽은 객체를 표시합니다. GMP가 완료되면, 다음 PGC는 이 데이터를 기반으로 작동합니다. 따라서 힙의 플로팅 가비지 양은 마지막 GMP가 시작된 이후에 사망한 객체 수로 제한됩니다.

Balanced가 수행하는 마지막 유형의 GC 작업은 전역 가비지 컬렉션(Global Garbage Collection, GGC)입니다. 이는 힙을 컴팩트하는 완전한 STW 컬렉션으로, ParallelOld에서 발생하는 전체 컬렉션과 유사합니다.

J9 객체 헤더

기본 J9 객체 헤더는 클래스 슬롯(class slot)으로, 이는 64비트 크기이며, 압축 참조(Compressed References)가 활성화된 경우 32비트 크기입니다.

참고 사항: Compressed References는 힙이 57GB 미만일 때 기본 설정이며, HotSpot의 압축된 oops(compressed oops) 기술과 유사합니다.

그러나, 객체 유형에 따라 헤더에 추가 슬롯이 있을 수 있습니다:

  • 동기화된 객체는 모니터 슬롯(monitor slots)을 가집니다.

  • 내부 JVM 구조에 배치된 객체는 해시 슬롯(hashed slots)을 가집니다.

또한, 모니터 슬롯과 해시 슬롯은 반드시 객체 헤더에 인접해 있지 않을 수 있으며, 정렬으로 인해 낭비된 공간을 활용하여 객체의 어느 곳에나 저장될 수 있습니다. J9의 객체 레이아웃은 그림 7-11에서 볼 수 있습니다.

클래스 슬롯의 상위 24비트(또는 56비트)는 클래스 구조(class structure)를 가리키는 포인터이며, 이는 Java 8의 Metaspace와 유사하게 힙 외부에 있습니다. 하위 8비트는 사용 중인 GC 정책에 따라 다양한 목적으로 사용되는 플래그입니다.

Balanced에서의 대형 배열

Java에서 대형 배열을 할당하는 것은 매우 흔한 일이므로, 이는 컴팩팅 컬렉션을 유발하는 주요 요인이 됩니다. 대형 배열을 할당하려면 충분한 연속 공간을 찾아야 하기 때문입니다. CMS 논의에서, 자유 목록의 병합이 충분한 공간을 확보하지 못해 concurrent mode failure가 발생할 수 있다는 점을 보았습니다.

영역 기반 컬렉터의 경우, Java에서 단일 영역을 초과하는 크기의 배열 객체를 할당할 수 있습니다. 이를 해결하기 위해, Balanced는 discontiguous chunks로 배열 객체를 할당할 수 있는 대체 표현(arraylets)을 사용합니다. 이는 힙 객체가 영역을 가로지를 수 있는 유일한 경우입니다.

배열let 표현은 사용자 Java 코드에서는 보이지 않으며, 대신 JVM에서 투명하게 처리됩니다. 할당자는 큰 배열을 중앙 객체(spine)로 표현하고, 배열의 실제 항목을 포함하는 배열 리프(array leaves)를 포함합니다. 이는 배열 항목을 읽을 때 단일 간접 참조의 추가 오버헤드만 발생하게 합니다. 예는 그림 7-12에서 볼 수 있습니다.

참고 사항: 배열let 표현은 JNI API를 통해 잠재적으로 볼 수 있으므로, 다른 JVM에서 JNI 코드를 포팅할 때 스파인(spine)과 리프(leaf) 표현을 고려해야 할 수 있습니다.

부분적인 영역 수집은 평균 일시 정지 시간을 줄이지만, 레퍼런스 수와 참조 대상 영역에 대한 정보를 유지하는 오버헤드로 인해 전체 GC 작업 시간은 더 많이 걸릴 수 있습니다.

결국, 힙이 가득 찼을 때 전역 STW 컬렉션 또는 컴팩션의 필요성이 크게 줄어들며, 이는 힙이 가득 찼을 때만 발생하는 최악의 일시 정지 시간 상황을 크게 줄여줍니다.

NUMA와 Balanced

비균일 메모리 접근(NUMA)은 다중 프로세서 시스템에서 사용되는 메모리 아키텍처로, 보통 중대형 서버에서 사용됩니다. 이러한 시스템에서는 메모리와 프로세서 간의 거리를 개념화하며, 프로세서와 메모리는 노드(node)로 배열됩니다. 특정 노드의 프로세서는 다른 노드의 메모리에 접근할 수 있지만, 동일한 노드의 메모리에 접근할 때는 훨씬 빠릅니다.

여러 NUMA 노드에서 실행되는 JVM의 경우, Balanced 컬렉터는 Java 힙을 여러 노드에 걸쳐 분할할 수 있습니다. 애플리케이션 스레드는 특정 노드에서의 실행을 선호하도록 배치되며, 객체 할당은 해당 노드의 로컬 메모리에 있는 영역을 선호합니다. 이러한 배열의 개략적인 모습은 그림 7-13에서 볼 수 있습니다.

또한, 부분적인 가비지 컬렉션은 애플리케이션 스레드가 참조하는 객체에 더 가까운(메모리 거리 측면에서) 객체로 이동하려고 시도합니다. 이는 스레드가 참조하는 메모리가 로컬일 가능성을 높여 성능을 향상시킵니다. 이 과정은 애플리케이션에 투명하게 이루어집니다.

Legacy HotSpot 컬렉터들

이전 HotSpot 버전에서는 여러 가지 다른 컬렉터들이 사용 가능했습니다. 여기에서는 완전한 프로덕션 사용을 더 이상 권장하지 않으며, 몇몇 조합은 Java 8에서 더 이상 사용되지 않거나 Java 9에서 제거되었음을 완전성을 위해 언급합니다.

Serial과 SerialOld

Serial과 SerialOld 컬렉터는 병렬 컬렉터와 ParallelOld 컬렉터와 유사하게 동작했지만, 중요한 차이점이 하나 있습니다: 이들은 GC를 수행하는 데 단일 CPU 코어만 사용했습니다. 현대의 멀티코어 시스템에서는 이 컬렉터들을 사용하는 데 있어 성능상의 이점이 전혀 없으므로, 사용해서는 안 됩니다.

성능 엔지니어는 이러한 컬렉터들을 인식하고, 이를 사용하는 애플리케이션을 발견하면 즉시 제거할 수 있도록 활성화 스위치를 알고 있어야 합니다.

이러한 컬렉터들은 Java 8에서 더 이상 사용되지 않으므로, 매우 오래된 Java 버전을 사용하는 레거시 애플리케이션에서만 만날 수 있습니다.

Incremental CMS (iCMS)

Incremental CMS (iCMS)는 이전의 동시 컬렉션 시도 중 하나로, CMS에 나중에 G1을 도입하는 아이디어를 일부 도입하려 했던 과거의 시도였습니다. 이 모드를 활성화하는 스위치는 다음과 같습니다:

  • -XX:+CMSIncrementalMode

일부 전문가들은 매우 오래된 하드웨어(코어가 하나 또는 두 개인 시스템)에 배포된 애플리케이션에서 iCMS가 성능 관점에서 유효한 선택일 수 있다고 주장하지만, 대부분의 현대 서버급 애플리케이션은 iCMS를 사용해서는 안 되며, Java 9에서 제거되었습니다.

경고: 할당과 관련된 세이프포인트 동작과 다른 단점으로 인해, 증가적 모드를 사용할 이점이 확실한 특별한 증거가 없는 한 이를 사용하지 말아야 합니다.

Deprecated 및 Removed GC 조합들

현재의 일반적인 탈퇴 및 제거 주기는 기능이 하나의 Java 버전에서 deprecated되고 다음 버전에서 제거되거나 그 이후에 제거되는 것입니다. 따라서, Java 8에서는 Table 7-1에 있는 GC 플래그 조합들이 deprecated되었고, Java 9에서 제거되었습니다.

Table 7-1. Deprecated GC combinations

조합
플래그

DefNew + CMS

-XX:-UseParNewGC -XX:+UseConcMarkSweepGC

ParNew + SerialOld

-XX:+UseParNewGC

ParNew + iCMS

-Xincgc

ParNew + iCMS

-XX:+CMSIncrementalMode -XX:+UseConcMarkSweepGC

DefNew + iCMS

-XX:+CMSIncrementalMode -XX:+UseConcMarkSweepGC -XX:-UseParNewGC

CMS foreground

-XX:+UseCMSCompactAtFullCollection

CMS foreground

-XX:+CMSFullGCsBeforeCompaction

CMS foreground

-XX:+UseCMSCollectionPassing

새로운 작업을 시작할 때, 애플리케이션이 deprecated된 구성을 사용하고 있지 않은지 확인하기 위해 이 표를 참조해야 합니다.

Epsilon

Epsilon 컬렉터는 레거시 컬렉터가 아닙니다. 그러나, 프로덕션에서 어떤 상황에서도 사용해서는 안 되기 때문에 여기에서 언급됩니다. 다른 컬렉터들이 환경에서 발견되면, 이를 즉시 높은 위험으로 표시하고 즉시 제거해야 하는 반면, Epsilon은 약간 다릅니다.

Epsilon은 테스트 목적을 위해 설계된 실험적인 컬렉터입니다. 이는 제로-노력(zero-effort) 컬렉터로, 실제로 가비지를 수집하려는 노력이 없습니다. Epsilon 하에서 실행되는 동안 할당된 모든 힙 메모리는 효과적으로 메모리 누수로 간주됩니다. 이는 재사용되지 않고 회수될 수 없으며, 결국 JVM이 힙을 고갈시켜 빠르게 충돌하게 됩니다.

제안된 VM-GC 인터페이스는 Epsilon을 인터페이스 자체의 최소한의 테스트 케이스로 사용함으로써 혜택을 볼 수 있습니다.

참고 사항: Epsilon은 주로 다음과 같은 목적에 유용합니다:

  • 성능 테스트 및 마이크로벤치마크

  • 회귀 테스트

  • 저/제로 할당 Java 애플리케이션 또는 라이브러리 코드 테스트

특히, JMH 테스트는 GC 이벤트가 성능 수치를 방해하지 않도록 확실히 제외할 수 있는 기능을 갖추는 것이 유용합니다. 메모리 할당 회귀 테스트는 변경된 코드가 할당 동작을 크게 변경하지 않는지 확인하는 데 쉽게 할 수 있습니다. 개발자는 Epsilon 구성으로 실행되는 테스트를 작성하여, 힙이 고갈되어 더 이상의 할당이 발생할 경우 실패하도록 할 수 있습니다.

Last updated

Was this helpful?