JEP草案:准备让 final 真正具有最终性

作者| Ron Pressler & Alex Buckley
---|---
所有者| Ron Pressler
类型| Feature
范围| JDK
状态| Draft
组件| core-libs
讨论| jdk dash dev at openjdk dot org
审核人| Alan Bateman
创建时间| 2025/02/06 10:25
更新时间| 2025/03/31 17:35
Issue| 8349536

摘要

针对使用 深度反射 来修改 final 字段的行为发出警告。这些警告旨在为未来的版本做准备,该版本将通过限制 final 字段的修改来确保默认完整性;这将使 Java 程序更安全,并可能更快。应用程序开发人员可以通过选择性地启用修改 final 字段的能力(在必要时)来避免当前的警告和未来的限制。

目标

非目标

动机

Java 开发人员依赖 final 字段来表示不可变状态。一旦在构造函数(对于 final 实例字段)或类初始化器(对于 static final 字段)中赋值,final 字段就不能被重新赋值;它的值(无论是原始值还是对象的引用)都是不可变的。开发者在推理正确性时,往往会依赖于 final 字段不能在程序的任何地方被重新赋值(无论是故意还是意外)。此外,许多类的存在 仅仅 是为了表示不可变状态,因此在 JDK 16 中引入了 records,以提供一种简洁的方式来声明所有字段都是 final 的类,从而更容易推理正确性。

final 字段不可被重新赋值的预期对于性能也很重要。JVM 对类的行为了解得越多,它能够应用的优化就越多。例如,能够相信 final 字段永远不会被重新赋值,使得 JVM 可以执行 常量折叠,这是一种优化,它可以消除从内存中加载值的需要,因为该值可以嵌入到 JIT 编译器发出的机器代码中。常量折叠通常是优化链中的第一步,这些优化共同提供了巨大的加速。

不幸的是,final 字段不能被重新赋值的预期是 错误的。Java Platform 提供了许多 API,允许程序中的任何代码随时重新赋值 final 字段,从而破坏了所有关于正确性的推理,并使重要的优化失效。这些 API 中最常见的是深度反射。以下是一个使用深度反射随意修改 final 字段的例子:

class C {
  final int x;
  C() { x = 100; }
}
// 对 C 的 final 字段执行深度反射
java.lang.reflect.Field f = C.class.getDeclaredField("x");
f.setAccessible(true);
// 创建 C 类的对象
C obj = new C();
System.out.println(obj.x); // 打印 100
// 修改对象中的 final 字段
f.set(obj, 200);
System.out.println(obj.x); // 打印 200
f.set(obj, 300);
System.out.println(obj.x); // 打印 300

因此,final 字段与非 final 字段一样可变。开发人员无法使用 final 字段来构建深度不可变的对象图,而这些对象图将使 JVM 能够提供最佳的性能优化。

Java Platform 提供了一个破坏 final 含义的 API,这似乎很荒谬。然而,在 JDK 5 引入了 Java Memory Model 并导致 final 字段 得到广泛使用之后,这样的 API 被认为对于支持序列化库是必要的。事后看来,提供这种不受约束的功能是一个糟糕的选择,因为它牺牲了完整性。当我们在 JDK 15 中引入 hidden classes,在 JDK 16 中引入 record classes 时,我们限制了深度反射,不允许修改隐藏类和 record 类中的 final 字段。当我们在 JDK 17 中强封装了 JDK 内部构件时,我们进一步限制了深度反射。在 JDK 24 中,我们启动了一个过程来删除 sun.misc.Unsafe 中的方法,这些方法与深度反射一样,允许修改 final 字段。

修改 final 字段的代码相对较少,但仅仅是存在用于执行此操作的 API 就使得开发人员或 JVM 无法信任 任何 final 字段的值。这损害了 所有 程序的安全性和性能。根据 默认完整性 的策略,我们计划强制执行 final 字段的不可变性,以便代码不能使用深度反射随意地重新赋值它们。我们将支持一种特殊情况——需要序列化库在反序列化期间修改 final 字段——通过一个有限用途的 API。

描述

在 JDK 5 及更高版本中,您可以通过深度反射(java.lang.reflect.Field 中的 setAccessibleset 方法)来修改 final 字段。在 JDK XX 中,我们将限制深度反射,以便默认情况下,修改 final 字段也会导致在运行时发出警告。仅仅使用 --add-opens 来启用对具有 final 字段的类的深度反射将无法避免警告。

我们将对修改 final 字段的限制称为 final 字段限制。我们将随着时间的推移加强 final 字段限制的效果。未来的 JDK 版本将抛出异常而不是发出警告,在默认情况下,当 Java 代码使用深度反射来修改 final 字段时。目的是确保应用程序和 Java Platform 具有默认完整性

启用 final 字段修改

应用程序开发人员可以通过在启动时为选定的 Java 代码启用 final 字段修改来避免警告(以及将来的异常)。启用 final 字段修改会确认应用程序需要修改 final 字段,并取消 final 字段限制。

在默认完整性的策略下,由应用程序开发人员(或者可能是部署人员,在应用程序开发人员的建议下)来启用 final 字段修改,而不是由库开发人员。依赖反射来修改 final 字段的库开发人员应该告知他们的用户,他们将需要使用以下方法之一来启用 final 字段修改。

要启用类路径上任何代码的 final 字段修改,而不管 final 字段是在哪里声明的,请使用以下命令行选项:

java --enable-final-field-mutation=ALL-UNNAMED ...

要启用模块路径上特定模块的 final 字段修改,同样不管 final 字段是在哪里声明的,请传递一个以逗号分隔的模块名称列表:

java --enable-final-field-mutation=M1,M2 ...

大多数希望允许 final 字段修改的应用程序开发人员将直接在启动脚本中将 --enable-final-field-mutation 传递给 java 启动器,但还有其他技术可用:

JDK XX 中的 API 更改

Field::setAccessible 的行为没有改变。这意味着当代码在 Field 对象 f 上调用 f.setAccessible(true) 时,代码必须与 f 反射的字段位于同一模块中,或者,如果代码位于不同的模块中,则 f 反射的字段必须通过 exportsopens 对调用者可见。如果这些条件不满足,则调用将抛出 InaccessibleObjectException

Field::set 的行为已更改为具有附加条件:

如果底层字段是 final,则只有在满足以下条件时,此 Field 对象才具有写入权限:
  已经为此 Field 对象成功调用 setAccessible(true);并且
  已为调用者的模块启用 final 字段修改
    并且此 Field 对象反射的字段的包对调用者的模块是开放的;并且
  该字段是非静态的;并且
  该字段的声明类不是隐藏类;并且
  该字段的声明类不是 record 类。
如果上述任何一项检查未通过,则此方法将抛出 IllegalAccessException。
请注意,如果此 Field 对象反映的字段是在调用者模块之外的模块中声明的,那么仅在导出的包中将该字段声明为 public 是不够的。该字段必须在开放的包中声明。
请注意,调用者可能与 setAccessible(true) 的调用者不同。

作为参考,如果满足以下条件,则模块 M 中的包 p 对模块 N开放的

因为每个模块都对自己开放,所以模块中的代码可以使用深度反射来修改同一模块中任何类的 final 字段,前提是在启动时为该模块启用了 final 修改。

其他相关方法的行为如下:

ModuleLayer.Controller::addOpensInstrumentation.redefineModule 也是如此。

控制 final 字段限制的效果

如果 为模块启用 final 字段修改,则模块中的代码通过深度反射修改任何 final 字段都是非法的。也就是说,给定一个反映 final 字段的 Field 对象 f,模块中的代码调用 f.setAccessible(true) 可能是合法的(取决于字段的包是否对调用者开放),但模块中的代码调用 f.set(..., ...) 是非法的。

如果 为模块启用 final 字段修改,但某些 final 字段位于未对模块开放的包中,则模块中的代码通过深度反射修改该 final 字段是非法的。当一个模块(字段的包对其开放)中的代码调用 f.setAccessible(true),然后将 f 传递给另一个模块(为其启用了 final 字段修改,但字段的包未对其开放)中的代码时,可能会发生这种情况。接收到 f 的代码调用 f.set(..., ...) 是非法的。

Java 运行时在尝试非法 final 字段修改时采取的操作由一个新的命令行选项 --illegal-reflective-final-mutation 控制。这在精神和形式上类似于 JDK 9 中的 JEP 261 引入的 --illegal-access 选项和 JDK 24 中的 JEP 472 引入的 --illegal-native-access。它的工作方式如下:

deny 成为默认模式时,allow 将被删除,但 warn 将至少保留一个版本以供支持。

为了为将来做好准备,我们建议使用 deny 模式运行现有代码,以识别通过深度反射修改 final 字段的代码。

关于修改 final 字段的警告

当从未启用 final 字段修改的模块调用 final 字段上的 Field::set 时,修改将成功,但 Java 运行时默认情况下会发出警告,以标识调用者:

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-reflective-final-mutation=N to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled

对于任何特定模块,最多发出一个此类警告,并且仅当尚未为该模块发出警告时才发出。该警告被写入标准错误流。

库不应使用深度反射来修改 final 字段

在 JDK 5 中添加了通过深度反射修改 final 字段的能力,以便序列化库可以提供与 JDK 自己的序列化工具相当的功能。特别是,即使对象的类声明了 final 字段,JDK 也可以从输入流中反序列化对象。JDK 绕过了通常分配实例字段的类的构造函数,而是直接将来自输入流的值分配给实例字段——即使它们是 final 的。第三方序列化库使用深度反射来执行相同的操作。

当在未来的 JDK 版本中加强 final 字段限制时,序列化库将不再能够开箱即用地使用深度反射。序列化库的开发者不应该要求用户在命令行上启用 final 字段修改,而应该使用 sun.reflect.ReflectionFactory 类来序列化和反序列化对象,该类为此目的提供了支持。即使从没有启用 final 字段修改的模块中的代码调用,它的反序列化方法也可以修改 final 字段。

sun.reflect.ReflectionFactory 类仅支持反序列化其类实现了 java.io.Serializable 的对象。我们相信这种限制平衡了使用序列化库的开发人员的利益与所有开发人员在拥有正确且高效的执行方面的更广泛利益。首先,它限制了任何利用反序列化的安全漏洞的影响,因为它不可能制作任意类的恶意对象。其次,它确保 JVM 在执行常量折叠等优化时,不会受到对其可以对 final 字段做出的假设的过度限制。虽然 JVM 必须将 Serializable 对象中的 final 字段视为可能可变的,但它可以假设所有其他对象(绝大多数)中的 final 字段是永久不可变的。

与序列化库不同,依赖注入、单元测试和模拟框架使用深度反射来操作对象,包括修改 final 字段。此类框架的维护者只应要求用户在命令行上启用 final 字段修改作为最后的手段。相反,维护者应该找到架构方法,以避免需要完全修改 final 字段(并访问 private 字段)。例如,大多数依赖注入框架现在禁止注入 final 字段,并且所有框架都不鼓励这样做,而是建议使用构造函数注入。

从原生代码修改 final 字段

原生代码可以通过调用 Java Native Interface (JNI) 中定义的 Set<Type>Field 函数SetStatic<Type>Field 函数 来修改 Java 字段。

这些函数在 final 字段上的行为是未定义的。这意味着该函数可以将字段修改为所需的值,或者将其修改为不同的值,或者根本不修改它,或者例如在 1000 次执行中正确修改它 999 次,但在 1000 次执行中导致 JVM 崩溃 1 次。当我们增强 JVM 的优化目录以利用对 Java 代码的 final 字段限制时,由于原生代码中未定义的行为而导致奇怪结果的可能性会增加。

由于存在未定义行为的可能性,因此已经存在对执行原生代码的限制,因此默认情况下,JVM 可以假设不会调用这些函数。但是,如果启用了原生访问,则此 JEP 建议使用新的诊断方法来减轻通过 JNI 修改 final 字段引起的奇怪结果的风险:

[0.20s][debug][jni] Set<Type>Field of final instance field C.f

或者

[0.20s][debug][jni] SetStatic<Type>Field of final static field C.f

在未来的 JDK 版本中,上述函数可能会更改,以便它们在 final 字段上调用时始终成功返回,但实际上永远不会产生任何修改。

当 Java 代码通过 sun.misc.Unsafe 类修改 final 字段时,没有诊断。这种修改可能会导致奇怪的错误或 JVM 崩溃。

风险和假设

替代方案