JDK8到JDK17升级:垃圾回收器选型与实战调优指南
2026/8/21 1:30:18 网站建设 项目流程

1. 从JDK8到JDK17,升级的核心动力与垃圾回收器变迁

如果你还在用JDK8,考虑升级到JDK17,最直接的理由可能不是新语法,而是垃圾回收器(GC)的全面革新。JDK8时代,CMS(Concurrent Mark Sweep)是很多线上服务的主流选择,因为它能提供相对较低的停顿时间。但到了JDK17,CMS已被彻底移除,G1(Garbage-First)成为默认GC,同时引入了ZGC和Shenandoah这两个以超低停顿时间为目标的革命性回收器。

这次升级,远不止是换个运行环境那么简单。它意味着你需要重新理解整个Java应用的停顿时间模型、内存管理策略和性能调优思路。很多人卡在升级这一步,不是因为代码不兼容——事实上,JDK17对大部分JDK8程序兼容性很好——而是因为对新的GC体系感到陌生,不知道如何为生产环境选择合适的回收器,以及如何配置参数。

所以,这篇文章不会只讲“如何安装JDK17”,那是第一步。我会重点拆解JDK8到JDK17中,五大垃圾回收器(Serial, Parallel, CMS, G1, ZGC/Shenandoah)的核心工作原理、适用场景、配置要点和升级后的实战调优思路。无论你是运维、架构师还是开发,只要你的服务对延迟敏感,或者内存规模较大,这篇文章都能帮你理清升级路上最关键的一环。

2. 升级准备:环境、兼容性与第一个可运行验证

在深入GC之前,必须确保基础升级流程是顺畅的。很多人一上来就研究高深的GC调优,结果连环境都没搭对。

2.1 环境选择与安装要点

从网络热词看,大家最关心的是安装。对于生产环境,我强烈建议使用LTS(长期支持)版本。JDK8和JDK17都是LTS版本,稳定性有保障。不要轻易使用非LTS版本(如JDK18, 19, 20)作为生产基准。

下载来源:优先从官方渠道如 adoptium.net (提供Temurin发行版)或Oracle官网下载。避免使用来路不明的安装包。对于Linux服务器,通常直接下载tar.gz压缩包解压即可,比RPM/DEB包更灵活,便于多版本共存。

安装与多版本共存:这是关键。你不需要卸载旧版本。在Linux/Mac上,通过JAVA_HOME环境变量和PATH来切换版本是最佳实践。

# 假设你将JDK17解压到了 /opt/java/jdk-17.0.11 export JAVA_HOME=/opt/java/jdk-17.0.11 export PATH=$JAVA_HOME/bin:$PATH

在Windows上,同样通过系统环境变量JAVA_HOME和修改Path来指定。IDEA等IDE可以单独为每个项目指定JDK,不影响全局。

验证安装:安装后,第一个命令不是跑业务应用,而是验证基础信息。

java -version

确认输出的是JDK 17。然后,跑一个最简单的Hello World程序,确保能编译和运行。

javac Hello.java java Hello

这个步骤能排除90%的基础环境问题,比如路径错误、安装包不完整等。

2.2 代码与依赖兼容性快速检查

JDK17移除了不少在JDK8中已被标记为“过时”(deprecated)的API,并加强了模块化封装。你的应用能否跑起来,取决于代码和依赖库。

核心检查点

  1. 内部API访问:如果你的代码或依赖库使用了sun.misc.*sun.reflect.*等内部API,在JDK17默认的强封装下会抛出IllegalAccessError。这是最常见的兼容性问题。
  2. 废弃的GC组合:在启动参数中,不能再使用-XX:+UseConcMarkSweepGC(CMS)。如果启动脚本里还有这个参数,应用将无法启动。
  3. 第三方依赖:重点检查那些多年未更新的基础库,如老版本的Apache Commons、网络客户端、序列化工具等。使用Maven或Gradle的依赖树分析工具,查看是否有已知的不兼容依赖。

快速测试方法:不要直接在生产代码上测试。建立一个隔离的测试环境,将你的应用打包(JAR或WAR),用JDK17启动,并加上-XX:+ShowCodeDetailsInExceptionMessages参数,它能提供更详细的错误信息。观察启动日志和初期运行是否报错。

如果遇到内部API访问错误,短期解决方案是在启动命令中添加JVM参数来开放这些内部包:

--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/sun.nio.ch=ALL-UNNAMED # ... 根据错误信息添加对应的 --add-opens 参数

但这只是权宜之计,长期来看,应推动依赖库升级或修改代码。

3. 五大垃圾回收器深度拆解:从原理到选型

环境搞定后,我们来直面核心:垃圾回收器。JDK17可用的回收器主要有五类,它们的设计目标和适用场景截然不同。

3.1 Serial 与 Parallel:基础与吞吐量的代表

  • Serial GC:这是一个单线程回收器。在进行垃圾回收时,必须暂停所有应用线程(Stop-The-World)。它的优势是简单、开销极小,没有线程交互的开销。在JDK17中,它依然存在,但仅适用于客户端应用或微型服务(比如内存几十兆),或者对吞吐量没要求、资源极度受限的环境(如嵌入式)。启用参数:-XX:+UseSerialGC

  • Parallel GC (吞吐量收集器):JDK8的默认GC。它的目标是最大化应用程序的吞吐量(即CPU用于运行用户代码的时间占比)。为了达到这个目标,它在年轻代和老年代都使用多线程进行并行垃圾回收,但同样会发生Stop-The-World停顿,且停顿时间可能较长。如果你的应用是后台计算密集型任务(如批量处理、科学计算),对延迟不敏感,那么Parallel GC可能仍然是JDK17下的一个好选择。启用参数:-XX:+UseParallelGC

选型建议:对于绝大多数从JDK8升级上来的Web应用或微服务,不再建议使用Parallel GC作为默认选择。因为现代应用更关注请求的响应速度(低延迟),而非纯粹的吞吐量。

3.2 CMS:昔日王者的落幕与替代方案

  • CMS (Concurrent Mark Sweep):这是JDK8时代很多中大型互联网服务的“标配”。它的设计目标是降低老年代回收的停顿时间。它的大部分标记和清理工作是与应用线程并发进行的,只有在初始标记和重新标记阶段需要短暂停顿。
  • 为什么被移除?CMS有几个致命缺点:1)内存碎片化严重,可能导致Full GC时出现不可预测的长停顿;2) 对CPU资源非常敏感,并发阶段会与应用线程争抢CPU;3)无法处理“浮动垃圾”,可能在并发清理阶段因应用线程产生新垃圾而导致“Concurrent Mode Failure”,进而触发一次Full GC。正因为这些难以根治的问题,它在JDK14中被标记为废弃,在JDK17中被彻底移除。
  • 升级后怎么办?如果你原来的应用使用CMS,升级到JDK17后,G1 GC是首选的直接替代品。G1同样致力于可控的停顿时间,但通过将堆内存划分为多个Region、引入预测模型和并行压缩,避免了CMS的碎片化问题。你需要将启动参数从-XX:+UseConcMarkSweepGC改为-XX:+UseG1GC

3.3 G1:JDK9后的默认王者与核心调优

从JDK9开始,G1取代Parallel成为默认垃圾回收器,这是有重大意义的。G1的设计目标是在延迟可控的情况下,尽可能获得高吞吐量。它不再坚持传统分代物理连续,而是将堆划分为多个大小相等的Region。

  • 核心工作流程

    1. 年轻代回收 (Young GC):当Eden区满时触发,采用多线程并行复制存活对象到Survivor区或老年代Region。这个过程是Stop-The-World的,但G1会尽量高效完成。
    2. 并发标记周期 (Concurrent Cycle):这不是每次Young GC都触发。当堆占用达到一定阈值(默认45%)时启动。它包括初始标记、根区域扫描、并发标记、最终标记、清理等阶段。其中初始标记和最终标记需要停顿,其他阶段并发。
    3. 混合回收 (Mixed GC):在并发标记周期之后,G1知道哪些Region里垃圾最多。Mixed GC会不仅回收年轻代Region,还会选择性地回收一部分垃圾多的老年代Region。这是G1实现可控停顿的关键。
    4. Full GC:G1设计上尽量避免Full GC。但如果并发收集速度赶不上对象分配速度,或者Mixed GC无法回收足够空间,就会退化为单线程的Serial Old GC进行Full GC,停顿会非常长。调优的核心目标之一就是避免Full GC
  • 关键调优参数

    • -XX:MaxGCPauseMillis=200这是目标值,不是保证值。G1会尽力达成,但不承诺。设置一个合理的期望(如100-200ms),不要设得太小(如20ms),否则G1会过度收缩年轻代,导致频繁GC,反而降低吞吐量。
    • -XX:G1HeapRegionSize:Region大小,范围1MB到32MB。通常不用设,G1会根据堆大小自动计算。堆很大(>4G)时,可以考虑显式设置(如16M)来平衡管理开销和回收粒度。
    • -XX:InitiatingHeapOccupancyPercent=45:触发并发标记周期的堆占用阈值。如果老年代对象增长很快,可以适当调低此值(如40),让G1更早开始标记,为Mixed GC预留时间。
    • -XX:G1ReservePercent=10:保留空间,用于晋升失败时的“兜底”。如果频繁发生晋升失败,可以适当提高此值。

G1适用场景适用于从JDK8升级上来的绝大多数应用,特别是内存从6G到几十G,要求停顿时间在几百毫秒可控范围内的服务。它是平衡吞吐量和延迟的“万金油”选择。

3.4 ZGC与Shenandoah:面向未来的超低延迟神器

如果你的应用对停顿时间有极致要求(目标停顿时间在10ms以下,甚至亚毫秒级),并且堆内存非常大(百G级别),那么你需要关注ZGC和Shenandoah。它们在JDK17中已是生产可用状态。

  • 共同核心思想:它们都采用了染色指针读屏障技术,使得垃圾回收的绝大部分工作(包括标记、转移/压缩)都能与应用线程并发执行,从而将Stop-The-World停顿时间缩短到与堆大小无关的极低水平(通常<1ms,甚至<0.1ms)。

  • ZGC:由Oracle开发,JDK15成为生产特性。其停顿时间几乎全部来自于GC周期的开始和结束阶段。启用参数:-XX:+UseZGC。它还有一个实验性的分代版本(ZGenerational),在JDK21中引入,旨在进一步提升吞吐量。

  • Shenandoah:由Red Hat开发,JDK12成为生产特性。其工作流程与ZGC类似,但实现细节不同。启用参数:-XX:+UseShenandoahGC

  • 关键配置与代价

    • 内存开销:为了实现并发转移,它们需要额外的内存来存储元数据,通常会有10%-20%的堆内存开销。
    • CPU开销:读屏障会带来一定的CPU指令开销,可能对极限吞吐量有轻微影响(通常<5%)。
    • 参数简单:它们的调优参数远比G1简单。通常只需要设置堆大小(-Xmx)和目标停顿时间(如ZGC的-XX:MaxGCPauseMillis),大部分工作由回收器自适应完成。

选型建议

  • 新应用或对延迟有严苛要求的应用:如果资源(内存、CPU)充足,直接上ZGC或Shenandoah。它们能提供近乎“无感”的GC体验。
  • 从G1升级:如果你的应用在使用G1时,停顿时间(gc_pause)仍然无法满足SLA要求,并且堆内存较大,可以考虑切换到ZGC/Shenandoah。
  • 注意:在JDK17中,ZGC和Shenandoah不支持类数据共享和压缩指针,这意味着它们的内存占用可能会比G1稍高。同时,确保你的监控工具(如Prometheus + Grafana)支持这些新GC的指标暴露。

4. 升级实战:参数迁移、监控与性能验证

理论清楚了,现在来落地。升级JDK并切换GC不是改个版本号就完事了,必须有一套验证流程。

4.1 启动参数迁移与适配

这是升级过程中最需要谨慎处理的部分。以下是一个常见的参数迁移对照示例:

JDK8 (CMS) 典型参数JDK17 (G1) 对应参数/建议说明
-Xms4g -Xmx4g-Xms4g -Xmx4g堆大小通常保持不变。G1建议不设置-Xmn(年轻代大小),让它自适应。
-XX:+UseConcMarkSweepGC-XX:+UseG1GC必须修改。这是核心变更。
-XX:+UseParNewGC(移除)CMS的年轻代回收器,G1自有其年轻代回收。
-XX:CMSInitiatingOccupancyFraction=75-XX:InitiatingHeapOccupancyPercent=45触发并发标记的阈值。G1默认45更激进,可根据老年代增长情况调整。
-XX:+UseCMSInitiatingOccupancyOnly(移除)G1无此参数。
-XX:+CMSParallelRemarkEnabled(移除)G1无此参数。
-XX:+ExplicitGCInvokesConcurrent(通常移除)处理System.gc()。G1会将其视为一次Full GC请求,可考虑用-XX:+DisableExplicitGC禁用显式GC,或依赖G1自身逻辑。
-XX:ParallelGCThreads=8-XX:ParallelGCThreads=8并行GC线程数,可保留。G1的Young GC和Mixed GC阶段会使用。
-XX:ConcGCThreads=4-XX:ConcGCThreads=4并发GC线程数,可保留。G1的并发标记阶段会使用。

启动脚本调整示例

# JDK8 CMS 风格 (已过时) java -Xms4g -Xmx4g -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -jar your-app.jar # JDK17 G1 风格 java -Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar your-app.jar

可以看到,G1的参数更简洁。不要简单地把CMS参数堆砌到G1上,很多参数不兼容或无效。

4.2 监控指标:如何判断新GC是否工作良好

升级后,必须通过监控来验证GC行为。关键指标如下:

  1. GC日志:这是第一手资料。在启动参数中添加详细的GC日志输出。

    -Xlog:gc*,gc+heap=debug,gc+ergo*=trace,gc+age*=trace:file=gc.log:time,uptime,level,tags:filecount=10,filesize=10m

    这个参数会输出非常详细的G1日志到文件。重点关注:

    • Pause Time:每次Young GC、Mixed GC的停顿时间。是否接近你设定的MaxGCPauseMillis目标?
    • GC Cycles:观察并发标记周期和混合回收的发生频率。
    • To-space Exhausted / Evacuation Failure:出现这些,说明回收速度跟不上分配速度,可能触发Full GC,需要调整参数(如增加堆大小、降低IHOP、增加G1ReservePercent)。
  2. JVM内置工具

    • jstat -gcutil <pid> 1s:实时查看各内存区域使用率和GC次数/时间。
    • jcmd <pid> GC.heap_info:查看堆概要信息。
  3. 可视化监控(生产必备):将JVM指标通过JMX或Micrometer暴露给监控系统(如Prometheus),在Grafana中制作仪表盘。核心看板应包括:

    • 堆内存使用趋势:老年代、新生代的占用变化。
    • GC暂停时间分布:Young GC和Mixed GC的停顿时间百分位数(P50, P90, P99, P999)。
    • GC吞吐量:应用运行时间占比。
    • GC原因:是什么触发了GC(Allocation Failure, System.gc等)。

4.3 性能压测与对比验证

在预发布或隔离环境进行压测,对比升级前后的关键性能数据:

  1. 基准测试:使用像wrk,jmeter等工具模拟生产流量。
  2. 对比指标
    • 吞吐量 (RPS/QPS):是否下降?如果使用ZGC/Shenandoah,轻微下降(<5%)是可接受的,因为换来了极低延迟。
    • 延迟 (P99, P999 Latency)这是最重要的指标。升级到G1/ZGC后,长尾延迟(P99)应该有显著改善。如果延迟反而变差,需要检查参数配置。
    • 系统资源:CPU使用率、系统内存占用。ZGC/Shenandoah的CPU开销可能略高。
  3. 异常情况测试:模拟内存泄漏、流量尖峰,观察GC的行为和应用的恢复能力。

5. 常见问题排查与进阶调优思路

即使按照上述步骤操作,在生产环境仍可能遇到问题。以下是典型的排查路径。

5.1 应用启动失败或立即崩溃

  • 现象Error: Could not create the Java Virtual Machine.Unrecognized VM option
  • 排查
    1. 检查启动参数,首要怀疑是残留的CMS相关参数,如UseConcMarkSweepGC。用java -XX:+PrintFlagsFinal可以查看所有有效参数。
    2. 检查--add-opens等模块化参数是否正确。
    3. 检查环境变量JAVA_HOMEPATH,确保指向的是JDK17。

5.2 频繁Full GC或长时间停顿

  • 现象:监控显示频繁发生Full GC,或者Young/Mixed GC的停顿时间远超预期(如秒级)。
  • 排查顺序
    1. 看GC日志:找到触发Full GC的原因。常见原因是“Evacuation Failure”(晋升失败)或“System.gc()”。
    2. 检查内存分配速率:使用jstat -gc <pid> 1s观察Eden区的增长速度。如果分配速率极高,G1可能来不及回收。考虑优化代码,减少对象创建。
    3. 调整G1参数
      • 晋升失败:尝试增加-XX:G1ReservePercent(如从10调到20),为晋升预留更多空间。或者适当增加堆大小(-Xmx)。
      • 并发模式失败:调低-XX:InitiatingHeapOccupancyPercent,让G1更早启动并发标记。
      • 停顿时间过长:确认-XX:MaxGCPauseMillis设置是否合理(通常200ms)。如果Region太多(堆太大,RegionSize太小),GC时选择收集的Region集合(CSet)可能过大,导致一次回收时间过长。可以考虑适当增大-XX:G1HeapRegionSize
    4. 检查代码:是否存在内存泄漏?使用jmap -histo:live <pid>或Profiler工具(如Async-Profiler, JProfiler)分析堆中对象类型,看是否有异常累积的对象。

5.3 切换到ZGC/Shenandoah后吞吐量下降明显

  • 现象:延迟降低了,但每秒处理请求数(RPS)下降超过10%。
  • 排查
    1. 确认CPU开销:使用系统监控工具(如top,htop)观察应用进程的CPU使用率。ZGC/Shenandoah的读屏障会带来额外CPU指令。
    2. 调整并发线程数:ZGC有-XX:ConcGCThreads参数。默认值可能不适合你的机器。可以尝试调整,但并非越多越好,需要平衡GC和应用线程。
    3. 评估是否值得:对于高吞吐量优先的批处理任务,切换回G1或Parallel可能是更优选择。超低延迟GC的代价就是一定的CPU开销。

5.4 元空间(Metaspace)问题

  • 现象java.lang.OutOfMemoryError: Metaspace
  • 排查:JDK8中的永久代(PermGen)已被元空间取代。元空间使用本地内存,默认上限很大。出现此错误通常是因为:
    1. 应用动态生成大量类(如大量使用CGLIB、ASM、动态代理)。
    2. 部署了多个应用且未使用共享类数据。
    3. 类加载器泄漏:这是最常见原因。某个类加载器(如Webapp ClassLoader)加载的类无法被卸载,导致元空间只增不减。
  • 解决:增加-XX:MaxMetaspaceSize限制并监控其使用情况。更重要的是,使用jcmd <pid> GC.class_statsjmap -clstats <pid>分析类加载器,找到泄漏根源。

5.5 容器环境(Docker/K8s)下的特殊考量

在容器中运行Java应用非常普遍,但JVM早期版本无法感知容器资源限制。

  • 关键参数:在JDK8 update 191+和JDK10+,JVM提供了对容器CPU和内存限制的自动支持。但在JDK17下,为了最佳实践,建议显式设置:
    -XX:+UseContainerSupport # 默认已开启,确保使用 -XX:MaxRAMPercentage=75.0 # 使用容器内存的75%作为堆上限 -XX:InitialRAMPercentage=50.0 # 初始堆大小为容器内存的50%
    不要再使用-Xmx-Xms指定绝对数值,而是使用百分比,让JVM根据容器实际分配的内存来调整。同时,确保容器内存限制设置合理,并预留足够空间给非堆内存(元空间、线程栈、直接内存等)。

升级JDK17并驾驭新的垃圾回收器,是一个从“能用”到“用好”的过程。我的建议是,先在测试环境用G1跑通你的应用,观察监控,理解其行为模式。如果延迟满足要求,G1就是最稳妥的选择。如果追求极致低延迟且资源充足,再尝试ZGC。记住,没有最好的GC,只有最适合你应用场景和资源约束的GC。每一次参数调整,都要有监控数据作为依据,而不是盲目猜测。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询