- Published on
JDK 26 的 final 字段修改警告 — JEP 500 到底改变了什么,以及现在该检查什么
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 有一天,stderr 里多了三行
- final 到目前为止其实并不是 final
- JEP 500 在 JDK 26 里到底改了什么
- 新标志,以及为什么 --add-opens 解决不了问题
- 实际会踩到什么 — issue 追踪器怎么说
- 现在能做什么 — 切到 deny,用 JFR 揪出元凶
- 库这一侧的替代方案,以及 ReflectionFactory 这个半吊子逃生通道
- 诚实的权衡 — 以及为什么现在还不算紧急
- 结语
- 参考资料
引言 — 有一天,stderr 里多了三行
升级到 JDK 26 后启动应用,明明什么都没改,标准错误里却冒出了这样的内容。
WARNING: Final field finalField in class TestFinalFieldModification$ClassWithFinalField has been mutated reflectively by class com.google.gson.internal.bind.ReflectiveTypeAdapterFactory$2 in unnamed module @59fd97a8 (file:/.../gson-2.13.2.jar)
WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled
这不是我编的例子,而是 Gson issue 追踪器上真实提交的复现。复现代码平凡得惊人 — 一个只有一个 final String 字段的类,跑一次 new Gson().fromJson(...) 就是全部。这段代码在 JDK 25 上一直悄无声息地跑得很好,到了 JDK 26 就开始报警告。
元凶是 JEP 500: Prepare to Make Final Mean Final。由 Ron Pressler 和 Alex Buckley 撰写的这份 JEP,以 Closed/Delivered 状态被收录进了 2026 年 3 月 17 日 GA 的 JDK 26。正如其名,这是「让 final 真正意味着 final」的准备阶段,而这次版本所做的事只有一件 — 发出警告。
final 到目前为止其实并不是 final
照搬 JEP 原文的说法是这样的。「final 字段不能被重新赋值这一期望是假的(false)。」因为自 JDK 5 起,反射 API 就一直允许这么做。
// 一个带有 final 字段的普通类
class C {
final int x;
C() { x = 100; }
}
// 1. 对 C 的 final 字段做深度反射
java.lang.reflect.Field f = C.class.getDeclaredField("x");
f.setAccessible(true); // 让 C 的 final 字段变得可修改
// 2. 创建实例
C obj = new C();
System.out.println(obj.x); // 输出 100
// 3. 修改 final 字段
f.set(obj, 200);
System.out.println(obj.x); // 输出 200
f.set(obj, 300);
System.out.println(obj.x); // 输出 300
之所以会有这个口子,是因为反序列化。JDK 自身的平台序列化会绕过构造函数,把流里的值直接塞进字段,第三方序列化库想提供同等功能,也需要做同样的事。于是 JDK 5 修改了反射 API,让 final 字段可以被修改。JEP 写道,回过头看,这是「一个牺牲了完整性(integrity)的糟糕选择」。
问题在于,这同时破坏了两件事。
第一,正确性推理变得不可能。final 字段的不可变性,自 JDK 5 起就在 Java 内存模型中扮演着核心角色 — 多线程代码中对象的安全初始化(safe initialization)正建立在这之上。Lucene issue 下的一条评论精准地戳中了这一点:用反射修改 final 字段只是被「允许」,却从来没有一贯正确地工作过 — 观察到的现象是,修改后的值未必能被所有线程看到。也就是说,这不是 JDK 26 新制造出来的问题,更接近于终于把一个本来就已经破损的东西说出来了。
第二,JIT 无法做优化。最典型的就是常量折叠(constant folding)。只有能相信 final 字段绝不会变,才能把常量表达式只求值一次而不是每次都求值,一整条优化链正是从这里开始的。可一旦「任何代码在任何时候都能修改 final 字段」,JVM 就必须对所有 final 字段都心存怀疑。用 JEP 的话说,仅仅是存在这样一个 API,就会让"所有程序"的安全性与性能一并受损 — 尽管真正会修改 final 字段的代码只占极少数。
这个方向本身不是凭空冒出来的。JDK 15 的隐藏类和 JDK 16 的记录类从一开始就阻止了通过深度反射修改 final 字段,JDK 17 对 JDK 内部做了强封装,JDK 24 开始了移除 sun.misc.Unsafe 相关方法的流程。JEP 500 是同一条「默认即完整性(integrity by default)」路线上的下一步。
JEP 500 在 JDK 26 里到底改了什么
核心是 Field::set 的行为变化。当 f.set(...) 被调用、且该字段是 final 时,现在必须同时满足以下三个条件,修改才会生效。
f.setAccessible(true)已经成功,- 声明该字段的类所在的包,对调用者所在模块是 open 的,
- 针对调用者所在模块,final 字段修改已被启用。
第 2、3 条是 JDK 26 新增的条件。这里有一个常见的误解 — setAccessible 本身的行为没有变。也就是说 f.setAccessible(true) 仍然会成功,但 f.set(...) 却可能是非法的。二者现在是两道独立的关卡。
条件不满足时运行时该做什么,由新选项 --illegal-final-field-mutation 决定。它和 JDK 9 的 --illegal-access(JEP 261)、JDK 24 的 --illegal-native-access(JEP 472)属于同一谱系 — JEP 直接把这两者列为先例。
| 值 | 行为 |
|---|---|
allow | 无警告地允许修改 |
warn | 允许修改,但会警告。JDK 26 的默认值 |
debug | 与 warn 相同,但每次修改都会警告 + 打印堆栈跟踪 |
deny | Field::set 抛出 IllegalAccessException。未来的默认值 |
JEP 明确写出的路线图是这样的 — 未来某个版本里 deny 会成为默认值,届时 allow 会被移除,warn 和 debug 至少还会再保留一个版本。warn 模式本身最终也会被逐步淘汰。
警告的格式,JEP 里是这样写的。
WARNING: Final field f in p.C has been [mutated/unreflected for mutation] by class com.foo.Bar.caller in module N (file:/path/to/foo.jar)
WARNING: Use --enable-final-field-mutation=N to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled
这里有个陷阱。警告对每个做反射的模块最多只会出现一次。如果你的整个应用都在 classpath 上(大多数情况都是这样),就会被统统归入同一个 unnamed module,于是无论违规出现在十个地方还是一百个地方,警告都只会冒出一次,仅此而已。也就是说,看到这条警告不能理解成「我们只有一处踩坑」。它是一个警报,不是一个计数器。
变化的不只是 Field::set。MethodHandles.Lookup::unreflectSetter 也以同样的方式被改动,运行时打开包的 Module::addOpens 系列也受到影响 — 对于命令行上没有任何人启用过的包,JVM 会选择信任该包的 final 字段,此后即便调用 addOpens,也不会因此获得修改 final 字段的权限。
反过来,没变的地方也要明确说清楚。System 的 setIn/setOut/setErr,在 JDK 26 中什么都没有变。这些字段原本就是 write-protected 的,无法通过深度反射修改,只能通过对应的方法来修改。
新标志,以及为什么 --add-opens 解决不了问题
要避免警告和(未来的)异常,需要使用新的永久选项 --enable-final-field-mutation。
# 对 classpath 上的所有代码放行
$ java --enable-final-field-mutation=ALL-UNNAMED ...
# 只对模块路径上的特定模块放行
$ java --enable-final-field-mutation=M1,M2 ...
除了直接传给启动器之外,还有几条别的路径 — JDK_JAVA_OPTIONS 环境变量、像 java @config 这样的参数文件、在可执行 JAR 的 manifest 里加上 Enable-Final-Field-Mutation(不过这里支持的值只有 ALL-UNNAMED,填别的值会抛异常)、jlink 的 --add-options,以及通过 JNI Invocation API 内嵌 JVM 的原生应用。不过这个选项只对启动模块层(boot module layer)生效,不能应用到用户自定义的层上。
这里要指出实务中最容易踩的坑。光靠 --add-opens 并不能让这个警告消失。这是 JEP 明确写死的一句话。反过来同样成立 — 打开 --enable-final-field-mutation 也不代表深度反射就一定能做到,你想修改的那个 final 字段,还得对那段代码是 open 的。前面看到的条件 2 和条件 3 是 AND 的关系。所以在实务中,这两者经常需要一起用。
还有一点 — 决定这个权衡的主体是应用开发者(或者接受其建议的部署方),而不是库的开发者。这是 JEP 在策略上明确钉死的一点。库要求自己的用户「打开这个标志」,应该是最后的手段,因为这个标志不会只对那个库生效,它是按模块生效的,如果是 classpath,最终就会作用于整个应用。
实际会踩到什么 — issue 追踪器怎么说
与其猜测,不如看看 GA 前后实际提交的 issue。
Gson。就是前面提到的 issue #2991。用 deny 跑,就会从警告变成异常。有意思的是维护者的回应 — 修改 final 字段这件事对 Gson 来说「已经根深蒂固(deeply ingrained)」,眼下能做的最好的事,大概就是把异常信息改得符合 JEP 500 的说法,并把替代方案写进文档。给出的替代方案是:去掉字段上的 final、改用 record、改用 Kotlin 的 data class 加一个专门的 TypeAdapterFactory,或者给出问题的每个类各写一个自定义的 TypeAdapter 或 JsonDeserializer。此外,维护者还提到,撇开这个问题不谈,基于反射的序列化本身就不好 — 一个公开的 JSON API 是从类的 private 字段里推导出来的,而且这不限于为 Gson 专门设计的类,任何类都会这样。截至本文写作时,这个 issue 仍处于打开状态,已提交的 PR 也只限于改进异常信息。
Spring Security。issue #19127。在 spring-security-acl 7.0.5 中,BasicLookupStrategy 通过反射修改了 AclImpl 的 final 字段 aces。打出来的正是上面那三行一模一样的警告。
Trino。issue #28207 最有教育意义。这是把测试直接以 --illegal-final-field-mutation=deny 跑出来的结果,但踩坑的并不是 Trino 自己的代码。picocli 无法给 ClientOptions 的 public final 列表字段赋值,Jackson databind 的 FieldProperty 也无法写入另一个库(hoverfly)的 final 字段。报错信息非常直白。
class com.fasterxml.jackson.databind.deser.impl.FieldProperty (in unnamed module @359df09a)
cannot set final field io.specto.hoverfly.junit.api.view.HoverflyInfoView.upstreamProxy
(in unnamed module @359df09a), unnamed module @359df09a is not allowed to mutate final fields
教训是这样的 — 这多半不是你自己代码的问题,而是依赖的问题,而且往往发生在你没法修改的代码里。
Lucene。issue #15482 是反过来的例子。因为 Lucene 一直用 forbiddenapis 禁止自己代码里出现 setAccessible,所以现在打算把 deny 传给测试运行器,连依赖库的行为也一并抓出来。只要自己家里是干净的,这个标志就会从负担变成武器。
现在能做什么 — 切到 deny,用 JFR 揪出元凶
JEP 的建议很明确。「要为未来做准备,就把现有代码跑在 deny 模式下,把那些通过深度反射修改 final 字段的代码找出来。」
警告每个模块最多只出现一次、不好计数的问题,有两种绕过方式。
# 1) 每次修改都警告 + 打印堆栈跟踪
$ java --illegal-final-field-mutation=debug -jar app.jar
# 2) 用 JFR 收集事件(JFR 打开时会自动记录)
$ java -XX:StartFlightRecording:filename=recording.jfr ...
$ jfr print --events jdk.FinalFieldMutation recording.jfr
jdk.FinalFieldMutation 事件会在 final 实例字段被修改、或者通过 Lookup.unreflectSetter 拿到可写的 MethodHandle 时被记录下来,其中包含声明该字段的类、字段名,以及堆栈跟踪。有堆栈跟踪这一点很关键 — 就像上面 Gson 的例子那样,当真正的调用者深藏在框架内部时,没有它就根本找不到。
实用的顺序是这样的。
- 把 CI 的测试作业用 JDK 26 +
--illegal-final-field-mutation=deny跑一遍,拉出一份清单(Trino 和 Lucene 干的正是这件事)。 - 把跑出来的条目分成「自己的代码 / 能修的依赖 / 修不了的依赖」三类。
- 自己的代码就去修 — 通常只要挪进构造函数就完事了。
- 只留下修不了的那部分,在生产环境的运行时里用
--enable-final-field-mutation明确把它打开。这不是逃避,而是一种记录 — 债务在哪里,命令行上会留下痕迹。
库这一侧的替代方案,以及 ReflectionFactory 这个半吊子逃生通道
JEP 和 Avoiding Final Field Mutation 给出的替代方案,最终都收敛到一件事上 — 用构造函数。
问题的根源在于「先造一个空对象,之后再填字段(construct-first-assign-later)」这种模式。平台序列化是这样,JavaBean 是这样,早年的 DI 也是这样。这种模式和 final 正面冲突,所以才需要强制赋值。但撇开 final 的问题不谈,这种模式本身就不好 — 一个类的不变式通常是在构造函数里建立起来的,绕过构造函数,就可能造出一个不满足不变式的实例。Inside Java 那篇文章里的 ReflectionFactory 例子,把这一点摆得明明白白。绕过了构造函数,校验也就跟着被绕过了,age 里直接被塞进了 -5。
DI 那一派早就走过这条路了。从字段注入转向了构造函数注入,如今大多数框架要么禁止、要么不推荐向 final 字段做注入。如果是序列化库,可选项包括:只支持 record、把 record 当作代理来用、或者让用户自己指定构造函数或静态工厂。如果是克隆,就用拷贝构造函数或静态工厂来代替 super.clone()(Effective Java 里那条老建议依然有效)。
那既有的序列化库该怎么办?JEP 给出的官方答案是 sun.reflect.ReflectionFactory。它是 jdk.unsupported 模块里的一个 critical internal API,从这里拿到的方法句柄,拥有和 JDK 自身序列化同等的权限。不需要命令行标志,即便在 deny 之下也不会产生警告或错误。
但这里有一个决定性的限制。ReflectionFactory 只支持反序列化实现了 java.io.Serializable 的类。这不是 bug,而是刻意划下的一条线。JEP 的解释很清楚 — JVM 必须假设 Serializable 对象的 final 字段可能会变,但对于其余所有对象(占绝大多数),它希望能够假设 final 字段是永久不可变的。这是为了给常量折叠这类优化留出空间而做的一笔交易。
所以这个逃生通道对 Gson 帮助不大,因为 Gson 并不要求实现 Serializable — issue 里指出的正是这一点。维护者的反应也带着一种精准的挖苦 — 如果要靠给值类加上 Serializable 来修这个问题,那还不如干脆改成 record。Inside Java 那篇文章也附上了同样的警告。ReflectionFactory 是一件"不是给胆小者准备的"锋利工具,它会把平台序列化的所有麻烦事都原封不动地接过来 — 重新实现 readObject/readResolve 协议、安全问题,以及 Serializable 的扩散。
诚实的权衡 — 以及为什么现在还不算紧急
接下来这些地方,语气要放低一些。
性能收益没有具体数字。JEP 只写了「更安全,且潜在上更快(potentially faster)」。Oracle 的 JDK 26 主要变更文档用的也是同样的说法。它解释了机制(常量折叠及其后续的优化链),但没有任何地方给出「快了百分之多少」这样的基准测试数据。这篇文章里也不会替它编出来。眼下诚实的总结是:这是一处为未来的优化预留空间的改动,其收益目前还没有以具体测量值的形式给出。
「为什么不能靠投机优化解决」这个反驳,JEP 亲自作答的部分反而挺有意思。就像 JIT 一贯的做法那样,「乐观地假设 final 不会变,一旦变了就反优化(deoptimize)」不就行了吗 — JEP 的回答是,这样可能还不够。因为未来计划中的一些优化,依赖的不只是进程存活期间内的不变性,还可能依赖从这一次运行延续到下一次运行的不变性。想到 AOT 缓存和 Leyden 那条线上的工作,这句话的分量就读出来了。运行时能够反优化,和相信上一次运行里已经烤进去的东西,是两个不同的问题。
还有时间线。这一点对实务者来说最重要。JEP 500 并没有指明哪个版本会让 deny 成为默认值,只写了「未来的版本」。而定于 2026 年 9 月 15 日 GA 的 JDK 27,已经过了 Rampdown Phase One、功能集合已经冻结("No further JEPs will be targeted to this release"),确定下来的 JEP 清单里也没有和强制 final 字段相关的内容。也就是说,这件事在 JDK 27 里不会变成错误。最快也要等到再下一个版本。
所以,眼下对这条警告的合理反应是这样的。
该在意的情况
- 已经升级到 JDK 26 或更高版本,技术栈里有基于反射的序列化(比如 Gson)或者老式的字段注入。
- 你是库或框架的维护者。这里是真正的功课 — JEP 的立场是,要求用户打开一个标志应该是最后手段,而改动架构是需要时间的。
- 测试已经干净到能在
deny下跑通,那现在就打开,用来防止回归,是划算的。
还不必在意的情况
- 还停留在 JDK 21 或 25 LTS 上。这项改动从 JDK 26 开始,而 JDK 26 是六个月周期的非 LTS 版本。大多数团队会通过下一个 LTS 才第一次遇到它。
- 眼下只是出现了警告,原因是别人写的库,而你正忙着扑另一场火。用
--enable-final-field-mutation=ALL-UNNAMED把它安静下来,记进 backlog,是完全合理的选择。这是一个永久选项,不是临时的宽限期。
不过,反射性地随手写下 --illegal-final-field-mutation=allow,最好还是避免。JEP 明确说了 — deny 成为默认值的那一天,allow 会被移除(warn 和 debug 至少还会再保留一个版本)。也就是说,这是一个带有有效期的值,等到那一天,手里还攥着这个标志的团队,无论如何都得再动一次手。如果现在确实需要安静地压下去,--enable-final-field-mutation 才是正确的选择 — 它是一个没有计划移除的永久选项,更重要的是,它诚实:会在命令行上留下「我们在这里修改 final」这句话。
最后还有一个盲区。以上所有的诊断,说的都是 Java 代码这一侧。原生代码通过 JNI 的 Set<type>Field 系列修改 final 字段,属于未定义行为(undefined behavior),JEP 甚至写到,其后果可能是数组边界检查被破坏,或者进程直接崩溃。相关诊断只能通过 -Xlog:jni=debug 或 -Xcheck:jni 获得。而通过 sun.misc.Unsafe 做修改,则连诊断都没有 — 它可能悄无声息地表现为奇怪的 bug 或者 JVM 崩溃。
结语
归纳一下。JDK 26 开始对通过深度反射修改 final 字段发出警告。行为本身还没有变化,默认值是 warn,而且警告每个模块最多只出现一次,要了解真实规模,需要 deny 或者 JFR。未来 deny 会成为默认值,但具体是什么时候,JEP 也没有说,至少不是 JDK 27。
这项改动真正的信息,不在标志本身,而在它下面。二十多年来,Java 一直说着「final 是不可变的」,同时又把打破这个承诺的工具一并交到了开发者手里,生态系统的相当一部分就建在这个工具之上。JEP 500 是把这张账单开始分期开出来了。一行警告很小,但它背后的问题并不小 — 你的对象,是在构造函数返回时就完整了,还是要等别人之后把字段填进去。
参考资料
- JEP 500: Prepare to Make Final Mean Final (Ron Pressler, Alex Buckley — JDK 26, Closed/Delivered)
- JDK 26 — 功能列表与 GA 时间表(2026/03/17)
- JDK 27 — Rampdown Phase One,确定的 JEP 列表(预计 2026/09/15 GA)
- Quality Outreach Heads-up — JDK 26: Warnings About Final Field Mutation (Nicolai Parlog, 2026/05/15)
- Avoiding Final Field Mutation (Nicolai Parlog, 2026/04/27) — 按场景划分的替代方案与 ReflectionFactory 示例
- Significant Changes in JDK 26 Release — Oracle 迁移文档
- google/gson #2991 — Prepare for final field mutation warning under Java 26
- spring-projects/spring-security #19127 — Final field mutation reported on Java 26
- trinodb/trino #28207 — illegal-final-field-mutation violations(以 deny 跑测试)
- apache/lucene #15482 — When running tests on Java 26 disallow modification of final fields