Java中的垃圾收集器:G1、ZGC及选择方法

1. 概述

垃圾收集器(GC)决定了你的Java应用停止多久以及花费多少CPU来清理内存。如果选错了,即使代码没变,你也可能遭遇延迟激增。

本文解释了G1和ZGC在Java 21中的工作原理、如何启用和读取GC日志、如何通过代码和测试检查GC的运行状态。最后介绍了为实际应用选择GC的标准。

2. Java 21中可用的GC

HotSpot JDK 21包含以下主要GC:

GC Flag 目标
Serial -XX:+UseSerialGC 小堆、单CPU、低内存占用
Parallel -XX:+UseParallelGC 最大吞吐量、接受长pause
G1 -XX:+UseG1GC 吞吐量和pause之间平衡
ZGC -XX:+UseZGC 超短pause、大堆
Shenandoah -XX:+UseShenandoahGC 短pause(仅在某些构建版本中可用)

如果你不传递任何标志,JVM会自动选择。在“server-class”机器上(至少2个CPU和约1792 MB RAM),JVM使用G1。否则,JVM使用Serial。

注意: 在容器中,JVM读取cgroup的CPU和内存限制。限制为1个CPU的Pod会被JVM认为不是server-class,从而默默运行Serial GC。这是Kubernetes上服务出现异常长pause的常见原因。

3. G1如何工作

G1(Garbage-First)将堆分割成多个相等的区域(region),通常从1 MB到32 MB。每个区域扮演一个角色:Eden、Survivor、Old或Humongous(用于大对象)。

基本周期包括:

  • Young GC:停止所有应用线程(stop-the-world),将存活对象从Eden复制到Survivor或Old。
  • Concurrent marking:当Old区域填满到某个阈值时,G1与应用并发地标记存活对象。
  • Mixed GC:清理young和含有最多垃圾的old区域。这就是“Garbage-First”的含义。

G1的强大之处在于你可以设置pause目标:

java -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xms2g -Xmx2g -jar order-service.jar

MaxGCPauseMillis=100要求G1尽量将每次pause保持在100 ms以下(默认为200 ms)。G1会调整young generation的大小以达到这个目标。这是一个软目标,而不是承诺。G1的pause仍会随着需要复制的存活对象数量增加而增加。

4. ZGC如何工作

ZGC将几乎所有繁重的工作(标记、重新定位、更新引用)与应用并发进行。它使用着色指针和加载屏障:每当代码读取一个引用时,屏障检查对象是否已移动,如需要则修复引用。

结果是pause通常低于1 ms,且不会随着堆大小增加而增加。2 GB或2 TB的堆都会产生相当的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是有代价的:

  • 加载屏障会减慢每次引用读取,因此吞吐量通常比G1低几个百分点。
  • ZGC需要headroom,即堆的可用部分。应用在并发GC运行时仍在分配。如果在GC完成之前堆填满了,线程会被阻止(“Allocation Stall”)。此时延迟激增,完全违背了初衷。
  • 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运行并通过统一日志(-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前后的堆大小(25M->3M)、已提交的总堆大小(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>

然后用AssertJ编写JUnit 5测试:

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将最大堆设置为容器内存的75%。其余部分用于metaspace、线程栈和直接缓冲区。始终明确指定-XX:+UseG1GC或-XX:+UseZGC而不是让JVM自己选择,原因如第2节所述。

如果已有Spring Boot Actuator和Micrometer,jvm.gc.pause指标给你每种GC类型的pause时间分布。改变GC前后,跟踪此指标的p99和max。

8. 如何选择GC

从应用需求开始,而不是从“最新的”GC开始:

选择G1当:

  • 你还没有具体的理由改变。G1是大多数REST API和服务的合理默认选择。
  • 堆从几百MB到几十GB。
  • 几十到200 ms的pause是可以接受的。
  • CPU被严格限制,你需要很好的吞吐量。

选择ZGC(generational)当:

  • p99或p999延迟要求非常严格,例如交易、游戏服务器、SLA低于50 ms的API。
  • 大堆(几十GB或更多),当G1的pause开始因为许多存活对象而变长时。
  • 你有多余的CPU和内存来做headroom。

选择Parallel当: 运行批处理作业、ETL、离线数据处理。总时间比pause更重要。

选择Serial当: 运行小型CLI、无服务函数,堆约100 MB以下,1个CPU。

注意: 不要在小容器(1个CPU、堆512 MB)上使用ZGC。GC线程将与应用争夺CPU,堆很少有headroom,容易导致Allocation Stall。结果可能比G1还糟。

调优时的一些常见错误:

  • 将大量旧标志如-XX:NewRatio或-XX:SurvivorRatio复制到G1。这些标志会妨碍自调节机制来实现MaxGCPauseMillis。你应该只从-Xmx和pause目标开始。
  • 为G1设置MaxGCPauseMillis太低,例如10 ms。G1会缩小young generation,GC运行频繁,吞吐量下降明显。如果需要这样的pause,切换到ZGC。
  • 改变GC来修复内存泄漏。没有GC能释放仍被引用的对象。先分析堆dump。
  • 用几秒的基准测试评估。用类似生产的负载测量至少几十分钟,并比较p99延迟、CPU、内存。

9. 结论

G1是平衡的默认选择,适合大多数应用。ZGC,特别是Java 21中的generational版本,以极大的堆提供亚毫秒级pause,代价是消耗更多CPU并需要更多headroom。

始终明确声明GC,启用-Xlog:gc并通过GarbageCollectorMXBean或测试确认GC正在运行。只有当p99延迟数据表明pause确实是个问题时,才改变GC。

javajvmgarbage-collectorg1zgc