跳到主要内容

JEP 412:外部函数和内存 API(孵化中)

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

总结

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

历史

本 JEP 提议的 API 是两个孵化中的 API 的演进:Foreign-Memory Access API 和 Foreign Linker API。Foreign-Memory Access API 最早由 JEP 370 提议,并于 2019 年底作为 孵化 API 被纳入 Java 14;随后在 Java 15 中通过 JEP 383 再次孵化,在 Java 16 中通过 JEP 393 再次孵化。Foreign Linker API 最早由 JEP 389 提议,并于 2020 年底作为 孵化 API 被纳入 Java 16。

目标

  • 易用性 — 使用更优越的纯 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 运行时之外的内存中的数据被称为*堆外(off-heap)*数据。(*堆(heap)是 Java 对象所在的地方——即堆内(on-heap)*数据——也是垃圾收集器工作的地方。)访问堆外数据对于诸如 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),
  • 操作和访问结构化外部内存
    MemoryLayoutMemoryHandlesMemoryAccess),
  • 管理外部资源的生命周期(ResourceScope),以及
  • 调用外部函数(SymbolLookupCLinker)。

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

示例

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

// 1. Find foreign function on the C library path
MethodHandle radixSort = CLinker.getInstance().downcallHandle(
CLinker.systemLookup().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,
CLinker.C_POINTER), ...);
// 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 = CLinker.toCString(javaStrings[i], newImplicitScope());
MemoryAccess.setAddressAtIndex(offHeap, i, cString.address());
}
// 5. Sort the off-heap data by calling the foreign function
radixSort.invoke(offHeap.address(), 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 = MemoryAccess.getAddressAtIndex(offHeap, i);
javaStrings[i] = CLinker.toJavaStringRestricted(cStringPtr);
}
assert Arrays.equals(javaStrings, new String[] {"car", "cat", "dog", "mouse"}); // true
java

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

内存段

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

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

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

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

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

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

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

解引用内存段

通过获取 变量句柄var handle),可以实现对与某段内存的解引用操作。Var handle 是 Java 9 中引入的一种数据访问抽象机制。具体来说,一个段是通过 内存访问变量句柄memory-access var handle)来解引用的。这种变量句柄使用一对访问坐标:

  • 类型为 MemorySegment 的坐标 —— 要解引用的内存段,以及
  • 类型为 long 的坐标 —— 从该段的基地址开始的偏移量,解引用在此处发生。

内存访问变量句柄是通过 MemoryHandles 类中的工厂方法获得的。例如,以下代码获取一个可以将 int 值写入本地内存段的内存访问变量句柄,并使用它在连续的偏移量处写入 25 个四字节的值:

MemorySegment segment = MemorySegment.allocateNative(100, newImplicitScope());
VarHandle intHandle = MemoryHandles.varHandle(int.class, ByteOrder.nativeOrder());
for (int i = 0; i < 25; i++) {
intHandle.set(segment, /* offset */ i * 4, /* value to write */ i);
}
java

通过使用 MemoryHandles 类提供的一个或多个组合方法,可以将内存访问变量句柄(var handles)进行组合,从而表达更高级的访问模式。利用这些方法,客户端可以实现例如重新排列给定内存访问变量句柄的坐标、删除一个或多个坐标,以及插入新坐标等操作。这使得创建能够接受多维数组的一个或多个逻辑索引的内存访问变量句柄成为可能,而这些多维数组由一块平坦的堆外内存区域支持。

为了使 FFM API 更易于使用,MemoryAccess 类提供了静态访问器,可以在不需要构造内存访问变量句柄的情况下解引用内存段。例如,有一个访问器可以在给定偏移量的段中设置 int 值,从而使上面的代码简化为:

MemorySegment segment = MemorySegment.allocateNative(100, newImplicitScope());
for (int i = 0; i < 25; i++) {
MemoryAccess.setIntAtOffset(segment, i * 4, i);
}
java

内存布局

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

SequenceLayout intArrayLayout
= MemoryLayout.sequenceLayout(25,
MemoryLayout.valueLayout(32, ByteOrder.nativeOrder()));
java

这将创建一个序列内存布局,其中 32 位的值布局(描述单个 32 位值的布局)重复了 25 次。有了内存布局,我们就可以避免在代码中计算偏移量,同时简化内存分配和内存访问变量句柄的创建:

MemorySegment segment = MemorySegment.allocateNative(intArrayLayout, newImplicitScope());
VarHandle indexedElementHandle =
intArrayLayout.varHandle(int.class, PathElement.sequenceElement());
for (int i = 0; i < intArrayLayout.elementCount().getAsLong(); i++) {
indexedElementHandle.set(segment, (long) i, i);
}
java

intArrayLayout 对象通过创建一个布局路径来驱动内存访问变量句柄的创建,该路径用于从复杂的布局表达式中选择嵌套布局。intArrayLayout 对象还驱动本地内存段的分配,这是基于从布局中获取的大小和对齐信息。前面示例中的循环常量 25 已被序列布局的元素计数所取代。

资源范围

前面示例中看到的所有内存段都使用非确定性释放:一旦内存段实例变得不可达,与这些内存段关联的内存就会由垃圾回收器释放。我们称这样的内存段为隐式释放

在某些情况下,客户端可能希望控制内存释放的时机。例如,假设使用 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 对象相关联,该对象负责在资源范围对象变得不可达之前(即客户端调用 close 方法之前),执行隐式的资源释放操作。

一些资源作用域(称为隐式资源作用域)不支持显式释放 —— 调用 close 将会失败。隐式资源作用域始终使用 Cleaner 来管理其资源。可以使用 ResourceScope::newImplicitScope 工厂方法创建隐式作用域,如前面的例子所示。

段分配器

当客户端使用堆外内存时,内存分配常常会成为瓶颈。FFM API 包含一个 SegmentAllocator 抽象,它定义了用于分配和初始化内存段的有用操作。段分配器通过 SegmentAllocator 接口中的工厂方法获取。例如,以下代码创建了一个基于竞技场的分配器,并使用它分配一个其内容从 Java int 数组初始化的段:

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

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

不安全的内存段

到目前为止,我们已经了解了内存段、内存地址和内存布局。解引用操作只能在内存段上进行。由于内存段具有空间和时间界限,Java 运行时始终可以确保与给定段相关的内存被安全地解引用。然而,在某些情况下,客户端可能只有 MemoryAddress 实例,这在与原生代码交互时经常发生。由于 Java 运行时无法知道与内存地址相关联的空间和时间界限,因此 FFM API 禁止直接解引用内存地址。

要解引用内存地址,客户端有两种选择。

  • 如果已知地址位于某个内存段内,客户端可以通过 MemoryAddress::segmentOffset 执行 重定位 操作。重定位操作会重新解释地址相对于段基址的偏移量,以生成一个新的偏移量,该偏移量可以应用于现有的段 —— 然后可以安全地解引用。

  • 或者,如果不存在这样的段,客户端可以使用 MemoryAddress::asSegment 工厂方法 不安全地 创建一个段。这个工厂方法实际上为原本原始的内存地址附加了新的空间和时间边界,以便允许解引用操作。此工厂返回的内存段是 不安全的:一个原始内存地址可能与一个 10 字节长的内存区域相关联,但客户端可能错误地高估了该区域的大小,并创建了一个 100 字节长的不安全内存段。这可能会导致后续尝试解引用超出与该不安全段关联的内存区域边界的内存,从而可能导致 JVM 崩溃,或者更糟的是导致悄无声息的内存损坏。因此,创建不安全段被视为一种 受限操作,默认情况下是禁用的(更多信息见下文)。

查找外部函数

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

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

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

  • SymbolLookup::loaderLookup 返回一个符号查找,该查找可以看到当前类加载器加载的所有库中的所有符号。
  • CLinker::systemLookup 返回一个特定于平台的符号查找,该查找可以看到标准 C 库中的符号。

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

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

将 Java 代码链接到外部函数

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

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

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

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

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

size_t strlen(const char *s);
c

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

MethodHandle strlen = CLinker.getInstance().downcallHandle(
CLinker.systemLookup().lookup("strlen").get(),
MethodType.methodType(long.class, MemoryAddress.class),
FunctionDescriptor.of(C_LONG, C_POINTER)
);
java

调用向下调用方法句柄将运行 strlen 并使其结果在 Java 中可用。对于 strlen 的参数,我们使用一个辅助方法将 Java 字符串转换为堆外内存段,并传递该段的地址:

MemorySegment str = CLinker.toCString("Hello", newImplicitScope());
long len = strlen.invokeExact(str.address()); // 5
java

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

在 Java 中描述 C 类型

要创建一个 downcall 方法句柄,FFM API 要求客户端提供目标 C 函数的双视角视图:一个是使用 不透明 的 Java 对象(MemoryAddressMemorySegment)的高级签名,另一个是使用 透明 的 Java 对象(MemoryLayout)的低级签名。依次处理每个签名:

  • 高级签名是一个 MethodType,用作下调方法句柄的类型。每个方法句柄都是强类型的,这意味着它对可以传递给其 invokeExact 方法的参数的数量和类型要求非常严格。例如,一个为接受一个 MemoryAddress 参数而创建的方法句柄不能通过 invokeExact(<MemoryAddress>, <MemoryAddress>)invokeExact("Hello") 调用。因此,MethodType 描述了客户端在调用下调方法句柄时必须使用的 Java 签名。实际上,这是 C 函数的 Java 视图。

  • 低级签名是一个 FunctionDescriptor,由 MemoryLayout 对象组成。这使 CLinker 能够精确理解 C 函数的参数,以便按照下面描述的方式正确地排列它们。客户端通常手头会有 MemoryLayout 对象,以便解引用外部内存中的数据,这些对象可以用作外部函数签名。

例如,获取一个接受 int 并返回 long 的 C 函数的 downcall 方法句柄,需要向 downcallHandle 提供以下 MethodTypeFunctionDescriptor 参数:

MethodType mtype         = MethodType.methodType(long.class, int.class);
FunctionDescriptor fdesc = FunctionDescriptor.of(C_LONG, C_INT);
java

(此示例针对 Linux/x64 和 macOS/x64,其中 Java 类型 longint 分别与预定义的 CLinker 布局 C_LONGC_INT 相关联。Java 类型与内存布局的关联因平台而异;例如,在 Windows/x64 上,Java 的 long 类型与 C_LONG_LONG 布局相关联。)

再比如,获取一个接受指针的 void C 函数的 downcall 方法句柄需要以下 MethodTypeFunctionDescriptor

MethodType mtype         = MethodType.methodType(void.class, MemoryAddress.class);
FunctionDescriptor fdesc = FunctionDescriptor.ofVoid(C_POINTER);
java

(C 语言中的所有指针类型在 Java 中都表示为 MemoryAddress 对象;其对应的布局(大小取决于当前平台)为 C_POINTER。客户端无需区分例如 int*char**,因为传递给 CLinker 的 Java 类型和内存布局共同包含了将 Java 参数正确传递给 C 函数的足够信息。)

最后,与 JNI 不同,CLinker 支持将结构化数据传递给外部函数。要获取一个接受结构体的 void C 函数的下调用方法句柄,需要以下 MethodTypeFunctionDescriptor

MethodType mtype         = MethodType.methodType(void.class, MemorySegment.class);
MemoryLayout SYSTEMTIME = MemoryLayout.ofStruct(
C_SHORT.withName("wYear"), C_SHORT.withName("wMonth"),
C_SHORT.withName("wDayOfWeek"), C_SHORT.withName("wDay"),
C_SHORT.withName("wHour"), C_SHORT.withName("wMinute"),
C_SHORT.withName("wSecond"), C_SHORT.withName("wMilliseconds")
);
FunctionDescriptor fdesc = FunctionDescriptor.ofVoid(SYSTEMTIME);
java

(对于高级的 MethodType 签名,Java 客户端在 C 函数期望按值传递结构体时,总是使用不透明类型 MemorySegment。对于低级的 FunctionDescriptor 签名,与 C 结构类型关联的内存布局必须是一个复合布局,该布局定义了 C 结构中所有字段的子布局,包括可能由原生编译器插入的填充。)

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

为 C 函数打包 Java 参数

不同语言之间的互操作需要一种 调用约定 来指定一种语言中的代码如何调用另一种语言中的函数、如何传递参数以及如何接收任何结果。CLinker 实现自带对多种调用约定的支持:Linux/x64、Linux/AArch64、macOS/x64 和 Windows/x64。由于它是用 Java 编写的,因此比 JNI 更容易维护和扩展,JNI 的调用约定是硬编码到 HotSpot 的 C++ 代码中的。

考虑上面显示的 SYSTEMTIME 结构体和布局的函数描述符。根据运行 JVM 的操作系统和 CPU 的调用约定,当使用 MemorySegment 参数调用 downcall 方法句柄时,CLinker 使用函数描述符来推断应该如何将结构体的字段传递给 C 函数。对于一种调用约定,CLinker 可以安排分解传入的内存段,使用通用 CPU 寄存器传递前四个字段,并将剩余的字段传递到 C 栈上。对于不同的调用约定,CLinker 可以安排 FFM API 通过分配一块内存区域间接传递该结构体,将传入内存段的内容批量复制到该区域中,并将指向该内存区域的指针传递给 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 方法句柄:

MethodHandle qsort = CLinker.getInstance().downcallHandle(
CLinker.systemLookup().lookup("qsort").get(),
MethodType.methodType(void.class, MemoryAddress.class, long.class,
long.class, MemoryAddress.class),
FunctionDescriptor.ofVoid(C_POINTER, C_LONG, C_LONG, C_POINTER)
);
java

和之前一样,我们使用 C_LONGlong.class 来映射 C 语言中的 size_t 类型,并且在第一个指针参数(数组指针)和最后一个参数(函数指针)中都使用了 MemoryAddess.class

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

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

class Qsort {
static int qsortCompare(MemoryAddress addr1, MemoryAddress addr2) {
return MemoryAccess.getIntAtOffset(MemorySegment.globalNativeSegment(),
addr1.toRawLongValue()) -
MemoryAccess.getIntAtOffset(MemorySegment.globalNativeSegment(),
addr2.toRawLongValue());
}
}
java

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

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

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

MemoryAddress comparFunc =
CLinker.getInstance().upcallStub(comparHandle,
FunctionDescriptor.of(C_INT,
C_POINTER,
C_POINTER),
newImplicitScope());
);
java

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

MemorySegment array = MemorySegment.allocateNative(4 * 10, newImplicitScope());
array.copyFrom(MemorySegment.ofArray(new int[] { 0, 9, 3, 4, 6, 5, 1, 8, 2, 7 }));
qsort.invokeExact(array.address(), 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 运行时验证的内存段(参见 MemoryAddress::asSegment)。

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 中使用原生库的开销。