JDK 8 升到 17 踩了 9 个坑,顺手把 JVM 参数对照表整理出来了

🔑 关键词:JDK17升级, JVM参数对照, MaxRAMPercentage, G1与ZGC, 容器内存

📖 摘要:一次真实的 JDK 8 到 17 升级记录:启动参数逐条对照、容器里堆内存被吃掉一半的原因、反射封装异常怎么解、Lombok 和 maven-compiler-plugin 的版本红线,外加我们压测出来的 G1 与 ZGC 数据。

JDK 8 升到 17 踩了 9 个坑,顺手把 JVM 参数对照表整理出来了

图片

先说我们这套服务的底子,不然下面的数字你没有参照物。

订单履约服务,2019 年 3 月上线,Spring Boot 2.1.6 + MyBatis 3.4.6 + HikariCP,容器 8C / 4Gi,日常 QPS 1200 上下,大促峰值摸到过 6000。跑在 K8s 上,基础镜像是 openjdk:8u212-jre。启动脚本是前一个人留下的:-Xmx3g -Xms3g -XX:MaxPermSize=256m -XX:+UseParallelGC -XX:+PrintGCDetails -Xloggc:/var/log/gc.log。看一眼就知道是从哪篇 2013 年的博客抄的,MaxPermSize 在 JDK 8 上早就不生效了,只会打印一句 ignoring option,没人看日志自然也没人管。

压测那天 P99 干到 3.2 秒,我挂着 jstat -gcutil <pid> 1000 盯了半小时:老年代每分钟涨 1%,期间来了 3 次 Full GC,每次 1.6~1.9 秒。运维说加机器,我说先给我两天时间。两天之后换成 Temurin 17.0.9+9,堆按容器 limit 的比例给,P99 落到 480ms 左右,Full GC 是 0(这个负载下 G1 的 mixed GC 足够用)。下面是完整过程。

一、JVM 参数对照表,能抄就抄

先把最容易翻车的那几条列出来,我们是照着这张表一条条改的:

图片

JDK 8 上的写法 JDK 17 上会怎样 建议改成
-XX:MaxPermSize=256m 启动直接失败,Unrecognized VM option 'MaxPermSize' 删掉,用 -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m
-XX:+UseConcMarkSweepGC 同样不认,CMS 在 JDK 14 被 JEP 363 移除了 默认 G1,或按需换 ZGC
-XX:+UseParNewGC 不认,ParNew 只服务 CMS 删
-Xmx3g -Xms3g 能跑,但容器扩容后没人记得改 -XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=70
-XX:+PrintGCDetails -Xloggc:... 失效,输出为空 -Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=5,filesize=50M
-XX:+UseStringDeduplication 能用,但只在 G1 下有意义 保留,前提是你已经切到 G1

关于 MaxRAMPercentage 我得多说两句,这条是这次收益最大的一条,而且跟 JDK 版本没关系。它在 JDK 10 引入,JDK 8u191 之后 backport 回了 8,默认值是 25%。什么意思呢,你容器 limit 给 4G,什么参数都不配,JVM 就心安理得地只拿 1G 堆,剩下 3G 就那么空着给你看。我见过好几个团队天天喊 OOM,最后发现是这么回事。改成 70% 之后,同一份镜像同一个 limit,堆从 1G 变成 2.8G。这一步你不升级 JDK 也能做,而且当天就能见效。

还有 GC 日志那个参数,-Xlog 的语法跟以前的 -Xloggc 完全不是一个东西,别想着加个 * 就完事。后面那串 filecount=5,filesize=50M 是滚动策略,不写的话大促一晚上能把宿主机磁盘写满,我们隔壁组就这么干过一次,凌晨三点起来删日志。

二、真正卡住你的不是 JDK,是你依赖的那堆东西

图片

JDK 8 是 2014 年 3 月发布的,JDK 17 是 2021 年 9 月。中间隔了七年。七年里 Java 生态的构建工具链换了好几代,你的代码可能一行没动,但 ASM、ByteBuddy、CGLIB、Lombok、Netty 这些东西只要有一个卡在 2019 年之前的版本,升级就变成一次人品测试。

判断方法很简单,跑一句 mvn dependency:tree | grep -E 'asm|byte-buddy|cglib|lombok|netty'。这里面 2019 年以前发版的,基本都要动。我们踩到的具体几个:

1. 模块封装导致的反射异常。 JDK 16 开始 --illegal-access 默认变成 deny,JDK 17 通过 JEP 403 彻底强封装。我们用的老版本 ORM 在启动时会反射改 String.value,直接抛 Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module。解法是加 --add-opens java.base/java.lang=ALL-UNNAMED。但我要提醒一句,别一上来就 --add-opens java.base=ALL-UNNAMED 一把梭,那样只是把问题盖住了,等哪天依赖升级你都不知道哪些能摘掉。

2. Lombok。 1.18.20 及以下在 JDK 17 上编译直接挂,报 java.lang.IllegalAccessError: class lombok.javac.apt.LombokProcessor。升到 1.18.24 就好了,现在建议直接 1.18.30+。

3. maven-compiler-plugin。 我们用的是 3.5.1,编译报 invalid target release: 17。换 3.11.0 解决,现在社区常用 3.13.0。这个坑的特点是报错信息完全不提插件版本,新手容易以为是 JDK 装错了。

图片

4. Netty。 4.1.60 以前在 JDK 17 上会有一堆反射告警,配合 Spring Boot 2.1.6 的话,升 Spring Boot 到 2.7.x 顺带就把 Netty 带到 4.1.86 了,一起做省事。

5. 编码。 这条特别容易被忽略。JDK 17 的 file.encoding 还是跟着系统 locale 走,JDK 18 才通过 JEP 400 改成默认 UTF-8。我们的容器基础镜像里 locale 是 POSIX,落进去 file.encoding 拿到的是 ANSI_X3.4-1968,也就是 ASCII,日志里的中文直接变问号。启动参数里老老实实加 -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8,别省。

三、G1 还是 ZGC,把我们的数据摊开说

ZGC 是 JDK 11 作为实验特性进来的(JEP 333),JDK 15 转正(JEP 377),JDK 21 支持分代(JEP 439)。很多人一升级就想直接上 ZGC,我们的结论是:分场景。

我们拿同一份代码在 8C16G 的压测机上跑了两轮,数据是自己的,不具普遍性,就当个参考:G1 跑到 9800 TPS,P99 是 42ms,P99.9 是 145ms;ZGC 是 9200 TPS,P99 是 28ms,P99.9 是 12ms。吞吐掉了大概 6%,尾延迟确实好一截。

图片

所以判断逻辑其实很朴素:如果你的服务是交易、支付这种卡单笔延迟的,ZGC 值得试;如果是批量对账、报表导出这种拼吞吐的,G1 更划算。别为了简历上多写一行 ZGC 就上,ZGC 吃内存也是真的,堆小了它效果出不来。

另外提一嘴 -XX:+AlwaysPreTouch。我们加了,启动时间从 12 秒变成 19 秒,但运行期少了页分配抖动,P99 更平。要不要加看你重启频率,一天重启八次的服务就别加。

四、什么情况下我劝你别升

这话可能不太讨喜,但升级不是政治正确。

图片

如果你们还在用 Hadoop 2.x、Spark 2.x 这一套,别碰 JDK 17,光是 IllegalAccess 就够你喝一壶,社区那套兼容方案还在半路上。如果代码里有大量 JNI 调用、有自己写的 native 库,先确认那些 so 文件编译时的 JDK 版本,不然运行期崩了你连栈都看不全。还有最现实的一条:如果团队里没人愿意在升级后面那两周盯着监控值班,那这次升级最好排到一个业务淡季再去干。

我见过最惨的一次是某团队周五下午升的,周一早上发现定时任务全没跑,原因是 Quartz 用的老版本反射拿不到 Thread 的私有字段。周五下午动生产环境这种事,怎么说呢。

五、最后碎碎念

我自己的感受是,JDK 8 到 17 这七年里,Java 变化最大的其实不是语言特性,是运行时的默认行为。默认 GC 从 Parallel 换成 G1 了(JEP 248,JDK 9 生效),字符串常量池、模块系统、Unsafe 的可用范围,全在悄悄变。这些默认值的变化不会在你的 diff 里出现,但会在某个凌晨三点的告警里出现。

所以升级前,先把旧参数一条条对照着过一遍,把 jcmd <pid> VM.flags 的输出来存个档,出问题的时候至少知道原来是什么样。这个习惯比记住任何一个具体参数都值钱。

🏷️ 标签: