升到 JDK 27(或经 26)后,深反射改 final 字段会在 stderr 刷 WARNING;本稿讲怎么认、怎么开白名单、怎么用 deny/JFR 预演,不写语言预览。
同系列:原始类型 pattern、PEM 编解码、TLS 后量子混合、Go1.27 stdversion 怎么验——一句对照,不写进本稿交付。
痛点:升版后日志里突然冒出 final 反射警告?
现场三类卡点:
目标很具体:在 JDK 26 起已落地、JDK 27 迁移文档仍强调的前提下,认清警告与两开关,用 debug/JFR 定位调用方,优先升级库或改架构;白名单只作临时过渡,deny 用来预演。

图注:深反射 set final → 是否已 enable 白名单 → 再按 warn/debug/deny 分支;deny 将抛 IllegalAccessException,适合升版前预演。
为什么出现警告?26 起落地,27 仍强调
口径以 JDK 27 Significant Changes、Preparing for final Field Mutation Restrictions、JEP 500 为准(核对日 2026-10-26 Asia/Shanghai):
| JEP 500 | Closed / Delivered,Release = 26 |
| 警告起点 | Starting in JDK 26:首次深反射改 final 发 WARNING |
| JDK 27 | Significant Changes 仍列出该警告,并指向两开关与 Preparing 文档 |
| 默认行为 | –illegal-final-field-mutation=warn(每模块最多一条) |
| 未来方向 | deny 将成为默认;官方 Tip:用 deny 预演 |
| 勿写 | 「JEP 500 是 27 新交付」——交付版次是 26;27 是迁移页仍强调 |
Significant Changes 的原文方向是:JDK 27 issues warnings about uses of deep reflection to mutate final fields;可用 –enable-final-field-mutation 与 –illegal-final-field-mutation。Preparing 写明从 JDK 26 起首次命中会 WARNING,并提示 classpath 场景可用 –enable-final-field-mutation=ALL-UNNAMED,未来将 block。
从 21/25 等直跳 27 的团队,升版日志里仍会撞上——不是「27 新发明」,而是 26 起警告、27 迁移文档仍专列。若中间从未在 26 上跑过生产流量,第一次看到警告往往就在 27 验收环境;别把「首次看见」误读成「本版新交付」。同发版的原始类型 pattern、PEM、TLS 是另文主线;本稿只守「final 深反射警告 + 开关 + 定位」。
–enable-final-field-mutation 怎么写?
白名单开关控制「哪些模块被允许」对 final 做深反射变异;即使开了白名单,字段声明类所在 package 仍须 open 给变异方模块。
| classpath(未命名模块) | –enable-final-field-mutation=ALL-UNNAMED | Preparing 对 classpath 的明示写法 |
| 模块路径 | –enable-final-field-mutation=mod.a,mod.b | 逗号分隔模块名 |
| package 可见性 | 声明类所在 package open 给调用方 | 仅开白名单不够 |
| 生产建议 | 优先升级/替换库 | 勿把全开 ALL-UNNAMED 当长期修复 |
# classpath 临时白名单(勿生产长期依赖)
java –enable-final-field-mutation=ALL-UNNAMED \\
–illegal-final-field-mutation=warn \\
-cp app.jar com.example.App
# 具名模块:只开真正需要的模块,逗号分隔
java –enable-final-field-mutation=com.acme.serializer \\
–illegal-final-field-mutation=debug \\
-p mods -m com.acme.app/com.acme.app.Main
# 升版前预演未来默认(期望命中处抛 IllegalAccessException)
java –illegal-final-field-mutation=deny -cp app.jar com.example.App
allow 仅临时;评审时把「谁在改哪个 final、为何不能改构造/工厂」写进说明,比启动参数里永久挂着 ALL-UNNAMED 更可维护。
warn 和 deny 有什么差别?
–illegal-final-field-mutation 四档(Preparing 明文):
| allow | 无警告放行 | 仅临时排障;勿长期 |
| warn | JDK 26 起默认;每模块最多一条警告 | 日常观察;易漏后续调用点 |
| debug | 每次警告并带堆栈 | 定位是哪条调用链在 set |
| deny | Field::set 抛 IllegalAccessException;未来将成为默认 | 升版前预演、CI 门禁 |

图注:四档对照——allow 静默放行;warn 默认且每模块一条;debug 带堆栈;deny 抛异常并将成未来默认。
默认 warn 的坑在于「每模块最多一条」:第一个调用方打完警告后,同模块后续命中可能不再刷屏,日志看似安静,债还在。要摸清全貌,用 debug 或下一节的 JFR;要验证「关掉白名单后能否活」,用 deny。
最小 demo:classpath 上 Field.set 改 final
下面演示「会走到深反射改 final」的路径。在 JDK 26+ 默认 warn 下应看到 WARNING;加 deny 时应在 set 处失败。不要假设「无 JVM 参数就能安静通过」——从 26 起默认就会警告。
import java.lang.reflect.Field;
/** 演示:深反射改 final(JDK 26+ 默认 warn;勿当生产模板) */
public class FinalFieldMutationDemo {
static final class Holder {
private final String id;
Holder(String id) {
this.id = id;
}
String id() {
return id;
}
}
public static void main(String[] args) throws Exception {
Holder h = new Holder("orig");
Field f = Holder.class.getDeclaredField("id");
f.setAccessible(true);
// 深反射写入 final:默认 warn;deny 时抛 IllegalAccessException
f.set(h, "mutated");
System.out.println("after set: " + h.id());
}
}
# 编译(任意支持该源码的 JDK 即可;行为以运行时 JDK 为准)
javac FinalFieldMutationDemo.java
# 1)默认路径(JDK 26+:warn,每模块最多一条 WARNING)
java FinalFieldMutationDemo
# 2)带堆栈,便于对照调用方
java –illegal-final-field-mutation=debug FinalFieldMutationDemo
# 3)预演未来默认:期望在 set 处失败
java –illegal-final-field-mutation=deny FinalFieldMutationDemo
# 4)临时白名单 + warn(排障用;勿写进常驻启动脚本当「修复」)
java –enable-final-field-mutation=ALL-UNNAMED \\
–illegal-final-field-mutation=warn \\
FinalFieldMutationDemo
判定清单:
把四步结果记成表:运行时版本、illegal 档位、是否 enable、命中字段/模块。升版评审时这张表比「日志里好像有警告」好用得多。若 deny 下失败点落在第三方 jar,优先查该库发行说明是否已适配 JEP 500;没有就提 issue 或换实现,而不是先在应用侧永久放行。
怎么用 JFR 找出是谁在改 final?
Preparing / JEP 500:JFR 启用时会记录事件 jdk.FinalFieldMutation(声明类、字段名、堆栈;亦覆盖经 Lookup::unreflectSetter 取得写权限的场景)。
# 录一段(按你们现有 JFR 启停方式即可;事件名以官方为准)
# 分析时只筛 final 变异事件:
jfr print –events jdk.FinalFieldMutation recording.jfr
和 debug 搭配:本地/预发先 debug 拿堆栈;压测或难复现环境开 JFR,事后 jfr print 筛 jdk.FinalFieldMutation。事件里的声明类与字段名,比「日志里好像有一条警告」更适合贴进缺陷单。
序列化库还要不要开白名单?
JEP 500 对序列化库的口径是:未来限制加强后,不宜再靠开白名单裸用深反射;应使用 sun.reflect.ReflectionFactory(官方支持用途)获得可直接赋实例字段(含 final)的 method handle,不必为序列化库模块启用 final field mutation。该路径仅支持实现 java.io.Serializable 的类。
实操边界:
测试里用反射塞 final 做夹具,和线上序列化不是同一类问题:单测可以改成包内可见构造、测试专用工厂或 testfixtures;把测试便利写成生产 JVM 永久 allow,会把护栏拆掉。mock 框架若依赖改 final,优先换不依赖该路径的方案,或把受影响用例标成已知债并跟踪库版本。
不要把「序列化需要改 final」理解成「生产可以长期 allow」。白名单是缓冲,不是终态。
和旧流水线怎么并存?
升 27 不必第一天全仓 deny。可分步:
多版本矩阵时注意:仍跑 JDK 25 及更早的任务不会出现这套警告语义;不要在旧 JDK 任务上强加 –illegal-final-field-mutation 却期望与 26/27 行为一致。新任务用 27(或至少 26)跑 warn/debug/deny,旧任务保持旧运行时,两边的启动脚本分开维护。
容器镜像里若把 JVM 参数写进公共 base image,白名单会落到所有服务——更稳妥是按服务覆盖:只有确认需要的进程带上收窄后的模块列表,其它服务保持默认 warn,并在流水线里定期跑 deny 抽样。
评审口头禅建议统一成:「这是 JEP 500(Delivered@26),27 Significant Changes 仍列;默认 warn,用 debug/JFR 定位,deny 预演,勿长期 allow / 无差别 ALL-UNNAMED」——比「反射警告先忽略」清楚得多。
常见误读 FAQ
Q:这是 JDK 27 才有的新限制吗? 不是。JEP 500 交付版次是 26;Preparing 写 Starting in JDK 26。JDK 27 迁移文档仍专列警告与两开关。须记「26 起警告、27 仍强调」。
Q:开了 –enable-final-field-mutation 是否还要 open? 要。白名单与 package open 是两道门;只开一项不够。
Q:默认 warn 是不是每个调用都打日志? 不是。默认每模块最多一条;要用 debug 或 JFR 事件 jdk.FinalFieldMutation 看全貌。
Q:生产能不能长期 allow? 官方把 allow 定位为临时;推荐优先升级/替换库。长期 allow 等于关掉整段护栏。
Q:和原始类型 pattern / PEM / TLS / stdversion 是一回事吗? 不是。那些是语言预览、密码学或 Go 工具链专文;本稿是 final 深反射警告与迁移开关。内链见文首,勿把预览语法或 Go vet 写进这篇的验收标准。
升级清单(给评审贴一条)
小结
| 改了什么 | 深反射改 final 发警告,为未来默认禁止做准备 |
| 版次 | JEP 500 Delivered@26;JDK 27 Significant Changes 仍列 |
| 默认 | –illegal-final-field-mutation=warn(每模块最多一条) |
| 白名单 | classpath → ALL-UNNAMED;模块 → 逗号分隔名;仍需 package open |
| 四档 | allow / warn / debug / deny;deny 将成未来默认 |
| 定位 | debug 堆栈或 JFR jdk.FinalFieldMutation |
| 序列化 | 优先 sun.reflect.ReflectionFactory;勿默认全开白名单 |
| 勿抢 | pattern / PEM / TLS / LazyConstant / 结构化并发 / Go vet |
升到 27 后,final 深反射警告不是可忽略的噪声,而是迁移窗口信号。先定位调用方,再决定升级库、改架构或临时收窄白名单,并用 deny 预演未来默认——比启动参数里永久 allow 更省债。本地用 warn → debug → deny 跑三遍,把字段与模块摊开看,比只关警告踏实。其它 JDK 27 / 工具链能力见前述内链,本文不展开。




