이번 포스팅에서는 JVM의 GC(Garbage Collection), 그중에서도 G1 GC에 대해 자세히 알아보겠습니다.
Garbage Collection(GC)이란?
자바에서는 메모리 관리의 복잡성을 줄이기 위해 Garbage Collection(GC)이라는 자동 메모리 관리 기법을 제공합니다. 개발자는 메모리를 처리하기 위한 로직을 직접 만들 필요가 없으며, 절대로 만들어서도 안 됩니다. 그렇다면 GC는 정확히 무엇을 하고, 어떻게 동작할까요?
자바에서의 '쓰레기'란?
자바에서 '쓰레기'는 더 이상 참조되지 않는 객체를 의미합니다. 즉, 프로그램에서 사용되지 않는 객체로, 메모리에서 제거되어야 하는 대상입니다.
public void createObjects() {
Object obj1 = new Object();
Object obj2 = new Object();
// obj1과 obj2를 사용하는 코드
}
// 메서드 종료 후 obj1과 obj2는 더 이상 참조되지 않음
위 코드에서 `createObjects()` 메서드가 실행된 후, `obj1`과 `obj2`는 메서드 스코프를 벗어나 더 이상 참조되지 않습니다. 이러한 객체들은 GC의 대상이 됩니다.
하지만 GC는 단순히 '쓰레기'를 정리하는 작업만 하는 것이 아닙니다. GC는 어떻게 필요한 객체와 필요 없는 객체를 구분할까요? 이를 이해하기 위해 JVM의 메모리 구조를 살펴보겠습니다.
JVM의 런타임 데이터 영역 (Run-time Data Areas)
링크를 클릭하면 오라클에서 제공하는 자바의 스펙을 정리한 문서 목록을 확인할 수 있는데, 그중에서 Java Virtual Machine Specification 문서를 보면 Run-time Data Areas라는 장이 있습니다. 이 장을 보면 자바에서 사용하는 메모리 영역들에 대한 상세한 설명을 볼 수 있습니다. 여기에 명시된 영역들의 목록은 다음과 같습니다.
- PC 레지스터(Program Counter Register)
- JVM 스택(Stack)
- 힙(Heap)
- 메서드 영역(Method Area)
- 런타임 상수 풀(Runtime Constant Pool)
- 네이티브 메서드 스택(Native Method Stack)
이 영역 중에서 GC가 주로 발생하는 부분은 힙(Heap) 영역이지만 메서드 영역(Method Area)에서도 클래스 언로딩과 같은 GC 작업이 발생할 수 있기 때문에 힙과 메서드 영역 모두 GC의 대상이 될 수 있습니다. 단순하게 얘기하면 자바의 메모리 영역은 크게 'Heap 메모리'와 'Non-Heap 메모리'로 구분할 수 있습니다.
Heap 메모리
클래스 인스턴스, 배열이 이 메모리에 쌓이게 됩니다. 이 메모리는 '공유(shared) 메모리'라고도 불리며 여러 스레드에서 공유하는 데이터들이 저장되는 메모리입니다.
Non-heap 메모리
이 메모리는 자바의 내부 처리를 위해서 필요한 영역입니다. 여기서 주된 영역이 바로 메서드 영역입니다.
- 메서드 영역 (Method Area) : 메서드 영역은 모든 JVM 스레드에서 공유합니다.
- 런타임 상수 풀 (Runtime Constant Pool) : 자바의 클래스 파일에는 `contant_pool`이라는 정보가 포함되어 있습니다. 이 `contant-pool`에 대한 정보를 실행 시에 참조하기 위한 영역입니다. 실행 시간에 변경되지 않는 상수와 심볼릭 참조를 저장합니다. 예를 들어, 문자열 리터럴, 상수 값 등이 포함됩니다.
- JVM 스택 (Stack) : 스레드가 시작할 때 JVM 스택이 생성됩니다. 이 스택에는 메서드가 호출되는 정보인 프레임(frame)이 저장됩니다. 그리고 지역 변수와 임시 결과, 메서드 수행과 리턴에 관련된 정보들도 포함됩니다.
- 네이티브 메서드 스택 (Native Method Stack) : 자바 코드가 아닌 다른 언어로 된(보통은 C로 된) 코드들이 실행하게 될 때 스택 정보를 관리합니다.
- PC 레지스터 (Program Counter Register) : 자바의 스레드들은 각자의 PC(Program Counter) 레지스터를 갖습니다. 네이티브한 코드를 제외한 모든 자바 코드들이 수행될 때 JVM의 인스트럭션 주소를 PC 레지스터에 보관합니다.
참고로 스택의 크기는 고정하거나 가변적일 수 있습니다. 만약 연산을 하다가 JVM의 스택 크기를 최대치를 넘어섰을 경우에는 `StackOverflowError`가 발생합니다. 그리고 가변적일 경우 스택의 크기를 늘리려고 할 때 메모리가 부족하거나, 스레드를 생성할 때 메모리가 부족한 경우에는 `OutOfMemoryError`가 발생합니다.
여기서 Heap 영역과 메서드 영역은 JVM이 시작될 때 생성되는데요. 자바의 GC와 연관된 부분은 힙이므로 힙 영역에 대해서만 중점적으로 살펴보겠습니다.
GC의 원리
GC 작업을 하는 가비지 콜렉터(Garbage Collector)는 다음의 역할을 합니다.
- 메모리 할당
- 사용 중인 메모리 인식
- 사용하지 않는 메모리 인식
사용하지 않는 메모리를 인식하는 작업을 수행하지 않으면, 할당한 메모리 영역이 꽉차서 JVM에 행(Hang)이 걸리거나, 더 많은 메모리를 할당하려는 현상이 발생하게 됩니다. 만약 JVM의 최대 메모리 크기를 지정해서 전부 사용한 다음, GC를 해도 더 이상 사용 가능한 메모리 영역이 없는데 계속 메모리를 할당하려고 하면 `OutOfMemoryError`가 발생하여 JVM이 다운될 수도 있습니다. 이 경우 GC 그래프는 다음과 같습니다.
행(Hang)이란 서버가 요청을 처리 못하고 있는 상태를 의미합니다. 이 단어를 영어 사전에서 찾아보면 다음과 같이 나와있습니다.
hang 《美속어》(컴퓨터가) 정체(停滯)하다. 움직이지 않게 되다.
자바의 Heap 영역
JVM의 메모리는 여러 영역으로 나뉘지만 GC와 연관된 부분은 힙(Heap) 영역이기 때문에 가비지 콜렉터가 인식하고 할당하는 자바의 힙 영역에 대해서 상세히 알아보겠습니다.
위 그림을 보면 크게 Young, Old, Perm 세 영역으로 나뉘는 것을 알 수 있습니다. 이 중 Perm(Permanent) 영역은 JDK 8부터는 제거되었으며, Metaspace라는 영역이 Native Memory에 추가되었습니다. Virutal이라고 쓰여 있는 부분 또한 가상 영역이므로 제외한다면 Young 영역과 Old 영역 일부가 남게 됩니다. Young 영역은 다시 Eden 영역 및 두 개의 Survivor 영역으로 나뉘므로 우리가 고려해야 할 자바의 메모리 영역은 총 4개 영역으로 나뉜다고 볼 수 있습니다.
일단 메모리에 객체가 생성되면, 아래 그림의 가장 왼쪽인 Eden 영역에 객체가 지정됩니다.
Eden 영역에 데이터가 꽉 차면 Minor GC가 발생합니다. 즉, 이 영역에 있던 객체가 어디론가 옮겨지거나 삭제되어야 합니다. 이때 옮겨 가는 위치가 Survivor 영역입니다. 위의 그림에서는 구분하기 위해서 1과 2로 나눈 것뿐이며, 두 개의 Survivor 영역 사이에 우선순위가 있는 것은 아닙니다. 이 두 개의 영역 중 한 영역은 반드시 비어 있어야 합니다. 그 비어 있는 영역에 Eden 영역에 있던 객체 중 GC 후에 살아남아 있는 객체들이 이동하게 됩니다.
혹은 다음과 같이 할당됩니다.
이와 같이 Eden 영역에 있던 객체는 Survivor 영역의 둘 중 하나에 할당됩니다. 할당된 Survivor 영역이 차면, GC가 되면서 Eden 영역에 있는 객체와 꽉 찬 Survivor 영역에 있는 객체가 비어 있는 Survivor 영역으로 이동합니다. 이러한 작업을 반복하면서, Survivor 1과 2를 오가며 일정 횟수의 GC 사이클 동안 살아남은 객체들은 Old 영역으로 이동합니다.
그리고, Young 영역에서 Old 영역으로 넘어가는 객체 중 Survivor 영역을 거치지 않고 바로 Old 영역으로 이동하는 객체가 있을 수 있습니다. 객체의 크기가 아주 큰 경우인데, 예를 들어 Survivor 영역의 크기가 16MB인데 20MB를 점유하는 객체가 Eden 영역에서 생성되면 Survivor 영역으로 옮겨갈 수가 없기 때문에 이런 객체들은 바로 Old 영역으로 이동하게 됩니다.
GC의 종류
GC는 크게 Minor GC와 Major GC 두 가지 타입으로 나뉩니다.
- Minor GC: Young 영역에서 발생하는 GC로, Eden 및 Survivor 영역의 가비지 객체를 정리합니다.
- Major GC: Old 영역에서 발생하는 GC로, Old 영역의 가비지 객체를 정리합니다.
이 두 가지가 GC가 어떻게 상호 작용하느냐에 따라서 GC 방식에 차이가 나며, 성능에도 영향을 주게 됩니다.
GC가 발생하거나 객체가 각 영역에서 다른 영역으로 이동할 때 애플리케이션의 병목이 발생하면서 성능에 영향을 주게 됩니다. 그래서 HotSpot VM에서는 스레드 로컬 할당 버퍼(TLABs: Thread-Local Allocation Buffers)라는 것을 사용합니다.
스레드 로컬 할당 버퍼(TLABs, Thread-Local Allocation Buffers)는 JVM이 각 스레드별로 독립적인 메모리 할당 공간을 제공하여, 객체 할당 시 전역 힙 영역에 대한 동기화 오버헤드를 줄이고 할당 속도를 향상시키는 메커니즘입니다. 이를 통해 GC가 발생하거나 객체가 영역 간에 이동할 때 애플리케이션의 병목 현상을 최소화할 수 있습니다.
다양한 GC 방식
JVM은 다양한 GC 알고리즘을 제공합니다. 각 알고리즘은 애플리케이션의 특성에 따라 다른 성능 특성을 지닙니다.
- Serial Collector (시리얼 컬렉터): 단일 스레드로 GC 작업을 수행합니다. 작은 애플리케이션에 적합합니다.
- Parallel Collector (병렬 컬렉터): 여러 스레드를 사용하여 GC 작업을 병렬로 수행합니다.
- CMS (Concurrent Mark-Sweep) Collector: 애플리케이션 스레드와 GC 스레드가 동시에 실행되어 멈춤 시간을 최소화합니다. JDK 9부터는 deprecated 되었고, JDK 14에서 완전히 제거되었습니다.
- G1 (Garbage First) Collector: 대용량 힙 메모리를 가진 멀티코어 시스템에서 효율적입니다.
- ZGC (Z Garbage Collector): JDK 11부터 도입된 GC로, 매우 짧은 GC 중단 시간을 목표로 합니다.
- Shenandoah GC: JDK 12부터 추가된 GC로, 낮은 지연 시간을 목표로 합니다.
명시된 GC 방식은 WAS나 자바 애플리케이션 수행 시 옵션을 지정하여 선택할 수 있으며, 이번에는 G1 GC에 대해서만 집중적으로 정리하고자 합니다.
G1 GC
G1 GC는 Eden과 Survivor 영역으로 나뉘는 Young 영역과 Old 영역으로 구성되어 있는 이전 GC들과는 다른 구조로 구성되어 있습니다. 먼저 G1 콜렉터가 어떻게 구성되어 있는지 확인해 보겠습니다.
G1은 그림과 같이 바둑판 모양의 구조를 가지고 있으며, 각 사각형을 리전(region)이라고 합니다. 리전의 크기는 힙(heap) 크기에 따라 자동으로 결정되며, 기본적으로 약 2048개의 리전을 갖도록 설정됩니다. 리전 크기는 1MB에서 32MB 사이의 2의 거듭제곱 크기로 설정되며, Young 영역과 Old 영역이 물리적으로 분리되어 있지 않고, 모든 리전의 크기가 동일합니다. 이러한 리전들은 Eden, Survivor, Old 영역의 역할을 순차적으로 변경하며 작업을 수행하고, Humongous 영역도 포함됩니다. G1은 힙 메모리의 어느 곳이든 회수 대상에 포함할 수 있는데요. 이를 회수 집합(collection set, Cset)이라고 합니다. 어느 세대에 속하느냐가 아니라 '어느 영역에 쓰레기가 가장 많으냐'와 '회수했을 때 이득이 어디가 가장 크냐'가 회수 영역을 고르는 기준으로 이것이 G1의 혼합 GC 모드입니다.
G1의 Young GC 동작 방식은 다음과 같습니다.
- Young 리전 할당: 몇 개의 리전을 선정하여 Young 영역으로 지정합니다.
- 객체 생성: 객체가 Young 영역으로 할당된 리전에 생성됩니다.
- Young 리전 가득 참: Young 영역으로 지정된 리전에 데이터가 꽉 차면 GC를 수행합니다.
- Survivor 리전 이동: 살아있는 객체들만 Survivor 리전으로 이동시킵니다.
이렇게 살아남은 객체들이 이동된 구역은 새로운 Survivor 영역이 됩니다. 그다음에 Young GC가 발생하면 Survivor 영역에 계속 쌓습니다. 그러면서 몇 번의 aging 작업을 통해서 (Survivor 영역에 있는 객체가 몇 번의 Young GC 후에도 살아 있으면), Old 영역으로 승격됩니다.
G1 GC는 Old 영역에서의 가비지 수집을 위해 CMS GC와 비슷한 방식으로 동작하지만, 그 구조와 단계에 있어서 차이점이 있습니다. 이제 G1 GC가 어떻게 동작하는지 단계별로 살펴보겠습니다. 여기서 STW라고 표시된 단계에서는 모두 Stop the world가 발생합니다.
1. 최초 표시 (Initial Mark) 단계 (STW)
최초 표시 단계에서는 힙에서 직접 참조 가능한 객체의 시작점을 의미하는 GC 루트가 직접 참조하는 객체들을 표시하고, TAMS(Top-at-Mark-Start) 포인터를 통해 객체가 할당되는 위치를 추적하여 효율적인 가비지 수집을 돕습니다. TAMS 포인터는 마크 시작 시점에서 객체 할당의 최상위 위치를 기록하여, GC가 진행되는 동안 새로운 객체가 어디에 할당되는지를 관리합니다.
이 과정에서 쓰기 배리어(Write Barrier)가 작동하여 객체 필드의 변경 사항을 감지하고, 필요한 메타데이터를 갱신합니다. 쓰기 배리어는 늙은 객체가 젊은 객체를 참조하게 되는 경우, 해당 객체가 속한 카드 테이블(Card Tables) 엔트리를 더럽게 표시(dirty)함으로써 G1 GC가 리멤버드 셋(Remembered Sets, RSet)을 효율적으로 관리할 수 있도록 지원합니다. 이러한 쓰기 배리어는 실행 엔진에 포함된 작은 코드 조각으로 구현되어, 객체 참조의 변경을 실시간으로 추적합니다.
또한, 스레드 로컬 할당 버퍼(TLABs, Thread-Local Allocation Buffers)를 통해 새로운 객체가 올바른 리전에 할당될 수 있도록 조정합니다. 이로 인해 사용자 스레드는 잠시 일시 정지(STW)되지만, 소요 시간이 매우 짧아 전체 GC 성능에 미치는 영향이 최소화됩니다. STW 단계는 매우 짧은 시간 동안 발생하며, G1 GC의 주요 설계 목표인 짧고 예측 가능한 일시 정지 시간을 보장합니다.
2. 동시 루트 영역 스캔 (Concurrent Root Region Scanning)
이 단계에서는 Survivor 영역을 스캔하여 Old 영역의 객체를 참조하는 레퍼런스를 찾습니다. 리멤버드 셋(Remembered Sets, RSet)은 각 리전에 하나씩 존재하며, 다른 리전에서 해당 리전을 참조하는 포인터를 관리합니다. RSet은 외부에서 힙 영역 내부를 참조하는 레퍼런스를 효율적으로 관리하기 위한 장치로, 특정 리전을 수집할 때 관련된 외부 참조를 빠르게 찾을 수 있게 합니다.
또한, 카드 테이블(Card Tables)이 함께 활용되어 객체의 필드가 변경될 때 해당 카드를 더럽게 표시(dirty)함으로써 필요한 부분만 스캔하도록 돕습니다. 카드 테이블은 객체의 변경 사항을 추적하여 가비지 컬렉션(GC)이 필요한 부분만 효율적으로 스캔할 수 있도록 지원합니다. RSet은 리전 간의 참조를 추적하는 반면, 카드 테이블은 힙 내 객체의 변경을 추적하여 GC의 성능을 향상시킵니다. 이러한 메커니즘들은 부유 가비지(Floating Garbage)를 최소화하는 데 중요한 역할을 합니다.
부유 가비지란 현재 수집 세트 외부에서 죽은 객체가 참조함으로 인해 이미 죽어야 할 객체가 계속 살아 있는 현상을 의미합니다. 즉, 전역 마킹에서는 죽은 객체처럼 보이지만, 루트 세트에 따라 제한적인 범위에서 로컬 마킹 시에는 살아 있는 객체로 잘못 인식되는 경우를 말합니다.
3. 동시 표시 (Concurrent Marking)
동시 표시 단계에서는 GC 루트로부터 시작하여 객체들의 도달 가능성을 분석합니다. 전체 힙의 객체 그래프를 재귀적으로 스캔하면서 살아있는 객체들을 표시하는 과정입니다. 이 단계에서는 SATB(Snapshot-At-The-Beginning) 알고리즘을 사용하여 GC 시작 시점의 힙 상태를 기준으로 살아있는 객체를 추적하며, 동시 마킹 중에도 객체 손실을 방지합니다. SATB 알고리즘은 마킹 시작 시점의 힙 상태를 캡처하고, 이후 변경된 객체들을 SATB 버퍼에 기록하여 동시 마킹 동안 변경된 객체들도 정확히 추적하여 마킹을 보완합니다. 또한, 쓰기 배리어와 SATB 버퍼가 동기화 메커니즘을 사용하여 객체의 상태 변경을 정확히 반영함으로써 race condition을 방지합니다. 이 단계는 시간이 다소 걸릴 수 있지만, 사용자 스레드와 동시에 수행되기 때문에 애플리케이션의 실행에 큰 지장을 주지 않습니다.
또한, 동시 마킹 중에 Young GC가 발생하면 해당 작업이 잠시 멈추고 Young GC를 우선 처리합니다. 이를 통해 애플리케이션의 성능 저하를 최소화할 수 있습니다.
4. 재표시 (Remark) 단계 (STW)
동시 마킹 동안 놓친 객체들을 처리하기 위해 사용자 스레드를 잠시 멈추고 재마킹을 수행합니다. 이때 RSet과 카드 테이블을 활용하여 시작 단계 이후에 변경된 객체들을 정확하게 다시 확인합니다. 약한 참조(Weak Reference)와 소프트 참조(Soft Reference)도 이 단계에서 처리되며, SATB 방식을 통해 정확한 가비지 수집을 보장합니다. RSet과 카드 테이블은 이 과정에서 부유 가비지를 효과적으로 처리하여, 가비지가 누락되지 않도록 합니다. 처리해야 할 객체의 수가 적어 이 단계도 매우 빠르게 완료됩니다.
5. 정리 (Cleanup) 단계
살아있는 객체와 사용되지 않는 리전을 식별하는 이 단계에서는 RSet과 카드 테이블을 사용하여 힙 영역 간의 객체 참조를 효율적으로 추적합니다. 어카운팅(accounting) 작업을 통해 마킹 결과를 기반으로 사용되지 않는 리전을 식별하고, RSet 스크러빙(scrubbing)을 통해 불필요한 참조 정보를 정리하여 메모리 효율을 높입니다. 이 과정에서 RSet과 카드 테이블은 부유 가비지를 최소화하는 데 기여하며, 사용되지 않는 리전을 정확히 식별할 수 있도록 도와줍니다. 또한, Survivor 공간으로 방출된 객체들은 서바이버 공간 (Survivor Space)에서 관리되며, 필요 시 테뉴어드 영역 (Tenured Generation)으로 승격(promote)됩니다. 이 과정은 주로 동시적으로 수행되며, 일부 STW 구간에서 불필요한 참조를 정리합니다.
6. 복사 및 이주 (Evacuation) 단계 (STW)
복사 및 이주 단계에서는 리전 크기와 RSet 정보를 기반으로 회수 가치(reclaim value)와 비용(reclaim cost)에 따라 리전을 정렬합니다. 목표로 한 일시 정지 시간에 맞춰 회수 계획을 세우고, 회수 대상이 된 리전들에서 살아남은 객체들을 빈 리전에 이주시킵니다. 이 단계에서는 다음과 같은 추가 요소들이 포함됩니다:
6.1 혼합 수집 (Mixed GC)
G1 GC는 Young GC뿐만 아니라 Mixed GC도 수행합니다. Mixed GC는 Young 영역뿐만 아니라 일부 Old 영역을 동시에 수집하여 Old 영역의 메모리를 점진적으로 회수합니다. 이는 전체 힙의 가비지 수집 효율을 높이고, Old 영역의 단편화를 줄이는 데 중요한 역할을 합니다. Mixed GC는 Young GC 이후나 특정 조건 하에서 수행되며, 회수 가치(reclaim value)와 회수 비용(reclaim cost)을 고려하여 수집할 리전을 선택합니다.
6.2 리전 선택 정책
G1 GC는 목표 일시 정지 시간(pause time goal)에 맞춰 수집할 리전을 선택합니다. 이 과정에서 회수 가치와 회수 비용을 고려하여 최적의 리전을 선택합니다. 회수 가치는 리전 내에서 회수 가능한 메모리의 양을 의미하며, 회수 비용은 해당 리전을 회수하는 데 필요한 시간과 자원을 의미합니다. G1은 이러한 요소들을 종합적으로 평가하여 회수 가치가 높은 리전을 우선적으로 선택함으로써 효율적인 메모리 관리를 실현합니다.
6.3 Humongous 객체 처리
Humongous 객체는 일반적으로 크기가 리전 크기의 50%를 초과할 때 발생하며, 단일 리전에 할당될 수 없기 때문에 여러 연속된 리전에 걸쳐 할당됩니다. 이러한 객체들은 Humongous 영역에서 관리되며, 이동 시 특별한 처리가 필요합니다. Humongous 객체가 이동되면 연속된 리전 전체가 이동되므로, RSet과 카드 테이블을 통해 객체 이동 후의 참조 상태를 정확히 반영하여 부유 가비지가 발생하지 않도록 합니다. 또한, Humongous 객체는 회수 가치가 높기 때문에 필요 시 우선적으로 수집 대상이 됩니다. 이 과정에서도 쓰기 배리어와 RSet이 객체 이동을 추적하고 관리합니다. 여러 개의 GC 스레드가 병렬로 처리하여 중단 시간을 최소화합니다.
Stop-The-World의 필요성
G1 GC를 포함한 대부분의 가비지 컬렉터에서 Stop-The-World(STW)가 필요합니다. 왜 반드시 STW가 필요할까요?
모든 애플리케이션 스레드가 GC가 끝날 때까지 대기하고 이후 다시 작동하는 방식으로 STW를 피할 수는 없을까요?
STW가 필요한 주된 이유는 다음 두 가지입니다:
- 객체 그래프의 일관성(Consistency) 보장:
- GC가 객체 간의 참조를 추적하는 동안 애플리케이션 스레드가 새로운 참조를 생성하거나 기존 참조를 수정하면, GC가 파악한 객체 그래프가 실제 상태와 불일치하게 됩니다. 이는 살아있는 객체를 실수로 수집하거나(premature collection), 가비지 객체를 남기는(memory leak) 심각한 문제를 초래할 수 있습니다. 특히 멀티스레드 환경에서는 여러 스레드가 동시에 객체를 수정할 수 있어, 정확한 객체 상태 파악이 더욱 어려워집니다.
- 객체 재배치 시 안전성 보장:
- GC가 객체를 재배치(evacuation)하는 동안 애플리케이션 스레드가 해당 객체에 접근하려 하면, 잘못된 메모리 주소를 참조하게 됩니다. 이는 애플리케이션의 크래시나 데이터 손상으로 이어질 수 있습니다. 따라서 객체 재배치 작업 동안은 모든 애플리케이션 스레드의 실행을 일시 중지하여 메모리 상태의 안전성을 보장해야 합니다.
이러한 이유로 G1 GC는 중요한 단계(Initial Mark, Remark, Evacuation)에서 STW를 사용하여 메모리의 안정적인 상태를 보장합니다. 다만 G1 GC는 이러한 STW 구간을 최소화하고, 가능한 많은 작업을 동시적(Concurrent)으로 처리하여 애플리케이션의 응답성을 향상시키도록 설계되었습니다.
G1 튜닝
엔드 유저가 최대 힙 크기와 최대 GC 중단 시간을 간단히 설정하면 나머지는 수집기가 알아서 처리하게 하는 것이 G1 튜닝의 최종 목표입니다.
G1 수집기는 힙 공간이 부족해지는 상황을 방지하기 위해 지속적으로 압축과 영역 회수를 수행하므로, CMS에서 발생하는 CMF(Concurrent Mode Failure)가 발생할 가능성이 매우 낮습니다. 어떤 애플리케이션에서 할당률이 계속 높은 상태로 대부분 단명 객체가 생성되고 있다면 다음 튜닝을 고려해 볼 수 있습니다.
- 영 세대를 크게 설정한다.
- 애플리케이션에서 수용 가능한 최장 중단 시간 목표를 정한다.
이와 같이 에덴 및 서바이버 영역을 구성하면 단명 객체가 Old 영역으로 승격될 가능성이 현저히 줄어듭니다. 그 결과 Old 영역의 크기가 감소하고, Old 영역을 정리하는 작업도 줄어듭니다.
다만, 영 세대의 크기를 늘릴 때에는 중단 시간(Pause Time)이 증가할 수 있으므로, 애플리케이션의 응답성 요구사항을 고려해야 합니다. G1 GC는 `-XX:MaxGCPauseMillis` 옵션을 통해 설정된 중단 시간 목표를 달성하기 위해 영 세대의 크기를 자동으로 조절합니다. 따라서 수동으로 영 세대의 크기를 크게 설정하면 중단 시간 목표를 초과할 수 있으므로 주의가 필요합니다.
Tenuring Threshold를 최대 15로 늘리면 객체가 Survivor 영역에 더 오래 머물게 되어, 단명 객체가 Old 영역으로 승격되는 것을 줄일 수 있습니다. 다만 G1 GC에서는 Tenuring Threshold가 자동으로 조정되므로, 수동으로 최대값을 설정해도 효과가 제한적일 수 있습니다. 따라서 필요하다면 다른 튜닝 옵션을 고려하는 것이 좋습니다.
참고
- 책 《자바 성능 튜닝 이야기》
- 책 《Optimizing Java 자바 최적화》
- 책 《JVM 밑바닥까지 파헤치기》
'🎨 Language' 카테고리의 다른 글
HotSpot VM과 JIT 컴파일러🔥 (1) | 2024.11.16 |
---|---|
JVM과 Class가 JVM에 로딩되는 과정 🧩 (0) | 2024.11.03 |