我把一个在 Java 17 上跑了快两年的线上服务升级到了 Java 27。说实话,这次升级最让我意外的不是新特性有多惊艳,而是一大堆默认值在我没动手的情况下悄悄变了。真正让我加班到凌晨的,不是虚拟线程也不是结构化并发,而是启动脚本里一行从 Java 6 时代就留着的老参数,在 Java 27 上直接让 JVM 起不来。
如果你也要做类似的跨版本升级,或者你正被各种“Java 面试题”“JVM 调优”资料绕晕,这篇实测记录应该能帮你少踩几个坑。我不打算罗列版本更新日志,重点讲清楚升级过程中被默认值“背刺”的过程、那个老参数的排查思路,以及一套升级前就能用上的 JVM 参数审计方法。
1. 升级背景:Java 27 到底带来了什么
1.1 语言与运行时新特性梳理
先说结论:Java 27 的语言层面新特性并不算多,大部分亮点集中在运行时和并发模型上。对我们这种老服务来说,最值得关注的是虚拟线程从预览走向稳定,结构化并发也在后续版本里变得可用,再加上分代 ZGC 逐渐成为默认的垃圾回收策略,很多以前靠堆大小硬扛的场景,现在有更优雅的解法。
但“有新特性”和“能直接用”是两回事。我这次升级的主要目标其实很简单:想借虚拟线程优化一批 IO 密集型的接口,顺便看看分代 ZGC 能不能让 GC 停顿再降一档。结果真正影响上线的,反而是那些不在 release notes 首页的小改动,尤其是默认值的调整。它们不像新特性那样有专门章节介绍,但会静默改变你的程序行为。
1.2 为什么说默认值比新特性更值得关注
新特性是增量,默认值是存量。增量可以不用,存量的变化躲不掉。举个例子,我在升级前仔细读过新特性文档,但没注意“javac 默认以当前版本为目标字节码版本”这条细节。上线当天,持续集成里有人用老脚本直接 javac 编译,不带任何 -source/-target 参数,结果产物从 Java 17 的 class 版本直接变成了 27。
如果只是编译脚本倒还好,真正麻烦的是运行时行为变化:默认编码、默认堆内存估算、垃圾回收器参数、CDS 归档行为、安全协议默认值,这些全部牵一发动全身。很多老项目能稳定跑几年,靠的不是代码写得有多好,而是 JVM 的默认行为一直没变。跨一个大版本升级,等于把你包装好的稳定环境重新拆开,所有“没写死就等于默认”的地方都要重新确认一遍。
2. 升级实测:默认行为变化逐项核对
2.1 编译与运行时的默认版本变化
最先碰到的是编译版本。Java 17 时代,javac 默认的 source 和 target 是 17,升级到 Java 27 后,不指定参数时默认就是 27。如果你有那种“不指定版本,全靠 IDE 默认配置”的项目,升级后大概率会遇到UnsupportedClassVersionError,或者是热词里很常见的那条警告:java: 警告: 源发行版 17 需要目标发行版 17。
我的处理建议是:跨版本升级后,编译参数里不要再省略版本。要么用-source 17 -target 17,要么直接用--release 17。后者更推荐,因为它不仅管 source 和 target,还会自动把 JDK 27 的新 API 挡在外面,防止你无意中编译出只能在 27 上跑的代码。实测中,我们的一些老模块在没加--release前,编译和运行都正常,但 IAM 系统里引用的一个新 API 被编进了低版本目标,线上报 NoSuchMethodError,排查了很久才定位到是编译版本问题。
另一个容易忽略的是运行时 class 文件版本。Java 27 能识别更早版本的 class 文件,但反过来不行。升级完 JVM 后,如果有第三方 Agent 或者字节码增强库还在用老旧的 ASM 版本,它们解析不了 27 版本的类,会直接抛IllegalArgumentException: Unsupported class file major version。这个不属于默认值,但属于升级后的连锁反应,最好在压测阶段就扫一遍所有依赖。
2.2 内存模型相关的默认值:堆、元空间、直接内存
JVM 内存模型这块,我做了个简单的默认值对照,方便大家直观感受变化。以下默认值主要基于 JVM 的 ergonomics 规则,不是我瞎编的,升级后用-XX:+PrintFlagsFinal可以直接验证:
| 配置项 | 升级到 Java 27 后的默认行为 | 注意事项 |
|---|---|---|
| 最大堆 | 物理内存的 1/4,容器环境按 cgroup 识别 | 老版本如果显式设过 -Xmx,需要注意容器内存上限是否合理 |
| 初始堆 | 物理内存的 1/64 | 启动后有数据写入时,堆会逐渐扩容,可能造成早期 GC 频繁 |
| 元空间 | 默认不设上限,受系统可用内存影响 | 类加载过多的老应用,升级后可能占用更多内存 |
| 直接内存 | 默认等于 -Xmx | 如果业务大量用 NIO/Netty,需要单独评估 MaxDirectMemorySize |
| 线程栈 | 默认 1MB(64位系统) | 线程数多的应用,这个默认值要纳入内存预算 |
这里有个比较坑的细节:容器环境下,JVM 对可用内存的识别在近几个版本一直在改。老服务以前用-Xmx4g -Xms4g显式约束还好,但如果哪次重构把这两个参数丢了,JVM 就按宿主机物理内存的 1/4 去估算,在容器里很容易分配出一个大于容器限制的堆,表现就是没跑多久就 OOMKilled。
我在实测中画了一条计算公式:触发容器 OOM 的理论界限 ≈ Xmx + MaxDirectMemorySize + 线程数 × 1MB + Metaspace + 系统预留。如果容器只有 8GB,堆设 6GB,直接内存又是 6GB,那已经超出了,Netty 压力一上来进程就没了。别以为这是低概率,我们压测一轮就复现了。
2.3 GC 与日志相关默认值变化
Java 27 里分代 ZGC 已经算完全体了,G1 依旧是比较稳妥的默认回收器。但升级后我注意到,默认的 GC 日志输出方式和老版本差别极大。老版本用-Xloggc:/data/logs/gc.log -XX:+PrintGCDetails这种“老一套”参数还行,新版本里这套参数要么被移除了,要么会产生大量告警。
我自己更推荐统一改成新式日志:-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags。这条配置等价于以前开 GC 详情日志,但是内容和格式更结构化,配合 GCeasy 等工具做分析会省力很多。升级过程中最大的风险不是配置本身,而是没人注意到老参数在新 JVM 上已经失效,结果线上配了日志路径,文件却不更新,等到排查 GC 问题时才发现所有历史日志都是空的。
GC 默认行为的另一个变化是 Full GC 的触发方式。老版本很多服务针对 G1 做了各种调参,比如-XX:G1NewSizePercent、-XX:G1MaxNewSizePercent,这些参数在新的 G1 实现里表现不一定一样。升级后最稳妥的做法不是直接搬参数,而是先看几分钟 GC 日志,确认 YGC 和 Full GC 的频率和停顿值,再决定要不要调。
2.4 升级对业务的可能影响评估
把这些默认值变化翻译成业务语言,就是三句话:批量任务可能变慢,因为堆扩容节奏和 GC 策略变了;接口改造可能踩坑,因为编译目标和依赖库版本变了;运维监控可能失灵,因为日志和 JMX 相关配置变了。
影响范围不是只停留在应用层。我给自己的升级评估列过一个清单:编译链工具(Maven/Gradle 的 JDK 版本)、运行环境(Docker 镜像里的 JDK 路径)、中间件客户端(Netty、gRPC、Kafka client)、字节码工具(CGLIB、ASM、Agent)、监控面板里的 GC 和内存指标含义。任何一环脱节,上线后都可能变成半夜的告警电话。
3. 一个老参数让 JVM 直接起不来
3.1 现场记录:启动脚本与报错
这次升级最戏剧性的部分来了。我把启动命令从 Java 17 的 JVM 参数直接搬到 Java 27 后,进程刚启动就退出,标准错误输出就两行:
Unrecognized VM option 'UseBiasedLocking' Error: Could not create the Java Virtual Machine. Error: A fatal exception has occurred. Program will exit.一开始我以为是参数拼错了,结果一看启动脚本,赫然躺着一行-XX:+UseBiasedLocking。这个参数从 Java 6 时代就有了,当年偏向锁是默认开启的,很多团队为了规避某些多线程竞争场景会手动加这一行。后来偏向锁因为维护成本高,在 JDK 15 被默认关闭,JDK 17 时选项虽然存在但已经不推荐使用,到 Java 26、27 这个选项直接被移除了,再传进去 JVM 根本不给面子,直接拒绝启动。
3.2 排查步骤:从日志到逐项隔离
遇到这种“以前能启动,现在启动不了”的问题,我建议按这个顺序排查:
第一,把启动命令单独拿出来,跑到最简形式。比如用java -XX:+UseBiasedLocking -version,不带业务类路径,也不带应用主类。如果这样都报 Unrecognized VM option,那问题就锁定在 JVM 参数本身,而不是业务代码。
第二,用二分法隔离可疑参数。把启动脚本里的-XX:参数全部摘出来,每次加一半,跑一次-version,很快就能定位到哪一行“有毒”。不要靠猜,尤其是老服务里可能攒了十几个历史遗留参数。
第三,确认报错出自哪个依赖。如果报错不是 Unrecognized,而是别的错误,还要考虑启动器是不是被替换过。比如某些 APM Agent 会插入自己的参数校验逻辑,让本来合法的参数变成非法。
我当时只用了第一步就锁定了,因为-XX:+UseBiasedLocking在 Java 27 上确实从参数清单里消失了。这不算 bug,属于清理历史包袱。
3.3 为什么以前能跑,现在不能跑
很多人会问,一个选项被移除就移除,为什么以前那么多年都没事?答案在于参数从废弃到移除有个过程。偏向锁在 JDK 15 默认关闭后,选项本身还能被 JVM 识别,只是不产生任何实际作用。所有老启动脚本都能正常跑,因为 JVM 对“已废弃但不认识的参数”采取的是忽略或警告策略。
但 Java 27 显然不想再背这个包袱了。参数移入“未识别”区域后,JVM 的策略变成直接抛错。这个设计逻辑其实很好理解:如果继续默默忽略,用户永远不会知道自己配的参数是无效的,等到某天依赖了错误行为,问题反而更大。直接让 JVM 起不来,反而是在强迫你面对参数清理这件事。
从升级角度看,这类“最老却最坑”的参数不止偏向锁一个。CMS 回收器的相关参数、旧式 GC 日志参数、PermGen 容量参数,都在不同版本被移除或废弃。老项目升级前,最好先做一次参数全面审计,别等启动报错再去找是哪一个。
4. 升级前 JVM 参数审计与快速验证
4.1 找出所有 -XX 参数:从启动脚本到在线确认
既然升级的锅大部分出在 JVM 参数上,那升级前就应该做一次全面盘账。第一步是静态扫描启动脚本,把所有-XX:开头的参数、JVM 相关系统属性(-D开头)、内存和 GC 参数(-Xms、-Xmx、-Xlog*)全部列出来。
第二步是动态核对实际生效参数。线上服务如果还活着,可以用jcmd <pid> VM.flags -all拿到 JVM 当前活跃参数,也可以配合jinfo -flags <pid>看用户显式设置过的参数。这个对比很有价值,因为很多启动脚本里写了参数,但被后面的参数覆盖了,或者根本没被 JVM 识别,你以为在调优,实际 JVM 压根没接招。
我用一个简单脚本把最近一次线上配置的 user-defined 参数抓了出来:
jcmd $PID VM.flags -all | grep -E "command line|^[a-zA-Z].*=" | sort重点看command line片段,里面记录的是 JVM 从命令行真实收到的参数,与启动脚本一对就清楚哪些是冗余,哪些是无效。升级前把这些冗余参数清掉,升级就不会被各种“老参数新增的启动报错”打断节奏。
4.2 用 -version 做启动参数冒烟测试
参数审计完,不要直接重启,先做冒烟测试。最实用的技巧就是命令层面验证:
java ${原来启动脚本里的JVM参数} -version-version会让 JVM 初始化但不去执行业务代码,足够触发所有 VM 参数的合法性校验。参数有任何一个不认识的、非法的,立刻就能看到报错。它能覆盖参数级问题,但覆盖不了参数之间的组合冲突以及业务代码对参数的依赖,所以只能当“第一道闸”。
我这次在老参数排查里就用了这个办法。把包含-XX:+UseBiasedLocking的完整 JVM 参数串接上-version,跑一遍直接报 Unrecognized,三秒钟定位问题。把参数删掉后再跑一遍,正常输出 Java 版本信息,说明参数层面已经没有致命问题。
4.3 基于测试结果整理参数迁移对照表
冒烟测试过程中,我整理了一张“旧参数—新状态—处理建议”的对照表,贴在下面供参考:
| 老参数 | Java 17 时的状态 | Java 27 实测状态 | 处理建议 |
|---|---|---|---|
| -XX:+UseBiasedLocking | 废弃但不报错 | 不可识别,JVM 启动失败 | 删除,默认已关闭 |
| -XX:+UseConcMarkSweepGC | 已移除,启动报错 | 不可识别 | 改用 G1 或 ZGC |
| -Xloggc:/path/gc.log | 警告但可用 | 推荐使用新式 -Xlog 替代 | 迁移到 -Xlog:gc*:file= |
| -XX:MaxPermSize | 已被忽略 | 不可识别 | 用 -XX:MaxMetaspaceSize |
| -XX:+PrintGCDetails | 警告但可用 | 新式日志框架接管 | 用 -Xlog:gc* 替代 |
| -Dfile.encoding= | 合法 | 合法,但默认编码已变 UTF-8 | 业务有乱码风险时显式设置 |
这张表不完整,但能代表很大一部分老项目会遇到的情况。更完整的做法是拿生产启动脚本直接跑一遍-version,把报出的每一个 Unrecognized 参数都查一遍官方推荐,而不是自己硬猜怎么改。
5. 常见问题速查与避坑心得
5.1 常见升级报错速查表
升级过程中不止碰到老参数问题,还有几个频繁出现的报错,我整理成一个速查表:
| 报错现象 | 常见原因 | 参考解法 |
|---|---|---|
| Unrecognized VM option 'xxx' | 参数已废弃/已移除 | 用-version定位并删除,查询官方替代项 |
| UnsupportedClassVersionError | 编译产物版本高于运行时 JVM | 用--release统一编译版本 |
| No JVM could be found on your system | JAVA_HOME 未配置或指向旧路径 | 更新 JAVA_HOME/PATH,确认 JDK 是否完整安装 |
| No suitable JVM was found to start the application | 启动器按平台规则找不到适配 JVM | 检查位数、安装目录、注册表残留 |
| java.lang.reflect.InvocationTargetException | 依赖 AOP/字节码框架版本过旧 | 升级 ASM/CGLIB/Spring 相关库 |
| 文件读取中文乱码 | 默认编码从平台编码变为 UTF-8 | 显式指定-Dfile.encoding=UTF-8 |
| 容器内频繁 OOMKilled | Xmx 和容器内存上限不匹配 | 用 MaxRAMPercentage 控制堆和堆外总预算 |
| 启动成功但 GC 日志目录为空 | 旧式 GC 日志参数失效 | 统一迁移到-Xlog:gc*:file= |
这张表不可能覆盖所有项目,但至少能帮你在遇到相似问题时快速缩小范围。注意,JVM 报错的信息有时候并不直接指到根因,比如在老项目里频繁出现Error occurred during initialization of VM,后面的提示一定不要漏看,它会把具体是哪个参数、哪类内存分配失败写得很清楚。
5.2 几个值得长期养成的升级习惯
踩完这一轮坑,我总结出三个习惯,特别适合跨大版本升级时执行。
第一个习惯是“每升一个大版本就把 JVM 参数完整重写一遍”,而不是从旧版本复制过来。Java 17 到 27 隔着十多个小版本,中间有大量参数语义调整,旧参数可能依旧有效,但实现已经完全不同,复制过来等于把一个黑盒搬进另一个黑盒。
第二个习惯是“升级测试里必须包含参数合法性和默认值核对”。常规的回归测试只会验证业务功能,不会管 JVM 参数有没有失效。把java ${JVM参数} -version以及java -XX:+PrintFlagsFinal -version放进 CI,一旦有人加了废参数,构建阶段就会被拦下来。
第三个习惯是“升级窗口内准备一套回滚参数包”。不只是代码回滚,JVM 参数也要能一键回滚。我这次虽然只改了一行,但生产环境的启动脚本往往被多个团队改过,回滚时如果不连同参数一起回滚,很可能出现“代码回滚了,JVM 还跑着新版参数”的割裂状态,问题定位会更难。
回到最初那个老参数,最后我只做了一件事:把-XX:+UseBiasedLocking删掉。因为它在 Java 15 以后就是默认关闭的,删除后对业务没有任何影响。这里有个小经验值得单独说:很多老参数在版本的演进中早就变成了“默认值本身”,它们存在的意义只是让旧启动脚本看起来更专业,实际已经在空转。删除它们不是冒险,而是替系统减负。升级期间如果你也遇到类似问题,别急着找兼容方案,先确认这个参数在当前版本是不是已经被某个默认值取代,很多时候“删掉”就是最优解。