Java의 Garbage Collector: G1, ZGC 그리고 선택 방법
1. 개요
Garbage Collector(GC)는 Java 애플리케이션이 얼마나 오래 정지되고 메모리를 정리하기 위해 얼마나 많은 CPU를 사용하는지를 결정합니다. 잘못 선택하면 코드를 변경하지 않았는데도 latency가 급증할 수 있습니다.
이 글에서는 Java 21에서 G1과 ZGC의 동작 방식, 활성화 및 GC 로그 읽기 방법, 코드와 테스트로 실행 중인 GC를 확인하는 방법을 설명합니다. 글 끝에서는 실제 애플리케이션에 맞는 GC를 선택하는 기준을 제시합니다.
2. Java 21에서 사용 가능한 GC들
HotSpot JDK 21의 주요 GC는 다음과 같습니다:
| GC | Flag | 용도 |
|---|---|---|
| Serial | -XX:+UseSerialGC |
작은 heap, 1 CPU, 낮은 footprint |
| Parallel | -XX:+UseParallelGC |
최대 throughput, 긴 pause 허용 |
| G1 | -XX:+UseG1GC |
throughput과 pause의 균형 |
| ZGC | -XX:+UseZGC |
극히 짧은 pause, 큰 heap |
| Shenandoah | -XX:+UseShenandoahGC |
짧은 pause (특정 빌드에서만 사용 가능) |
플래그를 전달하지 않으면 JVM이 자동으로 선택합니다. “server-class” 머신(최소 2개 CPU, 약 1792 MB RAM)에서는 JVM이 G1을 사용합니다. 그 외의 경우 JVM은 Serial을 사용합니다.
주의: 컨테이너에서 JVM은 cgroup의 CPU 및 메모리 제한을 읽습니다. CPU가 1로 제한된 Pod는 JVM에서 server-class로 간주되지 않아 조용히 Serial GC를 실행합니다. 이것이 Kubernetes에서 서비스의 pause가 비정상적으로 긴 일반적인 원인입니다.
3. G1은 어떻게 동작하는가
G1(Garbage-First)은 heap을 여러 개의 동일한 region으로 나눕니다. 보통 1 MB에서 32 MB입니다. 각 region은 역할을 수행합니다: Eden, Survivor, Old 또는 Humongous(큰 객체용).
기본 사이클은 다음과 같습니다:
- Young GC: 모든 애플리케이션 스레드를 중지하고(stop-the-world), Eden에서 Survivor 또는 Old로 살아있는 객체를 복사합니다.
- Concurrent marking: Old가 임계값에 도달하면, G1은 애플리케이션과 병렬로 살아있는 객체를 표시합니다.
- Mixed GC: young과 가장 많은 쓰레기가 있는 Old region을 정리합니다. 이것이 “Garbage-First”의 의미입니다.
G1의 강점은 pause 목표를 설정할 수 있다는 것입니다:
java -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xms2g -Xmx2g -jar order-service.jar
MaxGCPauseMillis=100은 각 pause를 100ms 이하로 유지하도록 G1에 요청합니다(기본값은 200ms). G1은 이 목표를 달성하기 위해 young generation의 크기를 조정합니다. 이것은 소프트 목표이지, 약속이 아닙니다. G1의 pause는 여전히 복사할 살아있는 객체의 수에 따라 증가합니다.
4. ZGC는 어떻게 동작하는가
ZGC는 마킹, 재배치, 참조 업데이트 같은 거의 모든 무거운 작업을 애플리케이션과 병렬로 수행합니다. colored pointer와 load barrier를 사용합니다: 코드가 참조를 읽을 때마다 barrier는 객체가 이미 이동했는지 확인하고 필요하면 참조를 수정합니다.
결과적으로 pause는 보통 1ms 이하이며 heap 크기에 따라 증가하지 않습니다. 2GB 또는 2TB의 heap이 비슷한 pause를 제공합니다.
4.1. Java 21의 Generational ZGC
Java 21은 Generational ZGC(JEP 439)를 소개했습니다. 이 버전은 young과 old generation을 분리하여 단기 객체를 훨씬 더 효율적으로 정리합니다. Java 21에서는 추가 플래그를 활성화해야 합니다:
java -XX:+UseZGC -XX:+ZGenerational -Xmx4g -jar order-service.jar
-XX:+ZGenerational이 없으면 Java 21은 구형 ZGC(non-generational)를 실행합니다. JDK 23부터 generational이 기본값입니다. JDK 24부터 non-generational 모드는 제거되었습니다. 따라서 Java 21에서 ZGC를 사용한다면 generational을 지금 바로 활성화해야 합니다.
4.2. 대가
짧은 pause에는 대가가 있습니다:
- Load barrier는 각 참조 읽기를 느리게 하므로 throughput은 보통 G1보다 몇 퍼센트 낮습니다.
- ZGC는 headroom, 즉 비워진 heap의 일부가 필요합니다. 애플리케이션은 GC가 병렬로 실행되는 동안에도 할당합니다. GC가 완료되기 전에 heap이 가득 차면 스레드가 차단됩니다(“Allocation Stall”). 이 경우 latency가 급증하여 원래의 목표와 정반대입니다.
- GC 스레드가 병렬로 실행되므로 애플리케이션과 CPU를 놓고 경합합니다.
5. 작은 프로그램으로 실험하기
아래 프로그램은 많은 단기 Order 객체를 생성하고 일부를 캐시처럼 유지합니다:
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class OrderLoadSimulator {
record Order(String id, String customer, byte[] payload) {}
public static void main(String[] args) {
List<Order> cache = new ArrayList<>();
long start = System.currentTimeMillis();
for (int i = 0; i < 5_000_000; i++) {
Order order = new Order(UUID.randomUUID().toString(), "customer-" + (i % 1000), new byte[512]);
if (i % 100 == 0) {
cache.add(order); // 1% 유지 -> old generation으로 이동
}
if (cache.size() > 20_000) {
cache.subList(0, 10_000).clear(); // 부분 해제, old에서 쓰레기 생성
}
}
System.out.printf("Done in %d ms, cache=%d%n",
System.currentTimeMillis() - start, cache.size());
}
}
각 루프 반복은 512바이트 페이로드를 가진 Order 객체를 생성합니다. 대부분의 객체는 즉시 소멸되어 “대부분의 객체는 어린 나이에 죽는다”는 가정을 따릅니다. 약 1%가 cache에 충분히 오래 유지되어 old generation으로 올라갑니다. subList(...).clear() 명령은 때때로 그들을 해제합니다.
G1에서 unified logging(-Xlog)을 사용하여 GC 로그를 활성화합니다:
java -XX:+UseG1GC -Xmx512m -Xlog:gc OrderLoadSimulator.java
출력은 다음과 같을 것입니다:
[0.008s][info][gc] Using G1
[0.312s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 25M->3M(512M) 2.814ms
[0.401s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 28M->4M(512M) 1.957ms
...
Done in 3120 ms, cache=15000
각 Pause Young 줄은 GC 전후의 heap(25M->3M), 커밋된 전체 heap(512M) 및 정지 시간(2.814ms)을 표시합니다.
이제 Generational ZGC를 사용하여 실행합니다:
java -XX:+UseZGC -XX:+ZGenerational -Xmx512m -Xlog:gc OrderLoadSimulator.java
[0.010s][info][gc] Using The Z Garbage Collector
[0.420s][info][gc] GC(0) Minor Collection (Allocation Rate) 60M(12%)->14M(3%) 0.021s
[0.610s][info][gc] GC(1) Minor Collection (Allocation Rate) 72M(14%)->16M(3%) 0.018s
...
Done in 3350 ms, cache=15000
각 ZGC 줄 끝의 시간(0.021s)은 전체 GC 사이클의 시간이며, 대부분 애플리케이션과 병렬로 실행됩니다. 이는 애플리케이션이 정지된 시간이 아닙니다. 실제 pause를 보려면 -Xlog:gc,gc+phases=debug를 사용하고 Pause Mark Start, Pause Mark End, Pause Relocate Start 줄을 찾습니다. 이러한 pause는 보통 수십 마이크로초입니다.
구체적인 수치는 당신의 머신에서 다를 것입니다. 일반적인 추세는 ZGC가 총 시간 측면에서 약간 느리지만 긴 pause가 없다는 것입니다.
6. 코드로 실행 중인 GC 확인하기
프로덕션 환경에서는 설정을 신뢰하기보다는 실제 GC를 확인해야 합니다. GarbageCollectorMXBean이 이를 알려줍니다:
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.List;
public class GcInspector {
public static List<String> activeCollectors() {
return ManagementFactory.getGarbageCollectorMXBeans().stream()
.map(GarbageCollectorMXBean::getName)
.toList();
}
public static void main(String[] args) {
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
System.out.printf("%s: count=%d, time=%d ms%n",
gc.getName(), gc.getCollectionCount(), gc.getCollectionTime());
}
}
}
각 GC는 특징적인 이름으로 하나 이상의 MXBean을 등록합니다. 예를 들어 G1의 경우:
G1 Young Generation: count=0, time=0 ms
G1 Concurrent GC: count=0, time=0 ms
G1 Old Generation: count=0, time=0 ms
Generational ZGC의 경우 이름은 ZGC Minor Cycles, ZGC Minor Pauses, ZGC Major Cycles 그리고 ZGC Major Pauses입니다.
6.1. GC 설정을 확인하는 테스트 작성
테스트로 GC 설정을 잠글 수 있어서 누군가 실수로 JVM 옵션을 변경하지 않도록 할 수 있습니다. 먼저 Surefire를 구성하여 ZGC로 테스트를 실행하도록 합니다:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<argLine>-XX:+UseZGC -XX:+ZGenerational</argLine>
</configuration>
</plugin>
그 다음 JUnit 5와 AssertJ를 결합한 테스트를 작성합니다:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class GcInspectorTest {
@Test
void whenRunningWithZgcFlag_thenZgcCollectorsAreActive() {
var collectors = GcInspector.activeCollectors();
assertThat(collectors)
.isNotEmpty()
.allMatch(name -> name.startsWith("ZGC"));
}
@Test
void whenGenerationalZgc_thenMinorCyclesBeanExists() {
assertThat(GcInspector.activeCollectors())
.contains("ZGC Minor Cycles");
}
}
첫 번째 테스트는 모든 collector가 ZGC에 속하는지 확인합니다. 두 번째 테스트는 generational 모드가 활성화되었는지 확인합니다. 누군가 -XX:+ZGenerational을 제거하면 bean의 이름이 ZGC Cycles로 변경되고 테스트가 실패합니다.
주의: 이 테스트는 테스트 프로세스의 JVM 옵션에 따라 다릅니다. IDE에서 Surefire의
argLine을 읽지 않으면 IDE에서 직접 실행할 때 테스트가 실패합니다.
7. Spring Boot 애플리케이션을 위한 GC 구성
Spring Boot는 자동으로 GC를 선택하지 않습니다. 배포 방식에 따라 JVM에 플래그를 전달합니다. Maven 플러그인으로 로컬에서 실행할 때:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<jvmArguments>-XX:+UseZGC -XX:+ZGenerational -Xmx2g</jvmArguments>
</configuration>
</plugin>
컨테이너에서는 JAVA_TOOL_OPTIONS 환경 변수를 사용하는 것이 가장 간단합니다. JVM은 시작할 때 이 변수를 자동으로 읽습니다:
docker run -e JAVA_TOOL_OPTIONS="-XX:+UseG1GC -XX:MaxRAMPercentage=75 -Xlog:gc*:stdout:time" \
--cpus=2 --memory=2g payment-service:1.0
MaxRAMPercentage=75은 최대 heap을 컨테이너 메모리의 75%로 설정합니다. 나머지는 metaspace, 스레드 스택 및 direct buffer용입니다. 섹션 2에서 언급한 이유 때문에 JVM이 자동으로 선택하도록 하는 대신 항상 -XX:+UseG1GC 또는 -XX:+UseZGC를 명시합니다.
Spring Boot Actuator와 Micrometer가 있다면 jvm.gc.pause 메트릭은 각 GC 유형별 pause 시간의 분포를 제공합니다. GC를 변경하기 전후에 이 메트릭의 p99와 max를 모니터링하세요.
8. GC 선택 방법
최신 GC부터 시작하지 말고 애플리케이션의 요구사항에서 시작하세요:
G1을 선택할 때:
- 변경할 구체적인 이유가 없습니다. G1은 대부분의 REST API 및 서비스를 위한 합리적인 기본 선택입니다.
- Heap은 수백 MB에서 수십 GB입니다.
- 수십에서 200ms의 pause는 허용됩니다.
- CPU가 엄격하게 제한되어 있고 좋은 throughput이 필요합니다.
Generational ZGC를 선택할 때:
- latency p99 또는 p999 요구사항이 매우 엄격합니다. 예: 트레이딩, 게임 서버, 50ms 이하의 SLA가 있는 API.
- 큰 heap(수십 GB 이상). G1의 pause가 많은 살아있는 객체 때문에 길어지기 시작할 때입니다.
- headroom을 만들 여유가 있는 CPU와 메모리가 있습니다.
Parallel을 선택할 때: 배치 작업, ETL, 오프라인 데이터 처리를 실행합니다. 총 시간이 pause보다 중요합니다.
Serial을 선택할 때: 작은 CLI, serverless 함수를 실행합니다. heap은 약 100MB 이하, CPU는 1입니다.
주의: 작은 컨테이너(1 CPU, 512MB heap)에서 ZGC를 사용하지 마세요. GC 스레드가 애플리케이션과 CPU를 놓고 경합하고, 적은 headroom 때문에 Allocation Stall이 발생하기 쉽습니다. 결과가 G1보다 더 나쁠 수 있습니다.
tuning 시 일반적인 실수:
-XX:NewRatio또는-XX:SurvivorRatio같은 많은 구형 플래그를 G1에 복사합니다. 이 플래그들은MaxGCPauseMillis를 달성하기 위한 자동 조정 메커니즘을 방해합니다.-Xmx와 pause 목표만으로 시작해야 합니다.- G1에 대해
MaxGCPauseMillis를 너무 낮게 설정합니다. 예: 10ms. G1은 young generation을 축소하고 GC가 자주 실행되며 throughput이 급격히 감소합니다. 그 정도의 pause가 필요하면 ZGC로 전환하세요. - 메모리 누수를 수정하기 위해 GC를 변경합니다. 어떤 GC도 여전히 참조되는 객체를 해제할 수 없습니다. 먼저 heap dump를 분석하세요.
- 수 초간의 벤치마크로 평가합니다. 최소 수십 분 동안 프로덕션과 유사한 부하로 측정하고 p99 latency, CPU, 메모리를 비교하세요.
9. 결론
G1은 균형 잡힌 기본 선택으로 대부분의 애플리케이션에 적합합니다. ZGC, 특히 Java 21의 generational 버전은 매우 큰 heap에서 밀리초 이하의 pause를 제공하지만 추가 CPU를 소비하고 더 많은 headroom이 필요합니다.
항상 GC를 명시적으로 선언하고, -Xlog:gc를 활성화하고, GarbageCollectorMXBean 또는 테스트로 실행 중인 GC를 확인합니다. p99 latency 데이터가 pause가 실제로 문제임을 보여줄 때만 GC를 변경하세요.