这次我们来看一个 Java 开发者绕不开的实战话题:从 JDK 8 升级到 JDK 17,特别是垃圾回收器(GC)的完整拆解。很多团队还在用 JDK 8,但无论是性能、安全还是新特性,JDK 17 作为长期支持版本(LTS)都已经是主流选择。升级的核心挑战之一,就是垃圾回收器的变迁——JDK 8 默认的 Parallel Scavenge + Parallel Old 组合,在 JDK 17 中已被 G1 取代,而经典的 CMS 回收器更是被彻底移除。
如果你关心升级后的应用性能、停顿时间(STW)变化,或者想知道如何为你的服务选择合适的 GC,这篇文章可以直接收藏。我们会先理清五大垃圾回收器(Serial, Parallel, CMS, G1, ZGC)的核心机制与适用场景,再给出从 JDK 8 迁移到 JDK 17 时,关于 GC 调优的实操建议和避坑指南。
1. 核心能力速览:JDK 8 到 JDK 17 的 GC 演变
在深入细节之前,我们先通过一个表格快速把握这次升级中垃圾回收器的关键变化,这决定了你后续的调优方向。
| 能力项 | JDK 8 典型情况 | JDK 17 典型情况 | 说明与影响 |
|---|---|---|---|
| 默认 GC | Parallel Scavenge (Young) + Parallel Old (Old) | G1 (Garbage-First) | JDK 9 起 G1 成为默认。目标是在高吞吐与低延迟间取得更好平衡。 |
| CMS 状态 | 可用 (-XX:+UseConcMarkSweepGC) | 已移除 | JDK 14 中废弃,JDK 17 中完全移除。升级时必须切换 GC。 |
| 新 GC 选项 | G1 已存在,ZGC/Shenandoah 无 | ZGC、Shenandoah 成为生产可用选项 | ZGC(低延迟)、Shenandoah(低停顿)为超大堆或低延迟场景提供新选择。 |
| 调优复杂度 | 相对简单,参数直观 | 相对复杂,需理解新算法 | G1 的调优逻辑与 Parallel/CMS 不同,需要重新学习。 |
| 目标场景 | 吞吐量优先 | 平衡吞吐与延迟,或极致低延迟 | 现代应用更关注响应时间,G1/ZGC 的设计更贴合此趋势。 |
| 升级强制动作 | 无 | 必须移除-XX:+UseConcMarkSweepGC参数 | 否则 JVM 无法启动。这是升级时最先要检查的点。 |
简单来说,从 JDK 8 升级到 JDK 17,在垃圾回收方面你不是在微调,而是在换一套引擎。默认的 GC 换了,旧的选项没了,新的强大工具出现了。理解这五大回收器的原理,是做出正确选择的前提。
2. 五大垃圾回收器核心原理拆解
要做出明智的选择,必须理解它们是如何工作的。我们按出现时间和设计目标,逐一拆解这五大回收器。
2.1 Serial 收集器:单线程的奠基者
这是最古老、最简单的收集器。它的“单线程”意义深远:在进行垃圾回收时,必须暂停所有用户线程(Stop-The-World, STW),并且这个暂停是由单个线程完成的。
工作过程:
- 新生代回收:采用“复制”算法。暂停所有线程,单个 GC 线程将 Eden 区和一个 Survivor 区中存活的对象复制到另一个空的 Survivor 区,然后清空 Eden 和之前的 Survivor。
- 老年代回收:采用“标记-整理”算法。同样暂停所有线程,单个 GC 线程标记出所有存活对象,然后将它们向内存空间的一端滑动,清理掉边界以外的内存。
优点:实现简单,没有线程交互开销,在单核处理器或极小堆内存(如几十到百兆)的场景下效率很高。
缺点:STW 时间与堆大小成正比,不适合现代多核服务器和大内存应用。
JDK 8/17 启用参数:
-XX:+UseSerialGC适用场景:客户端模式、嵌入式系统、或用于学习理解 GC 原理。生产环境服务器基本不用。
2.2 Parallel / Throughput 收集器:吞吐量的王者
这是 JDK 8 的默认组合,目标是最大化应用程序的吞吐量(单位时间内处理的业务量)。它是 Serial 收集器的多线程并行版本。
工作过程:
- 新生代:Parallel Scavenge,多线程并行执行复制算法。
- 老年代:Parallel Old,多线程并行执行标记-整理算法。
- 两者在进行回收时,都会发生 STW,但利用多核优势,缩短了 STW 的时间。
优点:吞吐量高,适合后台运算、批处理等不太关心个别请求延迟的场景。
缺点:STW 时间依然不可控,在堆较大时,一次 Full GC 的停顿可能达到数秒甚至更久。
JDK 8/17 启用参数:
-XX:+UseParallelGC(年轻代) 和-XX:+UseParallelOldGC(老年代,通常自动启用)适用场景:科学计算、数据导出、报表生成等吞吐量优先的业务。
2.3 CMS 收集器:低延迟的尝试(已退役)
Concurrent Mark Sweep 收集器的设计目标是获取最短的回收停顿时间。它允许垃圾收集线程与用户线程并发工作。
工作过程(复杂,分阶段):
- 初始标记:STW,仅标记 GC Roots 直接关联的对象,速度很快。
- 并发标记:GC 线程与用户线程并发,遍历整个对象图进行标记。
- 重新标记:STW,修正并发标记期间因用户线程运行而产生变动的标记。
- 并发清除:GC 线程与用户线程并发,清理未被标记的死亡对象。
优点:大部分标记和清除工作与应用并发,STW 时间极短,用户体验好。
缺点:
- CPU 敏感:并发阶段会占用一部分 CPU,导致应用吞吐量下降。
- 浮动垃圾:并发清理阶段产生的垃圾只能下次 GC 处理。
- 内存碎片:采用“标记-清除”算法,会产生碎片,可能触发 Full GC 进行压缩。
- 调优复杂:参数繁多,如
-XX:CMSInitiatingOccupancyFraction设置触发阈值。
JDK 8 启用参数:
-XX:+UseConcMarkSweepGC现状:JDK 14 废弃,JDK 17 移除。升级时必须替换。
历史场景:曾经用于对延迟敏感的服务,如 Web 服务器。
2.4 G1 收集器:平衡之选(JDK 9+ 默认)
Garbage-First 的设计目标是替代 CMS,在可控的停顿时间内获得尽可能高的吞吐量。它引入了“Region”的概念,将堆划分为多个大小相等的独立区域。
核心思想:
- 化整为零:不再固定分代物理边界,每个 Region 可以属于 Eden、Survivor、Old 或 Humongous(存放大对象)。
- 可预测停顿:通过
-XX:MaxGCPauseMillis参数设定目标停顿时间(如 200ms),G1 会尽力达成。 - 筛选回收:根据每个 Region 的“垃圾价值”(回收所需时间与可释放空间),优先回收价值高的 Region(Garbage-First 名字由来)。
工作过程:
- 年轻代回收:STW,多线程并行将 Eden 和 Survivor Region 的存活对象复制到新的 Survivor 或 Old Region。
- 并发标记周期:类似 CMS,但作用于整个堆,用于标记老年代 Region 的存活对象,并计算各 Region 的回收价值。
- 混合回收:在并发标记周期后,G1 会多次进行混合回收,不仅回收年轻代 Region,还会根据价值选择部分老年代 Region 进行回收。
优点:兼顾吞吐和停顿,可预测停顿时间,有效避免内存碎片。
缺点:内存占用稍高(Remembered Set 等数据结构开销),调优参数比 Parallel 多。
JDK 8/17 启用参数:
-XX:+UseG1GC(JDK 9 后默认)适用场景:JDK 9+ 的通用默认选择,适用于大多数服务端应用,特别是堆内存较大(如 6GB 以上)或对停顿时间有要求的服务。
2.5 ZGC 收集器:极致低延迟的先锋
Z Garbage Collector 是 JDK 11 引入的实验特性,在 JDK 15 成为生产可用,目标是将停顿时间控制在10 毫秒以内,且停顿时间不会随堆大小增长而增加。
核心技术:
- 染色指针:将 GC 相关的元数据存储在指针本身,而非对象头,这减少了内存访问开销。
- 并发处理:标记、转移(压缩)、重定位等几乎所有阶段都是并发执行的,STW 时间极短,仅用于根节点扫描等必要环节。
- Region 布局:支持动态创建和销毁的 Region,更灵活。
优点:超低停顿(亚毫秒到十毫秒级),停顿时间与堆大小无关,吞吐量损失小。
缺点:在 JDK 17 中,不支持类卸载(JDK 21 已支持),内存占用较高。
JDK 17 启用参数:
-XX:+UseZGC适用场景:超大堆内存(TB 级别)、对延迟极度敏感的核心交易系统、实时计算。需要评估其内存开销和功能限制。
Shenandoah是另一个低停顿收集器,目标与 ZGC 类似,但实现原理不同(使用 Brooks 指针)。启用参数为-XX:+UseShenandoahGC。在 JDK 17 中,ZGC 更受 Oracle 官方推荐。
3. 从 JDK 8 (CMS/Parallel) 迁移到 JDK 17 (G1) 实战指南
理论清楚了,现在进入实战。假设你有一个正在使用 JDK 8 和 CMS 收集器的线上服务,如何平稳升级到 JDK 17?
3.1 环境准备与前置检查
- 备份与回滚方案:这是第一步。确保有完整的代码、配置备份,并规划好快速回滚到 JDK 8 的流程。
- 检查依赖兼容性:
- 框架/库版本:确保 Spring Boot、MyBatis、Netty 等核心框架支持 JDK 17。通常 Spring Boot 2.5+ 已提供良好支持。
- 第三方 JAR 包:某些古老的或使用了内部 API 的 JAR 可能在 JDK 17 上运行失败。使用
jdeps工具进行初步分析。
# 分析应用 jar 对 JDK 内部 API 的依赖 jdeps --jdk-internals --class-path "lib/*" your-application.jar- 移除 Nashorn:JDK 15 移除了 Nashorn JavaScript 引擎。如果项目中使用,需迁移到 GraalVM JavaScript 等替代方案。
- 清理废弃的 JVM 参数:
- 首要任务:删除所有
-XX:+UseConcMarkSweepGC和-XX:+UseParNewGC参数。 - 检查其他废弃参数:如
-XX:+CMSClassUnloadingEnabled等 CMS 相关参数也应移除。启动时 JVM 会警告废弃参数,需关注日志。
- 首要任务:删除所有
3.2 安装部署与启动方式
- 下载 JDK 17:从 Adoptium (原 AdoptOpenJDK)或 Oracle 官网下载对应系统的 JDK 17 LTS 版本。
- 配置环境变量:更新
JAVA_HOME和PATH指向 JDK 17 目录。 - 首次启动(无参数):先使用 JDK 17 默认设置(即 G1GC)启动应用,进行最基本的冒烟测试。
# 假设原来启动命令是: # java -Xms2g -Xmx2g -XX:+UseConcMarkSweepGC -jar app.jar # 升级后,先简化为: java -Xms2g -Xmx2g -jar app.jar - 验证启动与功能:确保应用能正常启动,核心业务流程跑通。
3.3 性能基准测试与 GC 日志分析
升级后,GC 行为变了,必须进行性能对比测试。
开启 GC 日志:这是最重要的调优依据。在启动参数中添加:
-Xlog:gc*,gc+heap=debug,gc+age=trace:file=gc_%p_%t.log:time,uptime,level,tags:filecount=10,filesize=100M- 这个参数在 JDK 9+ 的 Unified Logging 框架下,会输出非常详细的 GC 日志。
filecount和filesize用于日志滚动,防止磁盘写满。
进行压力测试:使用 JMeter、wrk 或生产类似的流量,对升级前后的应用进行压测。记录:
- 吞吐量(QPS/TPS)
- 平均响应时间、P95/P99 响应时间
- JVM 的 CPU 和内存使用情况
分析 GC 日志:关注以下关键指标:
- Young GC 频率与耗时:G1 的年轻代回收是否过于频繁?平均耗时多少?
- Mixed GC 情况:G1 是否启动了混合回收?回收了哪些老年代 Region?
- Full GC:出现 Full GC 是警报!G1 的设计目标之一就是避免 Full GC。如果出现,说明配置可能不合理或存在内存问题。
- 停顿时间:对比 CMS 时代的停顿时间,G1 是否满足
-XX:MaxGCPauseMillis的目标(默认 200ms)?
3.4 G1 调优参数建议(从 CMS 迁移而来)
如果默认 G1 表现不佳,可以尝试以下调优,但记住调优的原则是“先测量,后调优”。
| 调优目标 | 关键 JVM 参数 | 说明与建议 |
|---|---|---|
| 控制停顿时间 | -XX:MaxGCPauseMillis=200 | G1 的目标停顿时间。设为应用可接受的范围(如 100-200ms)。设得太小会导致 GC 更频繁,反而降低吞吐。 |
| 设置堆大小 | -Xms4g -Xmx4g | 建议设为相同值,避免堆动态调整带来的额外 GC。G1 适合大堆,建议至少 4GB 以上。 |
| 调整并行线程数 | -XX:ParallelGCThreads=n | 并行阶段(STW阶段)的GC线程数。默认为CPU核数。如果GC线程占用CPU过多,可适当调小。 |
| 调整并发线程数 | -XX:ConcGCThreads=n | 并发阶段(标记阶段)的GC线程数。默认为ParallelGCThreads / 4。增加此值可加快并发标记,但会占用更多应用CPU。 |
| 调整 Region 大小 | -XX:G1HeapRegionSize=n | Region 大小,可为 1M 到 32M,必须是2的幂。堆内存很大时(如>32G),可考虑设置为 16M 或 32M。通常不需要改。 |
| 触发混合回收的阈值 | -XX:InitiatingHeapOccupancyPercent=45 | 堆占用率达到多少时启动并发标记周期。默认45%。如果老年代增长快,可以适当降低此值,让 G1 更早开始标记。 |
| 处理大对象 | -XX:G1MixedGCLiveThresholdPercent=85-XX:G1HeapWastePercent=5 | 控制混合回收中哪些老年代Region会被回收。如果大对象多,可以调整这些参数。 |
一个从 CMS 迁移过来的示例启动参数:
# 原 CMS 参数(JDK 8) # java -Xms4g -Xmx4g -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -jar app.jar # 迁移后的 G1 参数(JDK 17) java -Xms4g -Xmx4g \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=200 \ -XX:InitiatingHeapOccupancyPercent=40 \ -Xlog:gc*,gc+heap=debug:file=gc.log:time,uptime,level,tags:filecount=5,filesize=50M \ -jar app.jar4. 何时考虑 ZGC 或 Shenandoah?
如果你的应用满足以下特征,可以考虑跳过 G1,直接评估 ZGC:
- 堆内存非常大:超过 32GB,甚至达到数百 GB。
- 对停顿时间极度敏感:要求 P99 响应时间稳定,且停顿不能超过 10-20ms,例如金融支付、实时竞价、游戏服务器。
- 愿意付出额外资源:ZGC 在并发阶段会消耗更多的 CPU 和内存带宽,并且内存占用(特别是堆外内存)会比 G1 高。
启用 ZGC 非常简单:
java -Xms32g -Xmx32g -XX:+UseZGC -jar your-app.jar对于 ZGC,通常只需要设置堆大小和启用开关。其核心优势就是“自动”,试图减少调优参数。但务必在测试环境充分压测,观察其 CPU 和内存开销是否符合预期。
5. 常见问题与排查方法
升级过程中,你可能会遇到以下问题:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
应用启动失败,报Unrecognized VM option 'UseConcMarkSweepGC' | JDK 17 中已移除 CMS 相关参数 | 检查启动脚本和配置文件的 JVM 参数 | 彻底删除所有-XX:+UseConcMarkSweepGC、-XX:+UseParNewGC等参数 |
| 升级后,Full GC 频繁或停顿时间变长 | 1. G1 自适应调整期 2. 堆大小或 Region 设置不合理 3. 对象分配速率过快 | 分析 GC 日志,观察 Young/Mixed GC 频率和耗时;使用jstat -gcutil监控 | 1. 给予 JVM 预热时间(如30分钟) 2. 调整 -XX:MaxGCPauseMillis或-XX:InitiatingHeapOccupancyPercent3. 检查代码是否存在内存泄漏或过度分配 |
启用 ZGC 后,出现OutOfMemoryError: Metadata space或 Native Memory 增长 | ZGC 在 JDK 17 中内存占用较高,且不支持类卸载 | 使用 NMT 监控 Native Memory:-XX:NativeMemoryTracking=detail | 1. 增加 Metaspace 大小:-XX:MaxMetaspaceSize=512m2. 如果类加载频繁,考虑升级到JDK 21(ZGC 支持类卸载)或暂用 G1 |
| 压测时吞吐量下降明显 | GC 线程占用过多 CPU,或目标停顿时间设置过小 | 监控系统 CPU 使用率,区分应用线程和 GC 线程 | 1. 适当调整-XX:ParallelGCThreads或-XX:ConcGCThreads2. 放宽 -XX:MaxGCPauseMillis |
| 年轻代 GC 异常频繁 | Eden 区过小,或对象过早晋升 | 分析 GC 日志中 Young GC 间隔和晋升大小 | 1. 无需直接设置年轻代大小,G1 会自动调整 2. 检查代码中是否存在大量短命大对象或不当的缓存 |
6. 最佳实践与使用建议
- 优先使用默认值:无论是 G1 还是 ZGC,现代 JVM 的默认配置都经过了广泛测试。不要一上来就调参。先使用默认配置进行压测,根据 GC 日志再决定是否需要调优。
- 理解监控指标:学会使用
jstat、jcmd、GC 日志以及 APM 工具(如 Prometheus + Grafana)监控 GC 行为。关键指标:GC 频率、各阶段耗时、STW 总时间、内存使用趋势。 - 堆大小设置黄金法则:
-Xms和-Xmx设置为相同值。避免堆自动扩容收缩带来的性能抖动。 - 面向低延迟与高吞吐的抉择:
- 追求吞吐量:可以继续使用
-XX:+UseParallelGC(Parallel Scavenge + Parallel Old),它在 JDK 17 中依然可用。 - 追求低延迟:默认的 G1 是安全且平衡的选择。堆超大且追求极致延迟,再考虑 ZGC。
- 追求吞吐量:可以继续使用
- 升级路径:对于复杂核心应用,建议分阶段升级:JDK 8 -> JDK 11 (LTS) -> JDK 17 (LTS)。JDK 11 是一个重要的中间版本,许多内部 API 和 GC 的变更已发生,在此版本验证兼容性更稳妥。
- 测试环境充分验证:升级必须在模拟生产环境的测试集群进行长时间(至少数天)的压测和稳定性测试,观察高峰、平峰期的 GC 表现。
从 JDK 8 升级到 JDK 17,垃圾回收器的切换是技术升级的核心环节。放弃熟悉的 CMS,拥抱 G1 或探索 ZGC,需要理念上的转变:从“手动精细调优”到“相信 JVM 的自动化与智能化”。掌握这五大回收器的工作原理,能让你在升级时心中有图,调优时手下有度。建议从默认的 G1 开始,打开详细的 GC 日志,让数据驱动你的决策,这才是应对升级挑战最可靠的方法。