JEP 472: 准备限制 JNI 的使用
概述
对使用Java Native Interface (JNI)的情况发出警告,并调整Foreign Function & Memory (FFM) API以一致的方式发出警告。所有这些警告旨在让开发人员为未来的版本做好准备,该版本将通过统一限制 JNI 和 FFM API 来确保默认的完整性。应用程序开发人员可以通过在必要时选择性地启用这些接口来避免当前的警告和未来的限制。
目标
-
保持JNI作为与本地代码互操作的标准方式的地位。
-
为将来的版本做好准备,该版本默认禁止通过JNI或FFM API与本地代码互操作。从该版本开始,应用程序开发人员需要在启动时显式启用JNI和FFM API的使用。
-
使JNI和FFM API的使用方式一致,以便库维护者可以在两者之间迁移,而无需应用程序开发人员更改任何命令行选项。
非目标
-
不打算弃用 JNI 或从 Java 平台上移除 JNI。
-
不打算限制通过 JNI 调用的本地代码的行为。例如,所有的本地 JNI 函数 仍可被本地代码使用。
动机
Java 本地接口 (JNI) 在 JDK 1.1 中被引入,作为 Java 代码和本地代码(通常是用 C 语言编写的)之间互操作的主要手段。JNI 允许 Java 代码调用本地代码(下调用),以及本地代码调用 Java 代码(上调用)。
不幸的是,Java 代码和本地代码之间的任何交互都是有风险的,因为它可能损害应用程序和 Java 平台本身的完整性。根据 默认完整性 的策略,所有能够破坏完整性的 JDK 功能都必须获得应用程序开发者的明确批准。
以下是四种常见的交互方式及其风险:
-
调用本地代码可能导致任意的未定义行为,包括 JVM 崩溃。这些问题无法通过 Java 运行时来预防,也不会引发 Java 代码可以捕获的异常。
例如,这个 C 函数接收从 Java 代码传递过来的一个
long
值,并将其视为内存中的地址,在该地址存储一个值:void Java_pkg_C_setPointerToThree__J(jlong ptr) {
*(int*)ptr = 3;
}调用这个 C 函数可能会破坏 JVM 使用的内存,导致 JVM 在 C 函数返回后的某个不可预测的时间崩溃。这种崩溃和其他意外行为很难诊断。
-
本地代码和 Java 代码通常通过 直接字节缓冲区 交换数据,这些内存区域不受 JVM 的垃圾收集器管理。本地代码可以生成一个由无效内存区域支持的字节缓冲区;在 Java 代码中使用这样的字节缓冲区几乎肯定会导致未定义行为。
例如,这段 C 代码构造了一个从地址 0 开始的 10 元素字节缓冲区,并将其返回给 Java 代码。当 Java 代码尝试读取或写入该字节缓冲区时,JVM 将崩溃:
return (*env)->NewDirectByteBuffer(env, 0, 10);
-
本地代码可以使用 JNI 来 访问字段 和 调用方法,而无需 JVM 进行任何访问检查。本地代码甚至可以使用 JNI 在
final
字段初始化后很长时间内更改其值。因此,调用本地代码的 Java 代码可能违反其他 Java 代码的完整性。例如,
String
对象被指定为不可变的,但这段 C 代码通过写入private
字段引用的数组来修改String
对象:jclass clazz = (*env)->FindClass(env, "java/lang/String");
jfieldID fid = (*env)->GetFieldID(env, clazz , "value", "[B");
jbyteArray contents = (jbyteArray)(*env)->GetObjectField(env, str, fid);
jbyte b = 0;
(*env)->SetByteArrayRegion(env, contents, 0, 1, &b);另一个例子是,数组被指定为 禁止超出其边界访问,但这段 C 代码可以写入数组末尾之外的位置:
jbyte *a = (*env)->GetPrimitiveArrayCritical(env, arr, 0);
a[500] = 3; // 可能越界
(*env)->ReleasePrimitiveArrayCritical(env, arr, a, 0); -
如果某些 JNI 函数使用不当,主要是
GetPrimitiveArrayCritical
和GetStringCritical
,则可能导致 不良的垃圾收集器行为,这可能在程序运行期间的任何时候表现出来。
在JDK 22中引入的外部函数和内存(FFM)API作为JNI的首选替代方案,也存在第一和第二风险。在FFM API中,我们采取了主动的方法来缓解这些风险,将可能危及完整性的操作与不会危及完整性的操作分开。因此,FFM API的一些部分被归类为受限方法,这意味着应用程序开发人员必须批准它们的使用并通过java
启动器命令行选项选择加入。JNI应该效仿FFM API的做法,默认实现完整性。
描述
在 JDK 22 及更高版本中,你可以通过 Java 本机接口(JNI)或外部函数和内存(FFM)API 调用本机代码。在这两种情况下,你都必须首先加载一个本机库,并将 Java 构造链接到库中的函数。这些加载和链接步骤在 FFM API 中是受限的,这意味着它们默认会在运行时发出警告。在 JDK 24 中,我们将限制 JNI 中的加载和链接步骤,以便它们也默认在运行时发出警告。
我们将对加载和链接本地库的限制称为本地访问限制。在 JDK 24 中,无论使用 JNI 还是 FFM API 加载和链接本地库,都将统一应用本地访问限制。JNI 中现在受本地访问限制约束的加载和链接本地库的确切操作将在后面描述。
我们将逐步加强本地访问限制的效果。未来的 JDK 版本在 Java 代码使用 JNI 或 FFM API 加载和链接本地库时,默认会抛出异常,而不是发出警告。这样做的目的不是阻止使用 JNI 或 FFM API,而是确保应用程序和 Java 平台具有默认完整性。
启用本地访问
应用程序开发人员可以通过在启动时为选定的 Java 代码启用本地访问来避免警告(以及将来可能出现的异常)。启用本地访问确认了应用程序加载和链接本地库的需求,并解除了本地访问限制。
在integrity by default策略下,是应用程序开发人员(或者可能是根据应用程序开发人员的建议进行部署的人员)启用本地访问,而不是库开发人员。依赖于 JNI 或 FFM API 的库开发人员应告知其用户,他们需要使用以下方法之一来启用本地访问。
要为类路径上的所有代码启用本地访问,请使用以下命令行选项:
java --enable-native-access=ALL-UNNAMED ...
要为模块路径上的特定模块启用本地访问,请传递一个逗号分隔的模块名称列表:
java --enable-native-access=M1,M2,... ...
使用 JNI 的代码如果存在以下情况,会受到本地访问限制的影响:
- 它调用了
System::loadLibrary
、System::load
、Runtime::loadLibrary
或Runtime::load
,或 - 它声明了一个
native
方法。
仅仅调用在不同模块中声明的 native
方法的代码不需要启用本地访问。
大多数应用程序开发人员会在启动脚本中直接将 --enable-native-access
传递给 java
启动器,但也可以使用其他技术:
-
你可以通过设置环境变量
JDK_JAVA_OPTIONS
间接地向启动器传递--enable-native-access
。 -
你可以将
--enable-native-access
放在一个参数文件中,该文件通过脚本或最终用户传递给启动器,例如java @config
。 -
你可以将
Enable-Native-Access: ALL-UNNAMED
添加到可执行 JAR 文件的清单中,即通过java -jar
启动的 JAR 文件。(Enable-Native-Access
清单条目的唯一支持值是ALL-UNNAMED
;其他值会导致抛出异常。) -
如果你为应用程序创建自定义 Java 运行时,可以通过
--add-options
选项将--enable-native-access
选项传递给jlink
,从而使生成的运行时映像启用本地访问。 -
如果你的代码动态创建模块,可以通过
ModuleLayer.Controller::enableNativeAccess
方法为它们启用本地访问,该方法本身是一个受限方法。代码可以通过Module::isNativeAccessEnabled
方法动态检查其模块是否启用了本地访问。 -
JNI 调用 API 允许本机应用程序在其进程中嵌入一个 JVM。使用 JNI 调用 API 的本机应用程序可以在 创建 JVM 时通过传递
--enable-native-access
选项来为嵌入式 JVM 中的模块启用本地访问。
更有选择性地启用本地访问
--enable-native-access=ALL-UNNAMED
选项是粗粒度的:它对类路径上的所有类解除 JNI 和 FFM API 的本地访问限制。为了降低风险并实现更高的完整性,我们建议将使用 JNI 或 FFM API 的 JAR 文件移到模块路径中。这样可以为这些 JAR 文件专门启用本地访问,而不是为整个类路径启用。JAR 文件可以从类路径移到模块路径而无需进行模块化;Java 运行时会将其视为 自动模块,其名称基于文件名。
控制本地访问限制的影响
如果未为某个模块启用本地访问,那么该模块中的代码执行受限操作是非法的。当尝试执行此类操作时,Java 运行时采取的措施由一个新的命令行选项 --illegal-native-access
控制,该选项在概念和形式上类似于 JDK 9 中由 JEP 261 引入的 --illegal-access
选项。其工作方式如下:
-
--illegal-native-access=allow
允许操作继续进行。 -
--illegal-native-access=warn
允许操作,但在特定模块中第一次发生非法本地访问时会发出警告。每个模块最多发出一个警告。这是 JDK 24 中的默认模式。此模式将在未来的版本中逐步淘汰,并最终被移除。
-
--illegal-native-access=deny
对每次非法本地访问操作抛出IllegalCallerException
异常。此模式将在未来的版本中成为默认模式。
当 deny
成为默认模式时,allow
将被移除,但 warn
至少在一个版本中仍会得到支持。
为了未来的准备,我们建议您使用 deny
模式运行现有代码,以识别需要本地访问的代码。
使 FFM API 一致
在 JDK 24 之前,如果一个或多个模块通过 --enable-native-access
选项启用了本地访问,那么从任何其他模块尝试调用受限的 FFM 方法都会导致抛出 IllegalCallerException
。
为了使 FFM API 与 JNI 保持一致,我们将放宽这种行为,使得非法的本地访问操作在 FFM API 中的处理方式与 JNI 完全相同。这意味着,在 JDK 24 中,此类操作将导致警告而不是异常。
你可以使用以下选项组合来恢复旧的行为:
java --enable-native-access=M,... --illegal-native-access=deny ...
加载本地库时的警告
本地库通过 java.lang.Runtime
类的 load
和 loadLibrary
方法在 JNI 中加载。(java.lang.System
类中同名的便捷方法 load
和 loadLibrary
仅仅是调用了系统范围的 Runtime
实例的相应方法。)
加载本地库是有风险的,因为它可能导致运行本地代码:
-
如果本地库定义了初始化函数,则在加载库时操作系统会运行它们;这些函数包含任意的本地代码。
-
如果本地库定义了
JNI_OnLoad
函数,则在加载库时 Java 运行时会调用它;此函数也包含任意的本地代码。
由于存在风险,load
和 loadLibrary
方法在 JDK 24 中受到了限制,就像 FFM API 中的 SymbolLookup::libraryLookup
方法受到限制一样。
当从一个未启用本地访问的模块调用受限方法时,JVM 会运行该方法,但默认情况下会发出一个警告来标识调用者:
WARNING: A restricted method in java.lang.System has been called
WARNING: System::load has been called by com.foo.Server in module com.foo (file:/path/to/com.foo.jar)
WARNING: Use --enable-native-access=com.foo to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled
对于任何特定的模块,最多只发出一个这样的警告,并且仅当该模块尚未发出警告时才发出。警告将被写入标准错误流。
关于链接本地库的警告
当 native
方法首次被调用时,它会自动链接到本地库中的相应函数。这个链接步骤称为绑定,在 JDK 24 中是一个受限操作,就像在 FFM API 中获取一个下向调用方法句柄 是一个受限操作一样。
在首次调用某个模块中声明的 native
方法而该模块未启用本地访问时,JVM 会绑定该 native
方法,但默认情况下会发出一个警告来标识调用者:
WARNING: A native method in org.baz.services.Controller has been bound
WARNING: Controller::getData in module org.baz has been called by com.foo.Server in an unnamed module (file:/path/to/foo.jar)
WARNING: Use --enable-native-access=org.baz to avoid a warning for native methods declared in org.baz
WARNING: Native methods will be blocked in a future release unless native access is enabled
对于任何特定模块,最多只发出一个这样的警告。具体来说:
-
仅在绑定
native
方法时发出警告,这发生在首次调用native
方法时。每次调用native
方法时都不会发出警告。 -
在特定模块中声明的任何
native
方法首次绑定时都会发出警告,除非该模块已经发出了警告。
警告被写入标准错误流。
确定原生代码的使用
-
JFR 事件
jdk.NativeLibraryLoad
和jdk.NativeLibraryUnload
用于跟踪本地库的加载和卸载。 -
为了帮助识别使用 JNI 的库,JDK 引入了一个新的工具,暂命名为
jnativescan
,该工具可以静态扫描提供的模块路径或类路径中的代码,并报告受限方法的使用情况以及native
方法的声明。
未来工作
-
为了促进可靠的配置,允许模块的声明断言该模块需要本地访问,无论是通过 JNI 还是 FFM API。在启动时,Java 运行时将拒绝加载任何需要本地访问但未在命令行上启用本地访问的模块。
-
为了允许使用 FFM API 而不是 JNI,提供一个命令行选项,该选项启用前者但不启用后者。JNI 允许本地代码破坏 Java 代码的封装,这可能会以 FFM API 不会的方式干扰未来的 JVM 优化。
风险和假设
-
JNI 从 JDK 1.1 开始就是 Java 平台的一部分,因此限制使用 JNI 可能会对现有应用程序产生影响。对 Maven Central 上的构件进行分析后发现,大约 7% 的现有构件依赖于本地代码。其中,约 25% 直接使用了 JNI;其余的则依赖于某些直接或间接使用 JNI 的其他构件。
-
我们假设那些直接或间接依赖于本地代码的应用程序的开发者能够配置 Java 运行时,通过
--enable-native-access
来启用 JNI 的使用,如上文所述。这类似于他们已经可以通过--add-opens
配置 Java 运行时来禁用模块的强封装性。
替代方案
- 而不是限制本地库的加载和
native
方法的绑定,JVM 可以在本地代码使用 JNI 函数 访问 Java 字段和方法时应用访问控制规则。然而,这不足以维护完整性,因为无论是否使用 JNI 函数,任何本地代码的使用都可能导致未定义的行为。出于同样的原因,FFM API 的某些部分也受到限制,即使 FFM API 并不提供从本地代码访问 Java 对象的功能。