本节目标:知道五种收集器各自优化什么、付出什么,能用一条命令确认当前进程用的是哪个,并按堆大小和停顿目标做选型。
适用版本: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 启动参数要同时服务这两种形态,于是团队里出现了三种主张:
- 全部用 G1,默认值最稳;
- 在线服务换 ZGC,「ZGC 停顿最低」;
- 批处理换 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 核):
| 收集器 | ParallelGCThreads | ConcGCThreads | MaxGCPauseMillis | G1HeapRegionSize |
|---|---|---|---|---|
| Serial | 0 | 0 | 18446744073709551614 | 0 |
| Parallel | 9 | 0 | 18446744073709551614 | 0 |
| G1 | 9 | 2 | 200 | 4194304 |
| ZGC | 6 | 3 | 18446744073709551614 | 0 |
| Shenandoah | 5 | 2 | 18446744073709551614 | 0 |
两个细节值得注意:
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 越高吞吐越高:
| 指标 | G1 | ZGC |
|---|---|---|
| rounds(3 次) | 58517 / 66422 / 66886 | 67781 / 58678 / 66285 |
| 最大停顿(单次 8 秒) | 52.374 ms | 0.053 ms |
| 停顿总时长(单次 8 秒) | 987.9 ms | 17.48 ms |
| 最大 RSS(3 次) | 545–547 MiB | 564–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 读取。
这组数字能支持三个结论,也只能支持三个:
- 停顿差距是数量级的。G1 的最大停顿 52 ms,ZGC 0.053 ms,差三个数量级。这来自机制,不是运气:ZGC 的标记和重定位都在并发阶段完成,STW 只做根扫描的交接。
- 吞吐差距在噪声内。两组 rounds 的区间重叠(58.5k–66.9k 对 58.7k–67.8k),在这个负载和 8 秒窗口下,不能得出「ZGC 吞吐更低」或「更高」的结论。要比较吞吐,需要更长的时间窗、更多轮次和固定 CPU 配额。
- ZGC 的内存代价是可测的。RSS 高出约 19–33 MiB,主要来自读屏障相关的元数据和页缓存。堆越大,这部分开销的比例越小。
所以「ZGC 永远最快」是错的,「ZGC 吞吐一定差」也是错的。正确的说法是:ZGC 买的是停顿,付的是吞吐与内存;这笔交易是否划算,取决于你的 SLO 是 P99 还是总吞吐。
选型表:
| 场景 | 首选 | 理由 | 不建议 |
|---|---|---|---|
| 容器 2C4G,P99 敏感 | G1 | 默认、停顿可控、内存开销小 | ZGC(堆太小,收益有限) |
| 容器 2C4G,P99 极敏感且堆 ≤ 4G | G1 + MaxGCPauseMillis | 先调参数,别急着换收集器 | 直接上 ZGC |
| 堆 16G 以上,停顿 < 10 ms | ZGC | 停顿与堆大小基本无关 | 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 日志解析:统一日志、停顿指标与分配失败 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。