JEP草案:准备让 `final` 真正具有最终性
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
字段的能力(在必要时)来避免当前的警告和未来的限制。
目标
- 为未来的版本做准备,该版本默认情况下将禁止通过深度反射来修改
final
字段。在该版本发布后,应用程序开发人员将必须在启动时显式启用此功能。 - 使普通类中的
final
字段与 record 类中的组件对齐,record 类的组件不能通过深度反射修改。 - 允许序列化库继续与
Serializable
类一起工作,即使这些类具有final
字段。
非目标
- 目标不是弃用或删除 Java Platform API 的任何部分。
- 目标不是阻止序列化库在反序列化期间修改
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
中的 setAccessible
和 set
方法)来修改 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_JAVA_OPTIONS
间接将--enable-final-field-mutation
传递给启动器。 - 您可以将
--enable-final-field-mutation
放在一个参数文件中,该文件通过脚本或最终用户传递给启动器,例如java @config
。 - 您可以将
Enable-Reflective-Final-Mutation
添加到可执行 JAR 文件的清单中,即可通过java -jar
启动的 JAR 文件。(Enable-Reflective-Final-Mutation
清单条目唯一支持的值是ALL-UNNAMED
;其他值会导致抛出异常。) - 如果您为应用程序创建自定义 Java 运行时,则可以通过
--add-options
选项将--enable-final-field-mutation
选项传递给jlink
,以便在生成的运行时映像中启用反射式 final 字段修改。 - JNI Invocation API 允许原生应用程序将 JVM 嵌入到自己的进程中。使用 JNI Invocation API 的原生应用程序可以通过在创建 JVM 时传递
--enable-final-field-mutation
选项,为嵌入式 JVM 中的模块启用final
字段修改。
JDK XX 中的 API 更改
Field::setAccessible
的行为没有改变。这意味着当代码在 Field
对象 f
上调用 f.setAccessible(true)
时,代码必须与 f
反射的字段位于同一模块中,或者,如果代码位于不同的模块中,则 f
反射的字段必须通过 exports
或 opens
对调用者可见。如果这些条件不满足,则调用将抛出 InaccessibleObjectException
。
Field::set
的行为已更改为具有附加条件:
如果底层字段是 final,则只有在满足以下条件时,此 Field 对象才具有写入权限:
已经为此 Field 对象成功调用 setAccessible(true);并且
已为调用者的模块启用 final 字段修改
并且此 Field 对象反射的字段的包对调用者的模块是开放的;并且
该字段是非静态的;并且
该字段的声明类不是隐藏类;并且
该字段的声明类不是 record 类。
如果上述任何一项检查未通过,则此方法将抛出 IllegalAccessException。
请注意,如果此 Field 对象反映的字段是在调用者模块之外的模块中声明的,那么仅在导出的包中将该字段声明为 public 是不够的。该字段必须在开放的包中声明。
请注意,调用者可能与 setAccessible(true) 的调用者不同。
作为参考,如果满足以下条件,则模块 M
中的包 p
对模块 N
是 开放的:
N
是M
本身,或者M
的module-info
包含opens p
或opens p to N
,或者M
是一个 自动模块,即它的类被放置在模块路径上,但它没有module-info
,或者M
是一个 未命名模块(类路径上的所有类都在一个未命名模块中),或者- 应用程序启动时使用了命令行选项
--add-opens M/p=N
,或者应用程序作为可执行 JAR 文件启动,其清单包含适当的Add-Opens
属性。
因为每个模块都对自己开放,所以模块中的代码可以使用深度反射来修改同一模块中任何类的 final
字段,前提是在启动时为该模块启用了 final
修改。
其他相关方法的行为如下:
MethodHandles.Lookup::unreflectSetter
的行为与Field::set
类似地进行了更改。Module::addOpens
方法允许模块M
中的调用者在运行时将模块N
中的包对另一个模块O
开放,前提是该包已经对M
本身开放。调用此方法 不会 使O
能够修改包中的final
字段,即使在启动时为O
启用了final
字段修改,因为 JVM 已经决定信任包中的final
字段,因为它没有在启动时对O
开放。
ModuleLayer.Controller::addOpens
和 Instrumentation.redefineModule
也是如此。
- 存在
System::setIn
,System::setOut
, 和System::setErr
方法,分别修改final
字段System.in
,System.out
, 和System.err
。这些字段一直是写保护的,这意味着它们 只能 通过调用System
中相应的方法来修改。永远不可能通过深度反射来修改这些字段。在 JDK XX 中,这些字段及其相应的方法没有任何类型的更改。
控制 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
。它的工作方式如下:
--illegal-final-final-mutation=allow
允许修改继续进行,不发出警告。--illegal-final-final-mutation=warn
允许修改,但在特定模块中第一次发生非法final
字段修改时发出警告。每个模块最多发出一个警告。 此模式是 JDK XX 中的默认模式。它将在未来的版本中逐步淘汰,最终被删除。--illegal-final-final-mutation=deny
将导致Field::set
为每个非法final
字段修改抛出IllegalAccessException
。 此模式将在未来的版本中成为默认模式。
当 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
字段引起的奇怪结果的风险:
- 如果应用程序启动时启用了原生代码的统一日志记录 (
-Xlog:jni=debug
),则在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
- 如果应用程序启动时启用了 对 JNI 函数的额外检查 (
-Xcheck:jni
),则在final
字段上调用上述任何函数将导致 JVM 终止并显示错误消息。
在未来的 JDK 版本中,上述函数可能会更改,以便它们在 final
字段上调用时始终成功返回,但实际上永远不会产生任何修改。
当 Java 代码通过 sun.misc.Unsafe
类修改 final
字段时,没有诊断。这种修改可能会导致奇怪的错误或 JVM 崩溃。
风险和假设
- 自 JDK 5 以来,修改
final
字段的能力一直是 Java Platform 的一部分,因此存在final
字段限制会影响现有应用程序的风险。 - 我们假设依赖于直接或间接修改
final
字段的应用程序的开发人员将能够配置 Java 运行时,以通过--enable-final-final-mutation
启用该功能。这类似于他们已经可以通过--add-opens
配置 Java 运行时以禁用模块的强封装的方式。
替代方案
- Java 运行时可以依赖推测,乐观地假设
final
字段未被修改,检测它们何时被修改,并在发生这种情况时反优化代码,而不是强制执行final
字段的不可变性。虽然推测性优化是 JVM 的 JIT 编译器的主要内容,但在这种情况下它们可能不够,因为未来计划的优化可能希望不仅依赖于进程生命周期内的不可变性,而且还依赖于字段从应用程序的一次运行到下一次运行的不可变性。 - 我们可以指定允许其类的
final
字段被修改的模块,而不是指定哪些模块的代码可以修改final
字段。但是,由于通常不希望修改final
字段,因此命令行选项最好记录哪些模块应该更改为不再执行修改。指定可以修改其final
字段的模块将难以了解它们允许其字段被修改的目的以及由谁修改。 - 要求
--enable-final-field-mutation
指定 双方——执行修改的模块_和_包含被修改字段的模块——是不必要的负担。在许多实际情况下,--enable-final-field-mutation
将与--add-opens
结合使用,而后者已经指定了反射访问的双方。