JDK 垃圾回收:从 JDK 27 回溯至 1.4,了解 G1、ZGC、Shenandoah 和 CMS
本文目录
对于现代 HotSpot,先通过测量建立 G1 基线;若延迟目标严格,则比较 ZGC 或 Shenandoah;若更看重吞吐量,则考虑 Parallel GC。 复制旧的 JVM 参数之前,先确认实际 JDK 发行版使用哪个默认收集器,以及包含哪些实现。
本文的参考日期为 2026-11-03。JDK 27 是最新的正式发布功能版本;JDK 28 处于 Early Access 阶段。本文涵盖 OpenJDK HotSpot,并非所有 JVM 实现。Oracle 的 LTS 版本线——8、11、17、21 和 25——是常见的部署选择,但支持期限和更新条款取决于发行商。Oracle 支持路线图
从最新 JDK 回溯到早期版本
| JDK | 主要 GC 变化 | 现有部署需要检查的事项 |
|---|---|---|
| 28 EA | 将 Shenandoah 的分代模式设为默认模式的 JEP 535 处于 Targeted 状态 | 不要将其视为已在 GA 版本中交付。这并不会使 Shenandoah 成为 JVM 全局默认收集器 |
| 27 | JEP 523 不再考虑 CPU 数量和内存大小,默认选择 G1 | 对于过去在小型环境中选择 Serial 的服务,重新检查其默认设置 |
| 26 | JEP 522 减少 G1 的同步开销,以提高吞吐量 | 上游的改进并不意味着你的服务也能获得相同比例的提升 |
| 25 | JEP 521 将分代 Shenandoah 从实验性功能提升为正式功能 | 模式可用与默认选择该模式是不同的变化 |
| 24 / 23 | 24 移除非分代 ZGC(JEP 490);23 在选择 ZGC 时默认使用分代模式(JEP 474)。24 还引入实验性的分代 Shenandoah(JEP 404) | ZGC 内部的默认模式发生变化,并不等于 JVM 默认收集器发生变化 |
| 21 / 17 / 15 | 21 引入分代 ZGC(JEP 439)。ZGC 和 Shenandoah 在 15 中成为正式功能(JEP 377、379),因此在 17 中也属于正式功能 | 在 21 中显式选择分代 ZGC。分别检查平台和发行版是否提供支持 |
| 14 / 12 / 11 | 14 移除 CMS(JEP 363)。12 添加实验性 Shenandoah(JEP 189);11 引入实验性 ZGC 和 Epsilon(JEP 333、318) | 区分历史上的实验状态与目前的正式功能状态;最初的支持并未覆盖所有平台 |
| 9 | G1 成为服务器配置的默认收集器(JEP 248);CMS 被弃用(JEP 291);统一 GC 日志引入(JEP 271) | 不要原样迁移 JDK 8 的日志解析器和选项 |
| 8 | Parallel 是服务器级机器以吞吐量为目标时的默认收集器;G1 和 CMS 是可选项。PermGen 被移除(JEP 122) | Parallel 并非所有环境中的默认收集器。不要把 PermGen 参数带入新部署 |
| 7u4 / 6 / 5 / 1.4.1 | Oracle JDK 对 G1 的支持始于 7u4。并行 Full GC 在 5u6 中引入,并在 6 中得到改进。1.4.1 文档介绍了并行年轻代回收和并发老年代回收 | 区分早期 Parallel 的年轻代并行回收与后来引入的老年代并行回收 |
理解 JDK 8 的默认设置时,应结合其自适应调优指南。更早的里程碑记录在 G1 支持历史、Parallel Compaction 说明和 JDK 1.4.1 GC 文章中。历史上的堆大小建议和暂停目标反映的是当时的情况,并非当前的部署默认值。
表格选取了重要里程碑,既不是每次更新修复的完整清单,也不表示本文实际运行了所有历史版本。
选择收集器之前,先区分三个概念
GC 回收的是 GC Roots 无法到达的对象所占用的内存;GC Roots 的例子包括运行中线程持有的引用。即使应用已不再需要某些对象,缓存或全局集合仍可能使它们保持可达。分代回收利用了这样一种观察:许多对象很快死亡,而部分对象存活更久,因此对年轻对象和年老对象采用不同的回收方式。HotSpot GC 实现
并行(parallel) 是指多个 GC 线程协同工作;并发(concurrent) 是指部分 GC 工作与应用执行在时间上重叠。两者属于不同维度。Stop-the-world(STW) 阶段会暂停应用执行。并发收集器仍可能发生短暂停顿或分配停滞;并发并不会消除请求延迟。
标记用于识别存活对象;清扫使可回收空间能够再次使用。压缩通过移动存活对象来合并空闲空间,而转移则把选定区域中的存活对象复制到其他位置。各收集器在分代、区域划分、并行工作和并发工作方面,以不同方式组合这些任务。
| 收集器 | 主要方式 | 比较时需关注的条件 |
|---|---|---|
| Serial | 使用一个 GC 线程;较低的协调开销可能适合小型任务 | 即便堆较小或 CPU 受限,也要测量吞吐量和暂停 |
| Parallel | 使用多个回收线程,优先考虑吞吐量 | 比较批处理完成时间和可容忍的暂停 |
| G1 | 使用区域划分、并发标记和暂停期间的转移 | 可作为通用服务器基线;暂停目标不是实时保证 |
| ZGC | 以并发方式执行大量对象重定位和引用处理,以实现短暂停顿 | 同时观察 CPU 和堆的余量、分配速率以及应用延迟 |
| Shenandoah | 采用并发重定位的低暂停收集器;分代模式的状态因版本而异 | 首先检查发行版是否提供该收集器,以及所选模式 |
| CMS / Epsilon | CMS 是历史上的并发标记清扫收集器;实验性 Epsilon 不回收内存 | 最新 JDK 中已没有 CMS。Epsilon 不能作为通用服务 GC 的替代品 |
对于 Serial 和 Parallel,可从当前收集器选择指南入手。G1 调优指南说明了 G1 的行为和暂停目标。历史 CMS 文档描述了并发阶段和碎片化限制;JEP 318明确说明 Epsilon 不进行内存回收。
除了收集器,也要检查分代模式
对于 JDK 21 中的 ZGC,-XX:+UseZGC -XX:+ZGenerational 选择分代模式。在 23 中,-XX:+UseZGC 默认采用分代模式;24 移除了非分代实现。将参数标为过时及移除参数,与移除实现是不同的步骤。本实验中的 JDK 27 拒绝了 -XX:+ZGenerational,因此不要将其附加到当前配置中。当前 ZGC 指南
Shenandoah 需要单独检查。25 中引入的正式分代模式通过 -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational 选择。在 27 中仅选择 Shenandoah,并不能确定已选择分代模式。 JEP 535 的目标版本是 28,在参考日期仍处于 Targeted 状态。构建选项和向后移植可能影响发行版提供哪些收集器;请检查实际使用的可执行文件。
读取同一个分配程序生成的日志
这个单线程实验用于展示对象生命周期和 GC 日志。程序分配 8192 个大小为 128 KiB 的数组,数组载荷合计为 1 GiB,同时循环替换 64 个槽位中的引用。最终保留的载荷为 8 MiB;数组头、引用以及 JVM 内存另计。它并未实现生产负载,也不是 GC 基准测试。
图中展示了代码中的分配和引用替换过程。不可达对象何时被回收由 JVM 决定;代码并未调用 System.gc()。不要假设每个被替换的对象都会立即被回收。
GcProbe.java
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
public final class GcProbe {
private static volatile byte[][] retained;
private static volatile long checksum;
public static void main(String[] args) {
System.out.println("runtime=" + System.getProperty("java.runtime.version"));
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
System.out.println("collector=" + gc.getName());
}
retained = new byte[64][];
for (int i = 0; i < 8192; i++) {
byte[] block = new byte[128 * 1024];
block[0] = (byte) i;
retained[i % retained.length] = block;
checksum += block[0] & 0xff;
}
System.out.println("iterations=8192 retainedSlots=" + retained.length
+ " checksum=" + checksum);
}
}
这些数组通过 retained 保持从外部可达。这个小程序仍不足以确定生产服务的对象生命周期分布或瓶颈。
在 JDK 27 中选择四种收集器并检查日志
这些命令使用 Unix 风格的 shell。将 GC_JDK 替换为你的 JDK 主目录,把 Java 文件保存到新的工作目录中,并在该目录运行命令。无需外部库或网络请求。每次运行的最大堆为 128 MiB,并使用有大小和数量限制的滚动日志。
GC_JDK="/path/to/jdk-27"
"$GC_JDK/bin/java" -version
mkdir -p out logs
"$GC_JDK/bin/javac" -d out GcProbe.java
for gc in G1 Parallel Serial Z; do
"$GC_JDK/bin/java" "-XX:+Use${gc}GC" -Xms128m -Xmx128m \
'-Xlog:gc*,safepoint:file=logs/gc-%p.log:time,uptime,level,tags:filecount=3,filesize=5M' \
-cp out GcProbe
done
%p 会展开为 JVM PID,从而区分不同运行生成的日志。确认四次运行均以下列行结束。前面的运行时和收集器行取决于 JDK 和收集器。
iterations=8192 retainedSlots=64 checksum=1044480
实际运行环境为 macOS arm64、Zulu 8u392-b08,以及 Temurin 21.0.13+9、25.0.5+8 和 27.0.1.0.2+1。归档版本用于展示历史行为,并非推荐当前采用这些安全更新组合。实际执行范围如下:
| JDK | 已检查的执行情况 |
|---|---|
| 8 | 默认 Parallel、显式选择的 G1 和 CMS,包括运行完成及旧式 GC 日志 |
| 21 | 非分代 ZGC 和显式选择的分代 ZGC,包括运行完成和日志 |
| 25 | 通过 ZGC 参数选择的分代 ZGC,包括运行完成和日志 |
| 27 | Serial、Parallel、G1、ZGC、非分代 Shenandoah 和显式选择的分代 Shenandoah,包括运行完成和日志 |
全部 12 种执行配置均生成了相同的校验和及 GC 日志。这些执行并未控制各 JDK 之间的实现、优化和安全差异,因此运行耗时或 GC 次数不能用于给收集器排名。28 EA 以及 1.4.1–7、9–20、22–24 和 26 的里程碑已对照上述官方资料核查。
一行日志能说明什么
下面是实际发生的 G1 事件,来自 JDK 27,仅移除了时间戳和标签修饰。
GC(0) Pause Young (Normal) (G1 Evacuation Pause) 47M->10M(128M) 1.468ms
47M->10M 是本次 GC 前后的堆使用量;(128M) 是该时刻的堆容量。括号中的值并非总是最大堆。本实验将 -Xms 和 -Xmx 设为相同值。1.468ms 是本次事件的暂停时间,并非服务的 p99,也不是 GC 的总 CPU 开销。单凭这一行既不能排除内存泄漏,也不能确立暂停时间保证。
JDK 8 还需要不同的日志选项
不要将 JDK 9 的统一日志配置原样应用于 8。使用该 JDK 的编译器重新编译同一源文件,以避免 class 文件版本不匹配。
GC_JDK="/path/to/jdk-8"
mkdir -p out8 logs
"$GC_JDK/bin/javac" -d out8 GcProbe.java
"$GC_JDK/bin/java" -Xms128m -Xmx128m -XX:+UseConcMarkSweepGC \
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:logs/jdk8-cms.log \
-cp out8 GcProbe
JDK 27 还拒绝了 -XX:+UseConcMarkSweepGC。支持、过时和移除阶段因版本而异;升级时请检查实际的启动消息和退出状态。
按以下方式检查默认收集器的选择。这会向一个新 JVM报告一个 CPU,并不会更改运行中服务的 CPU 分配。
GC_JDK="/path/to/jdk-27"
"$GC_JDK/bin/java" -XX:ActiveProcessorCount=1 -Xms128m -Xmx128m \
-XX:+PrintCommandLineFlags -version
在相同条件下,Temurin 21 输出 UseSerialGC,Temurin 27 输出 UseG1GC。显式选择收集器会覆盖默认设置。关键在于 JVM 可见的 CPU 和内存;仅凭宿主机规格无法确定容器会选择哪个收集器。
生产环境中的比较应该测量什么?
对于现有服务,先记录当前 JDK、发行版、参数和工作负载。在相同请求模式及 CPU/内存预算下,同时观察应用吞吐量、p95/p99、错误、RSS、堆使用量、分配速率、GC 暂停和并发 GC 的 CPU 使用情况。如果 GC 线程与应用线程的 CPU 竞争更激烈,即使暂停缩短,应用延迟仍可能变差。
对于 G1,先使用默认设置建立基线,不要固定沿用其他收集器的年轻代大小,再根据需要调整整体堆大小和暂停目标。ZGC 需要为存活对象集和并发回收期间的新分配预留空间。-Xmx 并不是进程 RSS,也不是整个容器的内存上限;还应为元空间、线程栈和直接缓冲区预留预算。
如果可达对象持续增长,应先检查保留的引用、缓存边界和请求生命周期。切换收集器不会自动移除可达对象。将服务侧证据与 LGTM 日志–追踪工作流结合分析,同时不要因请求延迟和 GC 事件恰好同时发生,就认定两者存在因果关系。
讨论