从 Java 21 升级到 Java 25:真正会出问题的地方
大多数“Java 25 新特性”的文章讲的都是你获得了什么:作用域值(Scoped Values)、灵活的构造函数体、紧凑源文件、Stream Gatherers。而这篇文章讲的是 LTS 到 LTS 升级的另一面——那些不再能正常工作的东西。
Java 21 和 Java 25 之间隔着四年的版本发布,21 中被废弃的某些东西到 25 已经被移除。其中一些会大声失败,直接卡在启动阶段。另一些则悄无声息地失败,后者更糟:构建是绿的,应用能启动,结果一周之后 bug 才冒出来。
下面的每个例子都在 JDK 21.0.7 和 JDK 25.0.3 上编译并运行过,你看到的输出就是这两个 JVM 打印出来的内容。我只缩短了文件路径并去掉了日志时间戳。每一节都附上了对应的 JEP 或发行说明链接。
1. 注解处理器不再运行,而且没有任何提示
这一条最有可能击中典型的 Maven 项目,因为它的失败不会报错。
在 JDK 22 之前,javac 会在类路径上查找注解处理器,并运行它找到的任何东西。从 JDK 23 起,它默认不再这么做:只有当你显式配置处理器(-processor、--processor-path、--processor-module-path),或用 -proc:full 主动要求时,处理器才会运行。
为了复现这一点,我构建了一个除了打印一条提示什么都不做的处理器,把它连同 META-INF/services 条目打包成 JAR,然后把该 JAR 放在类路径上编译一个普通类:
在 JDK 21 上,处理器会运行,并且 javac 会警告你这一行为即将改变:
在 JDK 25 上,同样的命令什么都不打印,退出状态为 0。处理器根本没运行。
对于 Lombok 来说,这意味着它本应生成的每个 getter 都会报 “cannot find symbol” 错误,所以至少你还能察觉。而对于那些生成可选内容的处理器,比如元数据文件、服务注册表,或者通过反射被加载的 mapper 实现,你可能要到运行时才会发现。
修复方式就是声明你的处理器。在 Maven 中,把它们列在 maven-compiler-plugin 配置的 annotationProcessorPaths 下,或者设置 <proc>full</proc>(用户属性 maven.compiler.proc),在支持该选项的插件版本上恢复旧行为。Gradle 自 Gradle 5.0 起就把处理器放在独立的 annotationProcessor 配置中,因此 Gradle 构建通常不受影响。
如果你的 JDK 21 构建日志里含有那条 “Annotation processing is enabled because...” 提示,那你就是受影响的对象。
2. 安全管理器没了,而且一个标志就能让 JVM 起不来
安全管理器在 Java 17 中被标记为待移除。在 JDK 24 中,JEP 486 将其永久禁用:API 还在,但不能再启用。
看看这个类:
在 JDK 21 上,加上 -Djava.security.manager=allow,它会安装安全管理器(并附带弃用警告)。在 JDK 25 上,JVM 根本走不到 main:
不加该标志时,setSecurityManager 现在会抛出 UnsupportedOperationException: Setting a Security Manager is not supported。
所以,请搜索你的启动脚本、Dockerfile、systemd 单元和 IDE 运行配置中的 java.security.manager。现在只有 -Djava.security.manager=disallow 仍然被接受。
还有一处会击中那些从未用过安全管理器的人:Subject.getSubject(AccessControlContext)。较老的认证代码、Kerberos 集成以及一些 Hadoop 时代的库至今仍在调用它:
在 JDK 21 上,没有 subject 时它会打印 null。在 JDK 25 上:
替代方案 Subject.current() 和 Subject.callAs(...) 从 Java 18 起就已可用,因此你可以趁还在 21 上时迁移那段代码。
3. 被移除的 Thread 方法:你的 catch 救不了你
Thread.suspend()、Thread.resume()、ThreadGroup.suspend()、ThreadGroup.resume() 和 ThreadGroup.stop() 在 JDK 23 中被移除(而 Thread.countStackFrames() 在 JDK 22 中被移除)。在 Java 21 上,它们本来就已经不干有用的事了:无条件抛出 UnsupportedOperationException。于是有些代码就变成了这样:
用 JDK 21 编译,然后分别在两边运行:
该方法已不存在,因此 JVM 抛出 NoSuchMethodError,它是一个 Error,而不是那段代码所准备好的异常。在 25 上重新编译你自己的代码能发现这个问题(cannot find symbol),但重新编译对多年前构建的依赖 JAR 无济于事。第 8 节会讲如何找出它们。
4. java.locale.providers=COMPAT 被忽略,只留下一行日志
自 JDK 9 起,区域设置数据来自 Unicode CLDR。旧的 JRE 数据以 COMPAT 的形式保留下来,而当 CLDR 改变了日期格式时,很多团队会把它打开——尤其是在 CLDR 42(JDK 20)之后,它在英文时间格式的 “AM/PM” 前加了一个窄的不换行空格(U+202F),导致用普通空格键入的字符串解析失败。
JDK 23 移除了 COMPAT 数据。该标志不会失败,只是记录一行日志然后被忽略:
以 -Djava.locale.providers=COMPAT,CLDR 运行:
同样的标志、同样的代码,JDK 25 格式化出的日期多了一个逗号,并且在 PM 前多了一个不可见字符,而且解析失败。数字格式也一样。对于 de-CH,在我的运行中分组分隔符在 COMPAT 下是 ',而在 CLDR 下是 '(U+2019)。
不要把本地化格式用于任何会被机器读回的内容。对于你存储、交换或解析的数据,请使用显式模式(DateTimeFormatter.ofPattern("MMM d, yyyy h:mm:ss a", Locale.US))或 ISO-8601。如果你需要旧模式,JEP 252 页面已更新了迁移建议。
单就 PM 前的空格而言,JDK 23 还增加了宽松解析中对空格的宽松匹配。java.time 的解析器默认是严格的,所以你必须主动要求:
用严格格式化器时,同一个字符串在 JDK 25 上会在索引 20 处失败。不过,宽松解析并不会恢复旧模式:年份后的那个逗号仍然是必需的。
5. 来自 sun.misc.Unsafe 和 JNI 的新警告
这些目前还不会破坏任何东西,但它们会改变你日志的样子,并且预示了下一步什么会坏掉。
sun.misc.Unsafe 内存访问。 它的内存访问方法在 JDK 23 中被标记为待移除(JEP 471),而从 JDK 24 起,第一次调用会打印警告(JEP 498)。下面这个小程序在堆外分配 8 个字节,写入一个 long 再读回来:
在真实应用中,调用者通常是某个库(序列化、堆外缓存、较老的网络代码),而警告会告诉你具体是哪一个。JEP 498 计划在后续版本中让这些调用抛出异常,因此请检查该库的新版本是否已迁移到 VarHandle 或 FFM API 的 MemorySegment。
JNI。 从 JDK 24 起(JEP 472),在未启用原生访问的情况下加载本地库或绑定 native 方法也会打印警告:
如果你依赖 JNI(带原生部分的数据库驱动、压缩、加密、平台集成),请添加 --enable-native-access=ALL-UNNAMED(或者明确列出你的模块),或者在可执行 JAR 的清单里加入 Enable-Native-Access: ALL-UNNAMED。
我的建议是在 25 上以两个开关的最严格值运行你的测试,这样 CI 现在就失败,而不是等到默认值改变的那天:
有了这两个开关,同样的程序会立刻失败,分别抛出 UnsupportedOperationException: allocateMemory 和 IllegalCallerException: Illegal native access from an unnamed module。
6. 如今会阻止 JVM 启动或被悄悄忽略的启动标志
JVM 选项会在脚本里逐年累积。以下是我实测在 25 上行为不同的那些:
-t、-tm、-checksource、-cs 和 -noasyncgc 在 JDK 24 中被移除,-Xfuture 也是,它们的失败方式相同。如果你用过 -Xfuture,发行说明建议改用 -Xverify:all。
ZGC 那一行需要仔细看看,因为并没有任何东西失败。在 Java 21 上,单独使用 -XX:+UseZGC 得到的是传统的单代模式。分代 ZGC 在 JDK 23 成为默认(JEP 474),而单代模式在 JDK 24 中被移除(JEP 490)。GC 日志会显示这一点:
引入分代 ZGC 的 JEP 439 认为它“对大多数用例是更好的方案”,但它的行为确实不同。如果你在 21 上调整过堆大小或调优过 ZGC,那么升级后请在负载下重新检查内存使用和暂停时间。
7. 边角锋利的小改动
发行说明里还有几个容易漏掉的点。
Windows 上的 File.delete() 不再删除只读文件(JDK 25,JDK-8355954)。它现在返回 false,而不是先清除只读属性:
那些忽略 delete() 返回值的清理代码——也就是大多数清理代码——会把文件留在磁盘上。-Djdk.io.File.allowDeleteReadOnlyFiles=true 可以在你修复代码期间恢复旧行为(自己清除属性,或者改用 Files.delete,它会抛异常而不是返回 false)。
TLS 默认值更严格了。 JDK 24 禁用了 TLS_RSA_* 密码套件,JDK 25 禁用了 TLS 1.2 握手签名中的 SHA-1。连接到仅提供这些算法的老旧内部服务器、设备或遗留合作伙伴端点,现在可能失败并抛出 SSLHandshakeException。请在升级前测试这些连接。你可以在 java.security 中重新启用这些算法,风险自负。## 8. 在投入生产前发现所有这些问题
以下是我会按顺序执行的检查清单:
-
查看 JDK 21 的构建输出。 如果你受到影响,第 1 节提到的注解处理提示已经会出现在那里。另外,使用
-Xlint:deprecation,removal编译,并阅读[removal]警告。 -
使用当前的 JDK 21 运行
jdeprscan --for-removal,扫描你的应用程序及其依赖 JAR。它会列出你使用到的所有已弃用并计划移除的内容,包括Thread.suspend。在我的测试中,即使加上--release 21,JDK 25 的jdeprscan也没有报告Thread.suspend,所以在切换之前请运行 JDK 21 的版本。下面是针对本文中的类运行该命令的结果,这些类使用 JDK 21 编译到
classes/:对于真实项目,请将
--class-path指向你的依赖项,并将你的 JAR 或 classes 目录作为最后一个参数传入。 -
运行
jdeps --jdk-internals,找出谁在使用sun.misc.Unsafe和其他内部 API。 -
在启动配置中执行 grep(脚本、Dockerfile、Helm chart、
JAVA_TOOL_OPTIONS、JDK_JAVA_OPTIONS),查找java.security.manager、java.locale.providers、-Xfuture、-Xnoagent和ZGenerational。 -
在 25 上运行完整测试套件,并加上
--sun-misc-unsafe-memory-access=deny --illegal-native-access=deny;同时阅读真实部署的启动日志,查找之前没见过的WARNING:行。 -
阅读 22、23、24 和 25 发行说明中的“Removed Features and Options”和“Other Notes”部分。 它们很长,但破坏性变更只占其中一小部分。
如果你只有时间检查三项,请先检查注解处理器、COMPAT 标志和只读文件。这三者都不会中止构建或应用:处理器只是不运行,COMPAT 只会在启动时留下一行日志,而 delete() 会返回 false。