《Java 虚拟机原理与性能调优》5.3 收集器谱系:Serial、Parallel、G1、ZGC 与 Shenandoah

五种收集器在同一台 M1 Pro、同一份负载下的真实对照:Serial 与 Parallel 的日志形状、G1 的 region、ZGC 的 0.053ms 最大停顿和 Shenandoah 的 256K region,附选型表与可复现命令。

本节目标:知道五种收集器各自优化什么、付出什么,能用一条命令确认当前进程用的是哪个,并按堆大小和停顿目标做选型。
适用版本:JDK 25(Temurin 25.0.5)。分代 ZGC 自 JDK 23 起为默认形态;-XX:+ZGenerational 在 JDK 24 已移除。

承接的问题

libraryd 的部署形态有两套:

  • 在线服务:容器 2 核 4 GB,-Xms2g -Xmx2g,要求 P99 在 200 ms 内;
  • 夜间批处理:16 核 32 GB,-Xms8g -Xmx8g,吞吐优先,允许秒级停顿。

同一套 java 启动参数要同时服务这两种形态,于是团队里出现了三种主张:

  1. 全部用 G1,默认值最稳;
  2. 在线服务换 ZGC,「ZGC 停顿最低」;
  3. 批处理换 Parallel,「Parallel 吞吐最高」。

这三句话都不算错,但都不完整。选型要先回答四个问题:堆多大、能接受多长停顿、吞吐损失多少、目标平台支持哪个。本节把五种收集器放在同一台机器上,用同一份 OldLoad 负载跑一遍,把答案变成可核对的数字。

OldLoad 的形状是:每轮分配 500 个 4 KB 数组和 500 个 m-<i>-<round> 字符串,把其中一半放进 retained 直到 250 MB,模拟「持续分配 + 稳定增长的老年代」。

五种收集器的目标与代价

收集器分代目标主要代价适用堆
Serial是单核简单、内存占用最小全程 STW,停顿随堆线性增长几百 MB 以内
Parallel是吞吐量停顿较长,不可控几 GB,批处理
G1是(分区)可预测停顿 + 较高吞吐region 元数据、记忆集开销几百 MB 到几十 GB
ZGC是(默认)极低停顿(亚毫秒级)读屏障、染色指针、吞吐与内存开销几 GB 到 TB
Shenandoah是低停顿转发指针、并发开销几 GB 到几百 GB

先说清两个容易过时的点。

分代 ZGC 是默认形态。 JDK 23 起 ZGC 默认分代,不需要额外开关。JDK 25 里加 -XX:+ZGenerational 会得到一条警告并被忽略:

本机输出(java -XX:+UseZGC -XX:+ZGenerational -version):

OpenJDK 64-Bit Server VM warning: Ignoring option ZGenerational; support was removed in 24.0
openjdk version "25.0.5" 2026-10-20 LTS
OpenJDK Runtime Environment Temurin-25.0.5+8 (build 25.0.5+8-LTS)

按 -XX:+PrintFlagsFinal 查,ZGenerational 已经不在 flag 列表里(grep ZGenerational 无输出)。任何「加这个参数开启分代 ZGC」的写法在 JDK 25 都是错的。

G1 是默认收集器。 Temurin 25 在 macOS/aarch64 上默认 G1,VM.info 会显示 g1 gc。这意味着不写 -XX:+UseG1GC 时日志里的 Using G1 也是正常的。

五种收集器的实现差异可以归到两个动作上:

  • 怎么找垃圾:Serial/Parallel 用完整 STW 标记;G1/Shenandoah/ZGC 用并发标记,应用线程和标记线程同时跑。
  • 怎么挪对象:Serial/Parallel 复制整个新生代;G1 按 region 挑回收价值高的;ZGC 用染色指针 + 读屏障,对象可以原地重定位;Shenandoah 用转发指针,读的时候多一次间接。

同一台机器上的五组证据

第一步,确认每个收集器实际生效的关键参数:

for gc in UseSerialGC UseParallelGC UseG1GC UseZGC UseShenandoahGC; do
  echo "=== $gc ==="
  java -XX:+$gc -XX:+PrintFlagsFinal -version 2>/dev/null \
    | awk '/ (UseSerialGC|UseParallelGC|UseG1GC|UseZGC|UseShenandoahGC|ParallelGCThreads|ConcGCThreads|MaxGCPauseMillis|G1HeapRegionSize) /{printf "%s=%s\n",$2,$4}'
done

本机输出整理成表(默认堆 8 GB,Apple M1 Pro 10 核):

收集器ParallelGCThreadsConcGCThreadsMaxGCPauseMillisG1HeapRegionSize
Serial00184467440737095516140
Parallel90184467440737095516140
G1922004194304
ZGC63184467440737095516140
Shenandoah52184467440737095516140

两个细节值得注意:

  • MaxGCPauseMillis 在非 G1 收集器上的值是 18446744073709551614,即无符号 64 位最大值减一,等价于「未设置」。给 Parallel 或 ZGC 调这个参数没有意义。
  • Serial 的 ParallelGCThreads=0 不是配置错误,它本来就不用工作线程。

第二步,看每个收集器启动后的第一行日志。同一个 GcLoad,-Xms128m -Xmx128m,各 1 秒:

java -XX:+UseSerialGC     -Xms128m -Xmx128m "-Xlog:gc,gc+heap=info" -cp out GcLoad 1
java -XX:+UseParallelGC   -Xms128m -Xmx128m "-Xlog:gc,gc+heap=info" -cp out GcLoad 1
java                      -Xms128m -Xmx128m "-Xlog:gc,gc+heap=info" -cp out GcLoad 1
java -XX:+UseZGC          -Xms128m -Xmx128m "-Xlog:gc" -cp out GcLoad 1
java -XX:+UseShenandoahGC -Xms128m -Xmx128m "-Xlog:gc" -cp out GcLoad 1

本机输出摘录:

=== Serial ===
[0.007s][info][gc] Using Serial
[0.052s][info][gc,heap] GC(0) DefNew: 34944K(39296K)->2202K(39296K) Eden: 34944K(34944K)->0K(34944K) From: 0K(4352K)->2202K(4352K)
[0.052s][info][gc,heap] GC(0) Tenured: 1150K(87424K)->1150K(87424K)
[0.052s][info][gc     ] GC(0) Pause Young (Allocation Failure) 35M->3M(123M) 0.633ms

=== Parallel ===
[0.009s][info][gc] Using Parallel
[0.064s][info][gc,heap] GC(0) PSYoungGen: 33280K(38400K)->2512K(38400K) Eden: 33280K(33280K)->0K(33280K) From: 0K(5120K)->2512K(5120K)
[0.064s][info][gc,heap] GC(0) ParOldGen: 1150K(87552K)->1158K(87552K)
[0.064s][info][gc     ] GC(0) Pause Young (Allocation Failure) 33M->3M(123M) 6.103ms

=== G1 ===
[0.015s][info][gc] Using G1
[0.064s][info][gc,heap] GC(0) Eden regions: 47->0(57)
[0.064s][info][gc,heap] GC(0) Survivor regions: 0->3(6)
[0.064s][info][gc,heap] GC(0) Old regions: 2->2
[0.064s][info][gc     ] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 48M->3M(128M) 0.914ms

=== ZGC ===
[0.019s][info][gc] Using The Z Garbage Collector
[0.087s][info][gc] GC(0) Major Collection (Warmup)
[0.089s][info][gc,heap] GC(0) Y: Min Capacity: 128M(100%)
[0.089s][info][gc,heap] GC(0) Y: Max Capacity: 128M(100%)

=== Shenandoah ===
[0.002s][info][gc] Min heap equals to max heap, disabling ShenandoahUncommit
[0.007s][info][gc] Heuristics ergonomically sets -XX:+ExplicitGCInvokesConcurrent
[0.007s][info][gc] Heuristics ergonomically sets -XX:+ShenandoahImplicitGCInvokesConcurrent
[0.007s][info][gc] Using Shenandoah

Using ... 这一行是最快的确认方式,比查启动参数可靠:参数可能写错、可能被 ergonomics 覆盖,日志里的 Using 是最终结果。

ZGC 的行要单独解释。同一轮 GC 稍后还会打印一行周期汇总:

[0.096s][info][gc] GC(0) Major Collection (Warmup) 14M(11%)->30M(23%) 0.005s

14M(11%)->30M(23%) 是回收前使用量 -> 回收后使用量,百分比相对最大堆。回收后反而变大不是 bug,因为标记期间应用还在分配,这一行统计的是周期结束时刻的占用。后面的 0.005s 是整个并发周期的墙钟时长,不是停顿时间;ZGC 的停顿行在 gc,phases 标签下,6.3 会给出逐项数字。

第三步,看运行中的堆结构。用 jcmd <pid> GC.heap_info,各收集器跑 OldLoad 3 秒后采样:

本机输出(-Xms256m -Xmx256m):

=== Serial ===
DefNew     total 78656K, used 46798K
 eden space 69952K,  65% used
 from space 8704K,  23% used
 to   space 8704K,   0% used
Tenured    total 174784K, used 104854K

=== Parallel ===
PSYoungGen      total 84480K, used 4006K
 eden space 81920K, 2% used
 from space 2560K, 92% used
ParOldGen       total 175104K, used 104892K

=== G1 ===
garbage-first heap   total reserved 262144K, committed 262144K, used 239932K
 region size 1024K, 133 young (136192K), 3 survivors (3072K)

=== Shenandoah ===
Shenandoah Heap
 256M max, 256M soft max, 256M committed, 141M used
 1024 x 256 K regions

=== ZGC ===
ZHeap            used 120M, capacity 256M, max capacity 256M
 Cache           136M (12)
  size classes   2M (7), 8M (3), 16M (1), 64M (1)

这张表把「同一种堆,五种记账方式」摆在一起:

  • Serial 的 DefNew 由一个 Eden 加两块 Survivor 组成,from/to 各 8704K;
  • Parallel 的 PSYoungGen 结构相同,比例不同(81920K Eden 对 2560K Survivor);
  • G1 只报 region 数:133 个 young region、3 个 survivor region,region 大小 1024K;
  • Shenandoah 也分区,256M 堆下是 1024 个 256K region,region 比 G1 小得多;
  • ZGC 的 Cache 是它自己的页缓存,size classes 列出三种页大小,和分代 region 不是一个模型。

选型:不是「谁最快」而是「谁的代价你能付」

先看同一份 OldLoad(8 秒,250 MB retained,-Xms512m -Xmx512m)在 G1 和 ZGC 下的实测。吞吐用 rounds 表示,每轮工作量相同,rounds 越高吞吐越高:

指标G1ZGC
rounds(3 次)58517 / 66422 / 6688667781 / 58678 / 66285
最大停顿(单次 8 秒)52.374 ms0.053 ms
停顿总时长(单次 8 秒)987.9 ms17.48 ms
最大 RSS(3 次)545–547 MiB564–580 MiB

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3,Temurin 25.0.5+8。所有数字为单次或三次运行,未做统计检验;RSS 由 /usr/bin/time -l 的 maximum resident set size 读取。

这组数字能支持三个结论,也只能支持三个:

  1. 停顿差距是数量级的。G1 的最大停顿 52 ms,ZGC 0.053 ms,差三个数量级。这来自机制,不是运气:ZGC 的标记和重定位都在并发阶段完成,STW 只做根扫描的交接。
  2. 吞吐差距在噪声内。两组 rounds 的区间重叠(58.5k–66.9k 对 58.7k–67.8k),在这个负载和 8 秒窗口下,不能得出「ZGC 吞吐更低」或「更高」的结论。要比较吞吐,需要更长的时间窗、更多轮次和固定 CPU 配额。
  3. ZGC 的内存代价是可测的。RSS 高出约 19–33 MiB,主要来自读屏障相关的元数据和页缓存。堆越大,这部分开销的比例越小。

所以「ZGC 永远最快」是错的,「ZGC 吞吐一定差」也是错的。正确的说法是:ZGC 买的是停顿,付的是吞吐与内存;这笔交易是否划算,取决于你的 SLO 是 P99 还是总吞吐。

选型表:

场景首选理由不建议
容器 2C4G,P99 敏感G1默认、停顿可控、内存开销小ZGC(堆太小,收益有限)
容器 2C4G,P99 极敏感且堆 ≤ 4GG1 + MaxGCPauseMillis先调参数,别急着换收集器直接上 ZGC
堆 16G 以上,停顿 < 10 msZGC停顿与堆大小基本无关G1(Mixed GC 停顿随堆增长)
夜间批处理,吞吐优先Parallel无并发开销,吞吐最高ZGC(读屏障拖吞吐)
不想绑定 ZGC 的平台Shenandoah低停顿,region 更细需要确认发行版包含
单核 / 极小堆Serial无线程开销任何并发收集器

libraryd 的两套部署正好落在表的两端:在线服务用 G1,批处理用 Parallel。这不是「哪个先进用哪个」,而是按 SLO 选。

在 libraryd 里落地:把选型写成参数组

选型不能只写在文档里,要变成可直接执行的脚本。scripts/jvm/collector-profiles.sh 定义两组:

#!/usr/bin/env bash
# libraryd 收集器参数组。用法:source collector-profiles.sh; java $LIBRARYD_ONLINE ...
set -euo pipefail

# 在线服务:2C4G,P99 < 200ms,G1 目标 100ms
LIBRARYD_ONLINE=(
  -Xms2g -Xmx2g
  -XX:+UseG1GC
  -XX:MaxGCPauseMillis=100
  "-Xlog:gc*:file=/var/log/libraryd/gc-%p.log:time,uptime,level,tags:filecount=5,filesize=50M"
)

# 夜间批处理:16C32G,吞吐优先,Parallel
LIBRARYD_BATCH=(
  -Xms8g -Xmx8g
  -XX:+UseParallelGC
  -XX:ParallelGCThreads=8
  "-Xlog:gc:file=/var/log/libraryd/gc-batch-%p.log:time,uptime:filecount=3,filesize=50M"
)

# 低延迟实验组:堆 8G 以上再启用
LIBRARYD_LOW_LATENCY=(
  -Xms8g -Xmx8g
  -XX:+UseZGC
  "-Xlog:gc*:file=/var/log/libraryd/gc-zgc-%p.log:time,uptime,level,tags:filecount=5,filesize=50M"
)

三组参数的取舍:

  • 在线组显式写 -XX:+UseG1GC。虽然它是默认值,但显式写出来可以防止基础镜像换 JDK 后默认值变化;MaxGCPauseMillis=100 比默认 200 更激进,配合 RuntimeProbe 的 P99 观测一起调。
  • 批处理组的 ParallelGCThreads=8 是给容器限制的:机器 16 核,容器配额 8 核,让 GC 线程数匹配配额,避免和业务线程抢 CPU。
  • 低延迟组只在堆 8 GB 以上启用。4 GB 堆用 ZGC,停顿收益不足以抵消读屏障和内存开销。

RuntimeProbe 的 gcCount / gcTimeMillis 来自 GarbageCollectorMXBean,可以直接校验参数组是否生效。bean 的名字随收集器变化,下面是本机实测:

public String collectorNames() {
    return ManagementFactory.getGarbageCollectorMXBeans().stream()
            .map(GarbageCollectorMXBean::getName)
            .collect(Collectors.joining(", "));
}

本机输出(同一个方法,五种启动参数):

默认 G1          : G1 Young Generation, G1 Concurrent GC, G1 Old Generation
-XX:+UseParallelGC: PS MarkSweep, PS Scavenge
-XX:+UseZGC       : ZGC Minor Cycles, ZGC Minor Pauses, ZGC Major Cycles, ZGC Major Pauses
-XX:+UseSerialGC  : Copy, MarkSweepCompact
-XX:+UseShenandoahGC: Shenandoah Pauses, Shenandoah Cycles

注意 G1 有三个 bean,其中 G1 Concurrent GC 统计的是并发周期,不是 STW 停顿;ZGC 有四个,Minor 和 Major 分开。做「GC 总停顿」指标时不能把所有 bean 的 getCollectionTime() 直接相加,否则会把并发阶段也算进去。libraryd 的 RuntimeProbe 只把收集时间当参考值,P99 以 GC 日志里的 Pause 行为准。

把收集器名字写进启动日志,比事后猜「这个进程到底用了哪个收集器」可靠。

常见错误与边界

错误一:以为 -XX:+ZGenerational 还能开分代。 本机输出 Ignoring option ZGenerational; support was removed in 24.0,参数被忽略。分代 ZGC 在 JDK 23 起就是默认形态,写这个参数只会得到一条警告。

错误二:给 Parallel 或 ZGC 调 -XX:MaxGCPauseMillis。 本机 PrintFlagsFinal 显示这两个收集器上该值是 18446744073709551614,即未设置。这个参数只在 G1 上参与 young region 数量的选择;在 Serial、Parallel、ZGC、Shenandoah 上设置它不会生效,也不会有任何提示。

错误三:只看 -Xlog:gc 就判断 ZGC 停顿。 ZGC 的停顿行带 gc,phases 标签。本机 -Xlog:gc 只输出 Major Collection (Warmup) 14M(11%)->30M(23%) 0.005s,这里的 0.005s 是整个并发周期,不是停顿。误读会把 5 ms 当成 STW,得出「ZGC 停顿 5 ms」的错误结论。

错误四:在 4 GB 堆上直接换 ZGC 期待 P99 改善。 本机 512 MB 堆的对照里,G1 的最大停顿 52 ms 已经能满足多数 200 ms 的 SLO;换 ZGC 后停顿降到 0.053 ms,但吞吐区间没有可测差异,RSS 反而增加。先确认瓶颈真的是 GC 停顿(用 JFR 的 jdk.GCPhasePause 与请求延迟对齐),再换收集器。

边界一:Shenandoah 的 region 更细,但不是免费。 本机 256 MB 堆下是 1024 个 256K region,元数据开销比 G1 的 256 个 1 MB region 高。小堆上它的优势不明显。

边界二:容器里 CPU 配额会影响 GC 线程数。 本机 10 核下 G1 的 ParallelGCThreads=9、ConcGCThreads=2;容器限制到 2 核后,ergonomics 会重新计算。不要用物理机上的线程数去推断容器里的行为。

边界三:Using ... 行只在启动时打印一次。 日志轮转后旧文件里才有;运行中的进程用 jcmd <pid> VM.info 或 VM.flags 确认,不要翻当前日志找不到就以为没生效。

小结

  • 五种收集器的差异归结为「怎么找垃圾」和「怎么挪对象」;Serial/Parallel 全 STW,G1/Shenandoah/ZGC 并发标记。
  • 分代 ZGC 在 JDK 23 起默认,-XX:+ZGenerational 在 JDK 25 被忽略并告警:support was removed in 24.0。
  • 本机同一负载下 G1 最大停顿 52.374 ms、ZGC 0.053 ms;吞吐 rounds 区间重叠,8 秒窗口内没有可测差异。
  • ZGC 的 RSS 比 G1 高约 19–33 MiB;堆越大,这部分比例越小。
  • GC.heap_info 的形状可以直接区分收集器:DefNew/Tenured、PSYoungGen/ParOldGen、region size、N x M K regions、ZHeap。
  • 选型按 SLO 与堆大小决定;libraryd 在线用 G1、批处理用 Parallel、8 GB 以上低延迟场景才考虑 ZGC。
  • 确认收集器看 Using ... 日志行或 GarbageCollectorMXBean 的名字,不要只看启动参数。

收集器确定了,下一个问题是「它每次到底做了什么」。6.1 从一行 GC 日志的每个字段开始,把停顿、回收量、失败原因逐项拆开。

阅读导航:上一节:5.2 分代假设与回收算法:复制、标记与整理 · 下一节:6.1 GC 日志解析:统一日志、停顿指标与分配失败 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发