Ohhnews

分类导航

$ cd ..
foojay原文

从 Java 21 升级到 Java 25:真正会出问题的地方

#java#jdk升级#向后兼容#注解处理器#api移除

大多数“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 放在类路径上编译一个普通类:

javac -cp hello-processor.jar -d out App.java

在 JDK 21 上,处理器会运行,并且 javac 会警告你这一行为即将改变:

Note: Annotation processing is enabled because one or more processors were found
  on the class path. A future release of javac may disable annotation processing
  unless at least one processor is specified by name (-processor), or a search
  path is specified (--processor-path, --processor-module-path), or annotation
  processing is enabled explicitly (-proc:only, -proc:full).
  Use -Xlint:-options to suppress this message.
  Use -proc:none to disable annotation processing.
Note: HelloProcessor ran

在 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 还在,但不能再启用。

看看这个类:

public class Sm {
    public static void main(String[] args) {
        System.out.println("security manager: " + System.getSecurityManager());
        try {
            System.setSecurityManager(new SecurityManager());
            System.out.println("installed: " + System.getSecurityManager());
        } catch (UnsupportedOperationException e) {
            System.out.println(e);
        }
    }
}

在 JDK 21 上,加上 -Djava.security.manager=allow,它会安装安全管理器(并附带弃用警告)。在 JDK 25 上,JVM 根本走不到 main:

$ java -Djava.security.manager=allow Sm
Error occurred during initialization of VM
java.lang.Error: A command line option has attempted to allow or enable the Security Manager. Enabling a Security Manager is not supported.
	at java.lang.System.initPhase3([email protected]/System.java:1970)

不加该标志时,setSecurityManager 现在会抛出 UnsupportedOperationException: Setting a Security Manager is not supported。

所以,请搜索你的启动脚本、Dockerfile、systemd 单元和 IDE 运行配置中的 java.security.manager。现在只有 -Djava.security.manager=disallow 仍然被接受。

还有一处会击中那些从未用过安全管理器的人:Subject.getSubject(AccessControlContext)。较老的认证代码、Kerberos 集成以及一些 Hadoop 时代的库至今仍在调用它:

System.out.println(Subject.getSubject(AccessController.getContext()));

在 JDK 21 上,没有 subject 时它会打印 null。在 JDK 25 上:

Exception in thread "main" java.lang.UnsupportedOperationException: getSubject is not supported
	at java.base/javax.security.auth.Subject.getSubject(Subject.java:277)

替代方案 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。于是有些代码就变成了这样:

Thread worker = new Thread(() -> {});
try {
    worker.suspend();
} catch (UnsupportedOperationException e) {
    System.out.println("caught: " + e);
}

用 JDK 21 编译,然后分别在两边运行:

# JDK 21
caught: java.lang.UnsupportedOperationException

# JDK 25, same class file
Exception in thread "main" java.lang.NoSuchMethodError: 'void java.lang.Thread.suspend()'
	at Legacy.main(Legacy.java:5)

该方法已不存在,因此 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 数据。该标志不会失败,只是记录一行日志然后被忽略:

var fmt = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(Locale.US);
String formatted = fmt.format(LocalDateTime.of(2026, 10, 8, 14, 30));
System.out.println("format -> " + formatted.replace(" ", "<U+202F>"));
System.out.println("parse  -> " + LocalDateTime.parse("Oct 8, 2026 2:30:00 PM", fmt));

以 -Djava.locale.providers=COMPAT,CLDR 运行:

# JDK 21
WARNING: COMPAT locale provider will be removed in a future release
format -> Oct 8, 2026 2:30:00 PM
parse  -> 2026-10-08T14:30

# JDK 25
INFO: Invalid locale provider adapter "COMPAT" ignored.
WARNING: COMPAT locale provider has been removed
format -> Oct 8, 2026, 2:30:00<U+202F>PM
parse  -> DateTimeParseException: Text 'Oct 8, 2026 2:30:00 PM' could not be parsed at index 11

同样的标志、同样的代码,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 的解析器默认是严格的,所以你必须主动要求:

var lenient = new DateTimeFormatterBuilder()
        .parseLenient()
        .append(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM))
        .toFormatter(Locale.US);

LocalDateTime.parse("Oct 8, 2026, 2:30:00 PM", lenient); // plain space: 2026-10-08T14:30

用严格格式化器时,同一个字符串在 JDK 25 上会在索引 20 处失败。不过,宽松解析并不会恢复旧模式:年份后的那个逗号仍然是必需的。

5. 来自 sun.misc.Unsafe 和 JNI 的新警告

这些目前还不会破坏任何东西,但它们会改变你日志的样子,并且预示了下一步什么会坏掉。

sun.misc.Unsafe 内存访问。 它的内存访问方法在 JDK 23 中被标记为待移除(JEP 471),而从 JDK 24 起,第一次调用会打印警告(JEP 498)。下面这个小程序在堆外分配 8 个字节,写入一个 long 再读回来:

# JDK 21
read back: 42

# JDK 25
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::allocateMemory has been called by OffHeap (file:/.../)
WARNING: Please consider reporting this to the maintainers of class OffHeap
WARNING: sun.misc.Unsafe::allocateMemory will be removed in a future release
read back: 42

在真实应用中,调用者通常是某个库(序列化、堆外缓存、较老的网络代码),而警告会告诉你具体是哪一个。JEP 498 计划在后续版本中让这些调用抛出异常,因此请检查该库的新版本是否已迁移到 VarHandle 或 FFM API 的 MemorySegment。

JNI。 从 JDK 24 起(JEP 472),在未启用原生访问的情况下加载本地库或绑定 native 方法也会打印警告:

WARNING: A restricted method in java.lang.System has been called
WARNING: java.lang.System::loadLibrary has been called by Native in an unnamed module (file:/.../)
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

如果你依赖 JNI(带原生部分的数据库驱动、压缩、加密、平台集成),请添加 --enable-native-access=ALL-UNNAMED(或者明确列出你的模块),或者在可执行 JAR 的清单里加入 Enable-Native-Access: ALL-UNNAMED。

我的建议是在 25 上以两个开关的最严格值运行你的测试,这样 CI 现在就失败,而不是等到默认值改变的那天:

--sun-misc-unsafe-memory-access=deny --illegal-native-access=deny

有了这两个开关,同样的程序会立刻失败,分别抛出 UnsupportedOperationException: allocateMemory 和 IllegalCallerException: Illegal native access from an unnamed module。

6. 如今会阻止 JVM 启动或被悄悄忽略的启动标志

JVM 选项会在脚本里逐年累积。以下是我实测在 25 上行为不同的那些:

选项JDK 21JDK 25
-Xfuture弃用警告Unrecognized option,JVM 无法启动
-Xnoagent接受Unrecognized option,JVM 无法启动
-XX:-ZGenerational非分代 ZGCIgnoring option ZGenerational; support was removed in 24.0
-Xdebug接受弃用警告
-noclassgc接受弃用警告

-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 日志会显示这一点:

# JDK 21, -XX:+UseZGC
[gc,init] Initializing The Z Garbage Collector
[gc,init] Using legacy single-generation mode

# JDK 25, -XX:+UseZGC
[gc,init] Initializing The Z Garbage Collector
[gc     ] Using The Z Garbage Collector

引入分代 ZGC 的 JEP 439 认为它“对大多数用例是更好的方案”,但它的行为确实不同。如果你在 21 上调整过堆大小或调优过 ZGC,那么升级后请在负载下重新检查内存使用和暂停时间。

7. 边角锋利的小改动

发行说明里还有几个容易漏掉的点。

Windows 上的 File.delete() 不再删除只读文件(JDK 25,JDK-8355954)。它现在返回 false,而不是先清除只读属性:

File f = Files.createTempFile("report", ".txt").toFile();
f.setReadOnly();
System.out.println("deleted: " + f.delete() + ", still exists: " + f.exists());
# JDK 21 on Windows
deleted: true, still exists: false

# JDK 25 on Windows
deleted: false, still exists: true

那些忽略 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. 在投入生产前发现所有这些问题

以下是我会按顺序执行的检查清单:

  1. 查看 JDK 21 的构建输出。 如果你受到影响,第 1 节提到的注解处理提示已经会出现在那里。另外,使用 -Xlint:deprecation,removal 编译,并阅读 [removal] 警告。

  2. 使用当前的 JDK 21 运行 jdeprscan --for-removal,扫描你的应用程序及其依赖 JAR。它会列出你使用到的所有已弃用并计划移除的内容,包括 Thread.suspend。在我的测试中,即使加上 --release 21,JDK 25 的 jdeprscan 也没有报告 Thread.suspend,所以在切换之前请运行 JDK 21 的版本。

    下面是针对本文中的类运行该命令的结果,这些类使用 JDK 21 编译到 classes/:

    $ jdeprscan --for-removal --class-path classes classes
    Directory classes:
    class Legacy uses deprecated method java/lang/Thread::suspend()V (forRemoval=true)
    class Sm uses deprecated class java/lang/SecurityManager (forRemoval=true)
    class Sm uses deprecated method java/lang/System::getSecurityManager()Ljava/lang/SecurityManager; (forRemoval=true)
    class Sm uses deprecated method java/lang/System::setSecurityManager(Ljava/lang/SecurityManager;)V (forRemoval=true)
    class Subj uses deprecated class java/security/AccessController (forRemoval=true)
    class Subj uses deprecated method javax/security/auth/Subject::getSubject(Ljava/security/AccessControlContext;)Ljavax/security/auth/Subject; (forRemoval=true)
    

    对于真实项目,请将 --class-path 指向你的依赖项,并将你的 JAR 或 classes 目录作为最后一个参数传入。

  3. 运行 jdeps --jdk-internals,找出谁在使用 sun.misc.Unsafe 和其他内部 API。

  4. 在启动配置中执行 grep(脚本、Dockerfile、Helm chart、JAVA_TOOL_OPTIONS、JDK_JAVA_OPTIONS),查找 java.security.manager、java.locale.providers、-Xfuture、-Xnoagent 和 ZGenerational。

  5. 在 25 上运行完整测试套件,并加上 --sun-misc-unsafe-memory-access=deny --illegal-native-access=deny;同时阅读真实部署的启动日志,查找之前没见过的 WARNING: 行。

  6. 阅读 22、23、24 和 25 发行说明中的“Removed Features and Options”和“Other Notes”部分。 它们很长,但破坏性变更只占其中一小部分。

如果你只有时间检查三项,请先检查注解处理器、COMPAT 标志和只读文件。这三者都不会中止构建或应用:处理器只是不运行,COMPAT 只会在启动时留下一行日志,而 delete() 会返回 false。