跳到主要内容

JEP 419:外部函数和内存 API(第二个孵化版)

QWen Max 中英对照 JEP 419: Foreign Function & Memory API (Second Incubator)

总结

引入一个 API,通过它 Java 程序可以与 Java 运行时之外的代码和数据进行互操作。通过高效调用外部函数(即 JVM 之外的代码),以及安全访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本地库并处理本地数据,而无需担心 JNI 的脆弱性和危险性。

历史

外部函数和内存 API 由 JEP 412 提出,并于 2021 年年中作为孵化 API 被纳入 Java 17。它结合了两个早期的孵化 API:外部内存访问 API 和外部链接器 API。本 JEP 建议根据反馈进行改进,并在 Java 18 中重新孵化该 API。此次更新包含以下更改:

  • 内存访问变量句柄中支持更多载体,例如 booleanMemoryAddress
  • 一个更通用的解引用 API,可在 MemorySegmentMemoryAddress 接口中使用;
  • 获取下调用方法句柄的更简单 API,不再需要传递 MethodType 参数;
  • 管理资源作用域之间时间依赖关系的更简单 API;以及
  • 用于在 Java 数组与内存段之间复制的新 API。

目标

  • 易用性 — 使用更优越的纯 Java 开发模型替换 Java 原生接口(JNI)。

  • 性能 — 提供与现有 API(如 JNI 和 sun.misc.Unsafe)相当甚至更好的性能。

  • 通用性 — 提供操作不同种类外部内存(例如,原生内存、持久内存和托管堆内存)的方式,并随着时间推移,适应其他平台(例如,32 位 x86)以及用非 C 语言(如 C++、Fortran)编写的外部函数。

  • 安全性 — 默认禁用不安全操作,仅在应用程序开发者或最终用户明确选择加入后才允许这些操作。

非目标

它不是一个目标

  • 在此 API 之上重新实现 JNI,或以任何方式更改 JNI;
  • 在此 API 之上重新实现旧版 Java API,例如 sun.misc.Unsafe
  • 提供从原生代码头文件自动生成 Java 代码的工具;或
  • 更改与原生库交互的 Java 应用程序的打包和部署方式(例如,通过多平台 JAR 文件)。

动机

Java 平台始终为希望超越 JVM 与其他平台交互的库和应用程序开发者提供了一个丰富的基础。Java API 可以方便且可靠地暴露非 Java 资源,无论是访问远程数据(JDBC)、调用 Web 服务(HTTP 客户端)、服务远程客户端(NIO 通道),还是与本地进程通信(Unix 域套接字)。然而,Java 开发者在访问一种重要的非 Java 资源时仍然面临显著障碍:即与 JVM 位于同一台机器上但位于 Java 运行时之外的代码和数据。

外部内存

存储在 Java 运行时之外的内存中的数据被称为堆外数据。(是 Java 对象所在的地方——即堆内数据——也是垃圾回收器工作的地方。)访问堆外数据对于 TensorflowIgniteLuceneNetty 等流行 Java 库的性能至关重要,主要原因是它能够帮助它们避免与垃圾回收相关的成本和不可预测性。它还允许通过诸如 mmap 等方式将文件映射到内存中,从而实现数据结构的序列化和反序列化。然而,Java 平台并未提供访问堆外数据的满意解决方案。

  • ByteBuffer API 允许创建分配在堆外的 直接 字节缓冲区,但它们的最大大小为 2 GB,并且不会立即释放。这些限制以及其他限制的原因在于,ByteBuffer API 不仅设计用于堆外内存访问,还用于在字符集编码/解码和部分 I/O 操作等领域中进行生产者/消费者之间的批量数据交换。在这种背景下,多年来提出的许多关于堆外增强的请求都未能得到满足(例如,4496703655836848375645029431)。

  • sun.misc.Unsafe API 提供了对堆内数据的内存访问操作,这些操作同样适用于堆外数据。使用 Unsafe 是高效的,因为它的内存访问操作被定义为 HotSpot JVM 的内部函数,并由 JIT 编译器优化。然而,使用 Unsafe 是危险的,因为它允许访问任何内存地址。这意味着 Java 程序可能通过访问已释放的内存地址导致 JVM 崩溃;出于这个原因以及其他原因,使用 Unsafe 一直被强烈反对

  • 使用 JNI 调用一个本地库,然后该库访问堆外数据是可行的,但性能开销很少使其适用:从 Java 调用本地代码比访问内存慢几个数量级,因为 JNI 方法调用无法从许多常见的 JIT 优化(如内联)中受益。

总之,当涉及到访问堆外数据时,Java 开发人员面临一个两难的选择:是选择安全但低效的方式(ByteBuffer),还是放弃安全性以换取性能(Unsafe)?他们实际上需要的是一个受支持的 API,用于访问堆外数据(即外部内存),并且该 API 从设计之初就应确保安全性并考虑 JIT 优化。

外部函数

自 Java 1.1 起,JNI 就支持对原生代码(即外部函数)的调用,但出于许多原因,它并不足够。

  • JNI 涉及多个繁琐的构件:一个 Java API(native 方法)、从 Java API 派生的 C 头文件,以及调用目标本地库的 C 实现。Java 开发人员必须跨多个工具链工作,以保持与平台相关的构件同步,当本地库快速演进时,这尤其繁重。

  • JNI 只能与使用 JVM 构建所针对的操作系统和 CPU 调用约定的语言(通常是 C 和 C++)编写的库进行互操作。native 方法不能用于调用使用不同约定编写语言中的函数。

  • JNI 并未将 Java 类型系统与 C 类型系统进行协调。Java 中的聚合数据用对象表示,而 C 中的聚合数据用结构体表示,因此传递给 native 方法的任何 Java 对象都必须由本地代码费力地解包。例如,考虑一个 Java 的记录类 Person:将一个 Person 对象传递给 native 方法时,需要本地代码使用 JNI 的 C API 从对象中提取字段(例如,firstNamelastName)。结果是,Java 开发人员有时会将其数据展平为单个对象(例如,字节数组或直接字节缓冲区),但更多时候,由于通过 JNI 传递 Java 对象速度较慢,他们会使用 Unsafe API 分配堆外内存,并将其地址作为 long 类型传递给 native 方法 —— 这使得 Java 代码变得极不安全!

多年来,出现了许多框架来填补 JNI 留下的空白,包括 JNAJNRJavaCPP。虽然这些框架通常比 JNI 有了显著的改进,但情况仍然不够理想,特别是与那些提供一流本地互操作性的语言相比。例如,Python 的 ctypes 包可以动态地包装本地库中的函数,而无需任何胶水代码。其他语言(如 Rust)提供了工具,可以从 C/C++ 头文件中机械地生成本地包装器。

最终,Java 开发者应该拥有一个受支持的 API,使他们能够直接使用任何被认为对特定任务有用的原生库,而无需 JNI 的繁琐粘连和笨拙。一个极佳的抽象构建基础是 method handles(方法句柄),它在 Java 7 中被引入以支持 JVM 上的快速动态语言。通过方法句柄暴露原生代码将极大地简化编写、构建和分发依赖于原生库的 Java 库的任务。此外,一个能够建模外部函数(即原生代码)和外部内存(即堆外数据)的 API 将为第三方原生互操作框架提供坚实的基础。

描述

外部函数和内存 API(FFM API)定义了类和接口,以便库和应用程序中的客户端代码可以

  • 分配外部内存
    MemorySegmentMemoryAddressSegmentAllocator),
  • 操作和访问结构化的外部内存
    MemoryLayoutVarHandle),
  • 管理外部资源的生命周期(ResourceScope),以及
  • 调用外部函数(SymbolLookupCLinkerNativeSymbol)。

FFM API 位于 jdk.incubator.foreign 模块的 jdk.incubator.foreign 包中。

示例

作为使用 FFM API 的一个简单示例,以下是获取 C 库函数 radixsort 的方法句柄的 Java 代码,然后使用它对从 Java 数组开始的四个字符串进行排序(省略了一些细节):

// 1. Find foreign function on the C library path
CLinker linker = CLinker.getInstance();
MethodHandle radixSort = linker.downcallHandle(
linker.lookup("radixsort"), ...);
// 2. Allocate on-heap memory to store four strings
String[] javaStrings = { "mouse", "cat", "dog", "car" };
// 3. Allocate off-heap memory to store four pointers
MemorySegment offHeap = MemorySegment.allocateNative(
MemoryLayout.ofSequence(javaStrings.length,
ValueLayout.ADDRESS), ...);
// 4. Copy the strings from on-heap to off-heap
for (int i = 0; i < javaStrings.length; i++) {
// Allocate a string off-heap, then store a pointer to it
MemorySegment cString = implicitAllocator().allocateUtf8String(javaStrings[i]);
offHeap.setAtIndex(ValueLayout.ADDRESS, i, cString);
}
// 5. Sort the off-heap data by calling the foreign function
radixSort.invoke(offHeap, javaStrings.length, MemoryAddress.NULL, '\0');
// 6. Copy the (reordered) strings from off-heap to on-heap
for (int i = 0; i < javaStrings.length; i++) {
MemoryAddress cStringPtr = offHeap.getAtIndex(ValueLayout.ADDRESS, i);
javaStrings[i] = cStringPtr.getUtf8String(0);
}
assert Arrays.equals(javaStrings, new String[] {"car", "cat", "dog", "mouse"}); // true
java

此代码比任何使用 JNI 的解决方案都要清晰得多,因为隐式转换和内存解引用(原本隐藏在 native 方法调用背后)现在可以直接用 Java 表达。现代 Java 习语也可以使用;例如,流可以允许多个线程并行地在堆内和堆外内存之间复制数据。

内存段

内存段 是一种抽象概念,用于建模位于堆外或堆内的一块连续内存区域。内存段可以是

  • Native 段,从本地内存中分配(例如,通过 malloc),
  • Mapped 段,包装在映射的本地内存区域周围(例如,通过 mmap),或者
  • Arraybuffer 段,分别包装在与现有 Java 数组或字节缓冲区关联的内存周围。

所有内存段都提供了强健的空间、时间和线程隔离保证,这些保证使得内存解引用操作变得安全。例如,以下代码分配了 100 字节的堆外内存:

// 示例代码无需翻译
java
MemorySegment segment = MemorySegment.allocateNative(100,
newImplicitScope());
java

段的空间边界决定了与该段关联的内存地址范围。上面代码中的段边界由基址 b(表示为 MemoryAddress 实例)和大小(以字节为单位,这里是 100)定义,从而形成的地址范围从 bb + 99(含)。

一个分段的时间界限决定了该分段的生命周期,即该分段何时会被释放。分段的生命周期和线程约束状态由ResourceScope抽象建模,详见下文。上面代码中的资源作用域是一个新的共享作用域,它确保当垃圾回收器认为 MemorySegment 对象不可达时,与此分段关联的内存将被释放。共享作用域还确保可以从多个线程访问该内存分段。

换句话说,上面的代码创建了一个行为与使用 allocateDirect 工厂分配的 ByteBuffer 非常相似的段。FFM API 还支持确定性内存释放以及其他线程限制选项,将在下文中讨论。

解引用段

要对某个内存段中的数据进行解引用,我们需要考虑几个因素:

  • 要解引用的字节数,
  • 发生解引用操作时地址的对齐约束,
  • 所述内存区域中字节存储的字节序,以及
  • 解引用操作中使用的 Java 类型(例如 intfloat)。

所有这些特性都包含在 ValueLayout 抽象中。例如,预定义的 JAVA_INT 值布局宽度为四个字节,没有对齐约束,使用本地平台的字节序(例如,在 Linux/x64 上为小端序),并且与 Java 类型 int 相关联。

内存段具有简单的解引用方法,用于从内存段读取和写入值。这些方法接受一个值布局,该布局唯一地指定了解引用操作的属性。例如,我们可以使用以下代码在内存段的连续偏移位置写入 25 个 int 值:

MemorySegment segment = MemorySegment.allocateNative(100,
newImplicitScope());
for (int i = 0; i < 25; i++) {
segment.setAtIndex(ValueLayout.JAVA_INT,
/* index */ i,
/* value to write */ i);
}
java

内存布局与结构化访问

考虑以下 C 语言声明,它定义了一个 Point 结构体数组,其中每个 Point 结构体有两个成员,分别是 Point.xPoint.y

struct Point {
int x;
int y;
} pts[10];
java

使用上一节中展示的解引用方法,要初始化这样的原生数组,我们必须编写以下代码:

MemorySegment segment = MemorySegment.allocateNative(2 * 4 * 10,
newImplicitScope());
for (int i = 0; i < 10; i++) {
segment.setAtIndex(ValueLayout.JAVA_INT,
/* index */ (i * 2),
/* value to write */ i); // x
segment.setAtIndex(ValueLayout.JAVA_INT,
/* index */ (i * 2) + 1,
/* value to write */ i); // y
}
java

为了减少对繁琐的内存布局计算(例如,上面示例中的 (i * 2) + 1)的需求,可以使用 MemoryLayout 以更具声明性的方式来描述内存段的内容。例如,上述示例中本地内存段的期望布局可以按照以下方式描述:

SequenceLayout ptsLayout
= MemoryLayout.sequenceLayout(10,
MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("x"),
ValueLayout.JAVA_INT.withName("y")));
java

这将创建一个 sequence memory layout,其中包含十个重复的 struct layout,其元素分别是名为 xy 的两个 JAVA_INT 布局。有了这个布局,我们可以通过创建一个 memory-access var handle 来避免在代码中计算偏移量。这是一种特殊的 var handle,它接受一个 MemorySegment 参数(要解引用的段)以及一个或多个 long 坐标(应该发生解引用操作的索引位置)。

VarHandle xHandle    // (MemorySegment, long) -> int
= ptsLayout.varHandle(PathElement.sequenceElement(),
PathElement.groupElement("x"));
VarHandle yHandle // (MemorySegment, long) -> int
= ptsLayout.varHandle(PathElement.sequenceElement(),
PathElement.groupElement("y"));

MemorySegment segment = MemorySegment.allocateNative(ptsLayout,
newImplicitScope());
for (int i = 0; i < ptsLayout.elementCount().getAsLong(); i++) {
xHandle.set(segment,
/* index */ (long) i,
/* value to write */ i); // x
yHandle.set(segment,
/* index */ (long) i,
/* value to write */ i); // y
}
java

ptsLayout 对象通过创建一个 布局路径 来驱动内存访问变量句柄的创建,该路径用于从复杂的布局表达式中选择嵌套布局。由于所选的值布局与 Java 类型 int 相关联,因此生成的变量句柄 xHandleyHandle 的类型也将是 int。此外,由于所选的值布局定义在序列布局内,生成的变量句柄会获得一个额外的 long 类型坐标,即要读取或写入的 Point 结构体的索引。ptsLayout 对象还驱动了本地内存段的分配,这是基于从布局中获取的大小和对齐信息。由于使用了不同的变量句柄来初始化 Point.xPoint.y 元素,循环内部不再需要进行偏移计算。

资源范围

上面的所有示例都使用了非确定性的释放方式:分配的段相关联的内存会在内存段实例变为不可达后由垃圾回收器释放。我们称这些段为隐式释放

有些情况下,客户端可能希望控制何时发生内存释放。例如,假设使用 MemorySegment::map 从文件映射了一段较大的内存区域。客户端可能希望在该内存段不再需要时立即释放(即解除映射)与其相关的内存,而不是等待垃圾回收器来处理,因为等待可能会对应用程序的性能产生不利影响。

内存段通过资源作用域支持确定性的释放操作。资源作用域模拟了一个或多个资源的生命周期,例如内存段。一个新建的资源作用域处于活动状态,这意味着它管理的所有资源都可以被安全访问。在客户端请求时,资源作用域可以被关闭,从而不再允许访问该作用域管理的资源。ResourceScope 类实现了 AutoCloseable 接口,因此资源作用域可以与 try-with-resources 语句配合使用:

try (ResourceScope scope = ResourceScope.newConfinedScope()) {
MemorySegment s1 = MemorySegment.map(Path.of("someFile"),
0, 100000,
MapMode.READ_WRITE, scope);
MemorySegment s2 = MemorySegment.allocateNative(100, scope);
...
} // both segments released here
java

此代码创建一个资源范围,并使用它来创建两个段:映射段(s1)和原生段(s2)。这两个段的生命周期与资源范围的生命周期绑定在一起,因此在 try-with-resources 语句之外访问这些段(例如,通过内存访问变量句柄解引用它们)将导致抛出运行时异常。

除了管理内存段的生命周期外,资源范围还控制哪些线程可以访问该段。受限的资源范围将访问限制为创建该范围的线程,而共享的资源范围允许任何线程访问。

资源作用域,无论是受限的还是共享的,都可以与一个 java.lang.ref.Cleaner 对象相关联,该对象在资源作用域仍然存活但变得不可达时执行隐式释放,从而防止意外的内存泄漏。

段分配器

当客户端使用堆外内存时,内存分配常常会成为一个瓶颈。因此,FFM API 包含了一个 SegmentAllocator 抽象,它定义了用于分配和初始化内存段的有用操作。段分配器通过 SegmentAllocator 接口中的工厂方法获取。其中一个工厂方法返回隐式分配器,即一种分配由全新隐式作用域支持的原生段的分配器。同时还提供了其他更优化的分配器。例如,以下代码创建了一个基于 Arena 的分配器,并使用它分配一个内容从 Java int 数组初始化的段:

try (ResourceScope scope = ResourceScope.newConfinedScope()) {
SegmentAllocator allocator = SegmentAllocator.newNativeArena(scope);
for (int i = 0 ; i < 100 ; i++) {
MemorySegment s = allocator.allocateArray(JAVA_INT,
new int[] { 1, 2, 3, 4, 5 });
...
}
...
} // all memory allocated is released here
java

此代码创建了一个受限的资源范围,然后创建一个与该范围关联的无界区域分配器。该分配器分配一段内存,并通过返回预先分配的那段内存的切片来响应分配请求。如果当前段没有足够的空间来容纳分配请求,则会分配一个新段。当与区域分配器关联的资源范围关闭时,分配器创建的段(即,在 for 循环体中)所关联的所有内存都会被原子性地释放。这种技术结合了由 ResourceScope 抽象提供的确定性释放的优势以及更灵活和可扩展的分配方案。在编写管理大量堆外段的代码时,这非常有用。

不安全的内存段

到目前为止,我们已经了解了内存段、内存地址和内存布局。解引用操作只能在内存段上进行。由于内存段具有空间和时间界限,Java 运行时确保与给定段关联的内存能够被安全地解引用。然而,在某些情况下,客户端可能只有 MemoryAddress 实例,这在与原生代码交互时经常发生。要解引用一个内存地址,客户端有两种选择:

  • 首先,客户端可以使用 MemoryAddress 类中定义的解引用方法之一。这些方法是不安全的,因为内存地址没有空间或时间界限,因此 FFM API 无法确保被解引用的内存位置是有效的。

  • 另一种选择是,客户端可以通过 MemorySegment::ofAddressNative 工厂方法将地址不安全地转换为段。此工厂方法会为原本原始的内存地址附加新的空间和时间界限,从而允许进行解引用操作。此工厂返回的内存段是不安全的:一个原始内存地址可能与一个 10 字节长的内存区域相关联,但客户端可能错误地高估了该区域的大小,并创建一个 100 字节长的不安全内存段。稍后,这可能导致尝试解引用超出与不安全段关联的内存区域界限的内存,从而可能引发 JVM 崩溃,或者更糟的是导致悄无声息的内存损坏。

这两个选项都不安全,因此被认为是受限操作,默认情况下是禁用的(更多信息见下方)。

查找外部函数

任何对外部函数支持的第一个要素都是加载原生库的机制。JNI 通过 System::loadLibrarySystem::load 方法实现了这一点,这些方法在内部映射为对 dlopen 或其等效函数的调用。使用这些方法加载的库始终与一个类加载器相关联,即调用该方法的类的加载器。库与类加载器之间的关联至关重要,因为它决定了已加载库的生命周期:只有当一个类加载器不再可访问时,它的所有库才能被安全卸载。

FFM API 并未提供加载原生库的新方法。开发者使用 System::loadLibrarySystem::load 方法来加载可通过 FFM API 调用的原生库。库与类加载器之间的关联得以保留,因此库将以与 JNI 相同的可预测方式卸载。

FFM API 与 JNI 不同,它提供了在已加载的库中查找给定符号地址的能力。这种能力由 SymbolLookup 对象表示,对于将 Java 代码链接到外部函数至关重要(参见下文)。获取 SymbolLookup 对象有两种方法:

  • 通过调用 SymbolLookup::loaderLookup,它会返回一个符号查找器,该查找器可以定位当前类加载器加载的所有库中的所有符号,或者

  • 通过获取一个 CLinker 实例,该实例实现了 SymbolLookup 接口,可用于在标准 C 库中查找特定于平台的符号。

给定一个符号查找,客户端可以使用 SymbolLookup::lookup(String) 方法找到一个外部函数。如果命名的函数存在于符号查找所见的符号之中,那么该方法会返回一个指向该函数入口点的 NativeSymbol。例如,以下代码加载了 OpenGL 库,使其与当前类加载器关联,并找到其 glGetString 函数的地址:

System.loadLibrary("GL");
SymbolLookup loaderLookup = SymbolLookup.loaderLookup();
NativeSymbol clangVersion = loaderLookup.lookup("glGetString").get();
java

将 Java 代码链接到外部函数

CLinker 接口是 Java 代码与原生代码互操作的核心。虽然 CLinker 主要专注于提供 Java 和 C 库之间的互操作性,但该接口中的概念足够通用,未来可以支持其他非 Java 语言。该接口既支持 下调用(从 Java 代码调用原生代码)也支持 上调用(从原生代码回调到 Java 代码)。

interface CLinker {
MethodHandle downcallHandle(NativeSymbol func,
FunctionDescriptor function);
NativeSymbol upcallStub(MethodHandle target,
FunctionDescriptor function,
ResourceScope scope);
}
java

对于向下调用,downcallHandle 方法接收一个外部函数的地址(通常是从库查找中获得的 NativeSymbol),并将此外部函数暴露为一个 向下调用方法句柄。随后,Java 代码通过调用其 invoke(或 invokeExact)方法来调用这个向下调用方法句柄,外部函数开始运行。传递给方法句柄 invoke 方法的任何参数都会被传递给外部函数。

对于上行调用,upcallStub 方法接受一个方法句柄 —— 通常是指向 Java 方法的句柄,而不是下行调用方法句柄 —— 并将其转换为 NativeSymbol 实例。随后,当 Java 代码调用下行调用方法句柄时,该原生符号作为参数传递。实际上,原生符号充当了函数指针的角色。(有关上行调用的更多信息,请参见下文。)

假设我们要从 Java 向标准 C 库中定义的 strlen 函数进行下层调用:

size_t strlen(const char *s);
c

可以按如下方式获取公开 strlen 的下调方法句柄(FunctionDescriptor 的细节将在稍后描述):

CLinker linker = CLinker.systemCLinker();
MethodHandle strlen = linker.downcallHandle(
linker.lookup("strlen").get(),
FunctionDescriptor.of(JAVA_LONG, ADDRESS)
);
java

调用 downcall 方法句柄将运行 strlen 并使其结果在 Java 中可用。对于 strlen 的参数,我们使用一个辅助方法将 Java 字符串转换为堆外内存段(使用隐式分配器),然后通过引用传递:

MemorySegment str = implicitAllocator().allocateUtf8String("Hello");
long len = strlen.invoke(cString); // 5
java

方法句柄在暴露外部函数时表现良好,因为 JVM 已经优化了方法句柄的调用,直至原生代码。当一个方法句柄引用 class 文件中的方法时,调用该方法句柄通常会导致目标方法被 JIT 编译;随后,JVM 通过将控制权转移给为目标方法生成的汇编代码来解释调用 MethodHandle::invokeExact 的 Java 字节码。因此,Java 中的传统方法句柄在背后针对的是非 Java 代码;而下向调用方法句柄是一种自然的扩展,允许开发者显式地针对非 Java 代码。方法句柄还具有一个称为 签名多态性 的特性,该特性允许使用基本类型参数进行无装箱调用。总之,方法句柄使 CLinker 能够以一种自然、高效且可扩展的方式暴露外部函数。

在 Java 中描述 C 类型

要创建一个 downcall 方法句柄,FFM API 要求客户端提供一个 FunctionDescriptor,用以描述目标 C 函数的 C 参数类型和 C 返回类型。在 FFM API 中,C 类型通过 MemoryLayout 对象来描述,例如用于标量 C 类型的 ValueLayout 和用于 C 结构类型的 GroupLayout。客户端通常手头已有 MemoryLayout 对象,用于解引用外来内存中的数据,并且可以重用它们来获取 FunctionDescriptor

FFM API 还使用 FunctionDescriptor 来推导下调方法句柄的类型。每个方法句柄都是强类型的,这意味着它对运行时传递给其 invokeExact 方法的参数数量和类型要求非常严格。例如,为接受一个 MemoryAddress 参数而创建的方法句柄不能通过 invokeExact(<MemoryAddress>, <MemoryAddress>) 调用,即使 invokeExact 是一个可变参数方法。下调方法句柄的类型描述了客户端在调用下调方法句柄时必须使用的 Java 签名。实际上,这是 C 函数的 Java 视图。

例如,假设一个 downcall 方法句柄应该公开一个接受 C 语言中的 int 类型并返回 C 语言中的 long 类型的 C 函数。在 Linux/x64 和 macOS/x64 上,C 语言中的 longint 类型分别与预定义的布局 JAVA_LONGJAVA_INT 相关联,因此所需的 FunctionDescriptor 可以通过 FunctionDescriptor.of(JAVA_LONG, JAVA_INT) 获得。然后,CLinker 会将 downcall 方法句柄的类型安排为从 Java 签名 intlong

如果客户端针对使用标量类型的 C 函数(例如 longintsize_t),则必须了解当前平台。这是因为标量 C 类型与布局常量的关联因平台而异。在 Windows/x64 上,C long 与 JAVA_INT 布局相关联,因此所需的 FunctionDescriptor 将是 FunctionDescriptor.of(JAVA_INT, JAVA_INT),而下调方法句柄的类型将是 Java 签名 intint

再比如,假设一个 downcall 方法句柄应该暴露一个接受指针的 void C 函数。在所有平台上,C 指针类型都与预定义布局 ADDRESS 相关联,因此可以通过 FunctionDescriptor.ofVoid(ADDRESS) 获取所需的 FunctionDescriptor。然后,CLinker 会将 downcall 方法句柄的类型安排为从 Addressablevoid 的 Java 签名。Addressable 是 FFM API 中可以通过引用传递的实体的通用超类型,例如 MemorySegmentMemoryAddressNativeSymbol

客户端可以使用 C 语言指针,而无需了解当前平台。客户端不需要知道当前平台上指针的大小,因为 ADDRESS 布局的大小是从当前平台推断出来的,客户端也不需要区分 C 指针类型,例如 int*char**

最后,与 JNI 不同,CLinker 支持将结构化数据传递给外部函数。假设一个 downcall 方法句柄应该暴露一个接受结构体的 void C 函数,该结构体由以下布局描述:

MemoryLayout SYSTEMTIME  = MemoryLayout.ofStruct(
JAVA_SHORT.withName("wYear"), JAVA_SHORT.withName("wMonth"),
JAVA_SHORT.withName("wDayOfWeek"), JAVA_SHORT.withName("wDay"),
JAVA_SHORT.withName("wHour"), JAVA_SHORT.withName("wMinute"),
JAVA_SHORT.withName("wSecond"), JAVA_SHORT.withName("wMilliseconds")
);
java

所需的 FunctionDescriptor 可以通过 FunctionDescriptor.ofVoid(SYSTEMTIME) 获取。CLinker 会将 downcall 方法句柄的类型安排为从 MemorySegmentvoid 的 Java 签名。

与 C 结构类型关联的内存布局必须是复合布局,该布局定义了 C 结构中所有字段的子布局,包括任何平台相关的填充(native 编译器可能会插入的)。

如果一个 C 函数返回一个按值传递的结构体(此处未展示),那么必须在堆外分配一段新的内存,并将其返回给 Java 客户端。为了实现这一点,downcallHandle 返回的方法句柄需要一个额外的 SegmentAllocator 参数,FFM API 使用该参数来分配一个内存段以保存 C 函数返回的结构体。

如前所述,CLinker 专注于提供 Java 和 C 库之间的互操作性,但它是与语言无关的:它对 C 语言类型如何定义没有特定的知识,因此用户需要负责获取适用于 C 语言类型的布局定义。这一选择是经过深思熟虑的,因为 C 语言类型的布局定义 —— 无论是简单的标量还是复杂的结构体 —— 最终都依赖于平台,因此可以由一个对该目标平台有深入了解的工具自动生成。

封装传递给 C 函数的 Java 参数

调用约定 通过指定一种语言中的代码如何调用另一种语言中的函数、传递参数以及接收结果,从而实现不同语言之间的互操作。CLinker API 在调用约定方面保持中立,但 CLinker 的实现开箱即用地支持多种调用约定:Linux/x64、Linux/AArch64、macOS/x64 和 Windows/x64。由于它是用 Java 编写的,因此比 JNI 更容易维护和扩展,JNI 的调用约定是硬编码在 HotSpot 的 C++ 代码中的。

考虑上面获得的 SYSTEMTIME 结构/布局的 FunctionDescriptor。根据运行 JVM 的操作系统和 CPU 的调用约定,CLinker 使用 FunctionDescriptor 来推断当使用 MemorySegment 参数调用 downcall 方法句柄时,应该如何将结构的字段传递给 C 函数。对于一种调用约定,CLinker 可以安排分解传入的内存段,使用通用 CPU 寄存器传递前四个字段,并将剩余字段传递到 C 栈上。对于另一种调用约定,CLinker 可以安排通过分配一块内存区域来间接传递该结构,将传入内存段的内容批量复制到该区域,并将指向该内存区域的指针传递给 C 函数。这种最低级别的参数封装在幕后自动完成,无需任何客户端代码的监督。

回调

有时,将 Java 代码作为函数指针传递给某些外部函数是很有用的。我们可以通过使用 CLinker 对上行调用的支持来实现这一点。在本节中,我们将一步步构建一个更复杂的示例,展示 CLinker 的全部功能,实现跨越 Java/原生边界的代码与数据的完全双向互操作。

请考虑以下在标准 C 库中定义的函数:

void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
java

要从 Java 调用 qsort,我们首先需要创建一个 downcall 方法句柄:

CLinker linker = CLinker.systemCLinker();
MethodHandle qsort = linker.downcallHandle(
linker.lookup("qsort").get(),
FunctionDescriptor.ofVoid(ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS)
);
java

和之前一样,我们使用 JAVA_LONG 布局来映射 C 语言中的 size_t 类型,并且对第一个指针参数(数组指针)和最后一个参数(函数指针)都使用 ADDRESS 布局。

qsort 使用作为函数指针传入的自定义比较函数 compar 对数组的内容进行排序。因此,要调用 downcall 方法句柄,我们需要一个函数指针作为最后一个参数传递给方法句柄的 invokeExact 方法。CLinker::upcallStub 可以帮助我们通过使用现有的方法句柄来创建函数指针,如下所示。

首先,我们在 Java 中编写一个 static 方法,该方法比较两个由 MemoryAddress 对象间接表示的 long 值:

class Qsort {
static int qsortCompare(MemoryAddress addr1, MemoryAddress addr2) {
return addr1.get(JAVA_INT, 0) - addr2.get(JAVA_INT, 0);
}
}
java

其次,我们创建一个指向 Java 比较器方法的方法句柄:

MethodHandle comparHandle
= MethodHandles.lookup()
.findStatic(Qsort.class, "qsortCompare",
MethodType.methodType(int.class,
MemoryAddress.class,
MemoryAddress.class));
java

第三,现在我们已经为 Java 比较器获取了方法句柄,可以使用 CLinker::upcallStub 创建一个函数指针。与向下调用一样,我们使用 FunctionDescriptor 来描述函数指针的签名:

NativeSymbol comparFunc =
linker.upcallStub(comparHandle,
/* A Java description of a C function
implemented by a Java method! */
FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS),
newImplicitScope());
);
java

我们终于得到了一个内存地址 comparFunc,它指向一个存根,可以用来调用我们的 Java 比较器函数,因此我们现在已具备调用 qsort 下行调用句柄所需的一切:

MemorySegment array = implicitAllocator().allocateArray(
ValueLayout.JAVA_INT,
new int[] { 0, 9, 3, 4, 6, 5, 1, 8, 2, 7 });
qsort.invoke(array, 10L, 4L, comparFunc);
int[] sorted = array.toIntArray(); // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
java

该代码创建了一个堆外数组,将 Java 数组的内容复制到其中,然后将该数组连同我们从 CLinker 获得的比较器函数一起传递给 qsort 句柄。调用完成后,堆外数组的内容将根据我们用 Java 编写的比较器函数进行排序。之后,我们从段中提取一个新的 Java 数组,其中包含已排序的元素。

安全性

从根本上讲,Java 代码与本地代码之间的任何交互都可能破坏 Java 平台的完整性。链接到预编译库中的 C 函数本质上是不可靠的,因为 Java 运行时无法保证该函数的签名与 Java 代码的预期相符,甚至无法保证 C 库中的符号确实是一个函数。此外,即使链接了一个合适的函数,实际调用该函数也可能导致低级故障(例如段错误),从而导致虚拟机崩溃。这些故障 Java 运行时无法防止,Java 代码也无法捕获。

使用 JNI 函数的原生代码尤其危险。此类代码可以通过使用诸如 getStaticFieldcallVirtualMethod 等函数,无需命令行标志(例如 --add-opens)即可访问 JDK 内部。它还可以在 final 字段初始化后的很长时间更改其值。允许原生代码绕过应用于 Java 代码的检查,会破坏 JDK 中的每个边界和假设。换句话说,JNI 本质上是不安全的。

JNI 无法被禁用,因此无法确保 Java 代码不会调用使用危险 JNI 函数的原生代码。这对平台完整性构成了风险,而这一风险对应用程序开发者和最终用户来说几乎是不可见的,因为这些函数 99% 的使用通常来自夹在应用程序和 JDK 之间的第三方、第四方甚至第五方库。

大多数情况下,FFM API 的设计是安全的。过去需要使用 JNI 和原生代码的许多场景,现在可以通过调用 FFM API 中的方法来实现,而这些方法不会危及 Java 平台的安全性。例如,JNI 的一个主要用途——灵活的内存分配,现在可以通过一个简单的方法 MemorySegment::allocateNative 来支持,该方法不涉及任何原生代码,并且始终返回由 Java 运行时管理的内存。一般来说,使用 FFM API 的 Java 代码不会导致 JVM 崩溃。

然而,FFM API 的一部分本质上是不安全的。当与 CLinker 交互时,Java 代码可以通过指定与底层 C 函数不兼容的参数类型来请求一个 downcall 方法句柄。在 Java 中调用该 downcall 方法句柄将导致与在 JNI 中调用 native 方法时可能出现的结果相同 —— 即虚拟机崩溃或未定义行为。FFM API 还可以生成不安全的段,即空间和时间边界由用户提供且无法被 Java 运行时验证的内存段(参见 MemorySegment::ofAddressNative)。

FFM API 中的不安全方法并不像 JNI 函数那样存在相同的风险;例如,它们无法更改 Java 对象中 final 字段的值。另一方面,FFM API 中的不安全方法从 Java 代码中调用非常容易。出于这个原因,FFM API 中不安全方法的使用是 受限制的:默认情况下对不安全方法的访问是被禁用的,因此调用这些方法会抛出 IllegalAccessException 异常。要为某个模块 M 中的代码启用对不安全方法的访问,需要在命令行中指定 java --enable-native-access=M。(在逗号分隔的列表中指定多个模块;指定 ALL-UNNAMED 以启用类路径上所有代码的访问权限。)FFM API 的大多数方法是安全的,无论是否提供了 --enable-native-access,Java 代码都可以使用这些方法。

我们在此并不打算限制 JNI 的任何方面。在 Java 中仍然可以调用 native 方法,并且原生代码也可以调用不安全的 JNI 函数。然而,在未来的版本中,我们很可能会以某种方式限制 JNI。例如,像 newDirectByteBuffer 这样的不安全 JNI 函数可能会被默认禁用,就像 FFM API 中的不安全方法一样。更广泛地说,JNI 机制存在如此不可挽回的危险性,以至于我们希望库开发者能够优先选择纯 Java 的 FFM API 来执行安全和不安全的操作,从而让我们最终能够默认禁用整个 JNI。这与 Java 更广泛的路线图是一致的,即使平台开箱即用时更加安全,要求终端用户明确选择参与破坏强封装或链接到未知代码等不安全行为。

我们在这里并不打算对 sun.misc.Unsafe 做出任何更改。FFM API 对堆外内存的支持是围绕 sun.misc.Unsafe 中的 mallocfree 的封装的绝佳替代方案,即 allocateMemorysetMemorycopyMemoryfreeMemory。我们希望需要堆外存储的库和应用程序能够采用 FFM API,这样假以时日,我们可以弃用并最终移除这些 sun.misc.Unsafe 方法。

替代方案

继续使用 java.nio.ByteBuffersun.misc.Unsafe、JNI 和其他第三方框架。

风险与假设

创建一个既能安全又能高效访问外部内存的 API 是一项艰巨的任务。由于前几节所述的空间和时间检查需要在每次访问时执行,因此 JIT 编译器能够通过将这些检查优化掉(例如,将它们提升到热点循环之外)是至关重要的。JIT 实现可能需要一些工作,以确保 API 的使用与现有的 API(如 ByteBufferUnsafe)一样高效且可优化。JIT 实现还需要一些工作,以确保从 API 获取的本地方法句柄的使用至少与现有的 JNI 本地方法一样高效且可优化。

依赖

  • 外部函数和内存 API 可以以一种更通用、更高效的方式访问非易失性内存,这在之前通过 JEP 352(非易失性映射字节缓冲区) 已经可以实现。

  • 这里描述的工作可能会推动后续工作提供一个名为 jextract 的工具,该工具从给定的本地库头文件出发,机械地生成与该库互操作所需的本地方法句柄。这将大大减少从 Java 中使用本地库的开销。