Skip to content
微信扫码关注公众号

JEP 419: Foreign Function & Memory API (Second Incubator) | 外部函数与内存 API(第二个孵化器)

摘要

介绍一个 API,通过该 API,Java 程序可以与 Java 运行时环境之外的代码和数据进行互操作。通过高效调用外部函数(即 JVM 之外的代码)并安全访问外部内存(即不由 JVM 管理的内存),该 API 使 Java 程序能够调用本地库并处理本地数据,同时避免了 JNI 的脆弱性和危险性。

历史

Foreign Function & Memory API 由 JEP 412 提出,并计划在 2021 年中期的 Java 17 中作为孵化 API 推出。它结合了之前的两个孵化 API:Foreign-Memory Access API 和 Foreign Linker API。此 JEP 提议根据反馈进行改进,并在 Java 18 中重新孵化该 API。本次更新包括以下更改:

  • 在内存访问变量句柄中支持更多载体,如 booleanMemoryAddress
  • MemorySegmentMemoryAddress 接口中提供更通用的解引用 API;
  • 简化获取下调方法句柄的 API,不再需要传递 MethodType 参数;
  • 简化管理资源作用域之间临时依赖的 API;
  • 提供新的 API 以将 Java 数组复制到内存段中,或从内存段中复制出来。

目标

  • 易用性 —— 用一个更高级、纯 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 允许创建分配在堆外的 直接 字节缓冲区,但其最大大小仅为两吉字节,并且不会及时释放。这些及其他限制源于 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 只能与用操作系统和 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 的繁琐粘合和笨拙。一个优秀的抽象构建基础是 方法句柄,它在 Java 7 中被引入以支持 JVM 上的快速动态语言。通过方法句柄暴露本地代码将极大地简化编写、构建和分发依赖于本地库的 Java 库的任务。此外,一个能够模拟外部函数(即本地代码)和外部内存(即堆外数据)的 API 将为第三方本地互操作框架提供坚实的基础。

描述

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

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

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

示例

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

java
// 1. 在 C 库路径上查找外部函数
CLinker linker = CLinker.getInstance();
MethodHandle radixSort = linker.downcallHandle(
                             linker.lookup("radixsort"), ...);
// 2. 分配堆上内存以存储四个字符串
String[] javaStrings   = { "mouse", "cat", "dog", "car" };
// 3. 分配堆外内存以存储四个指针
MemorySegment offHeap  = MemorySegment.allocateNative(
                             MemoryLayout.ofSequence(javaStrings.length,
                                                     ValueLayout.ADDRESS), ...);
// 4. 将字符串从堆上复制到堆外
for (int i = 0; i < javaStrings.length; i++) {
    // 在堆外分配一个字符串,然后存储指向它的指针
    MemorySegment cString = implicitAllocator().allocateUtf8String(javaStrings[i]);
    offHeap.setAtIndex(ValueLayout.ADDRESS, i, cString);
}
// 5. 通过调用外部函数对堆外数据进行排序
radixSort.invoke(offHeap, javaStrings.length, MemoryAddress.NULL, '\0');
// 6. 将(重新排序的)字符串从堆外复制到堆上
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

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

内存段

内存段 是一种抽象,用于模拟位于堆外或堆上的连续内存区域。内存段可以是:

  • 原生 段,在原生内存中从头开始分配(例如,通过 malloc),
  • 映射 段,围绕映射的原生内存区域进行包装(例如,通过 mmap),或者
  • 数组缓冲区 段,分别围绕与现有 Java 数组或字节缓冲区相关联的内存进行包装。

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

java
MemorySegment segment = MemorySegment.allocateNative(100,
                                                     newImplicitScope());

空间界限 确定与段相关联的内存地址范围。上述代码中段的界限由一个基地址 b(表示为 MemoryAddress 实例)和字节大小(100)定义,从而产生从 bb + 99(含)的地址范围。

时间界限 确定段的生命周期,即段何时将被释放。段的生命周期和线程隔离状态由 ResourceScope 抽象表示,将在 下文 中讨论。上述代码中的资源范围是一个新的 共享 范围,它确保当 MemorySegment 对象被垃圾收集器判定为不可达时,与该段相关联的内存将被释放。共享范围还确保内存段可以从多个线程访问。

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

解引用段

要从内存段中解引用一些数据,我们需要考虑几个因素:

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

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

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

java
MemorySegment segment = MemorySegment.allocateNative(100,
                                                     newImplicitScope());
for (int i = 0; i < 25; i++) {
    segment.setAtIndex(ValueLayout.JAVA_INT,
                       /* 索引 */ i,
                       /* 要写入的值 */ i);
}

内存布局和结构化访问

考虑以下 C 声明,它定义了一个 Point 结构体的数组,其中每个 Point 结构体都有两个成员,即 Point.xPoint.y

java
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,
                       /* 索引 */ (i * 2),
                       /* 要写入的值 */ i); // x
    segment.setAtIndex(ValueLayout.JAVA_INT,
                       /* 索引 */ (i * 2) + 1,
                       /* 要写入的值 */ i); // y
}

为了减少关于内存布局繁琐计算的需要(例如,上例中的 (i * 2) + 1),可以使用 MemoryLayout 以更声明性的方式描述内存段的内容。例如,上述示例中所需的本机内存段布局可以以下述方式描述:

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

这创建了一个 序列内存布局,其中包含十次重复的 结构体布局,其元素分别是名为 xy 的两个 JAVA_INT 布局。给定此布局,我们可以通过创建 内存访问变量句柄(一种特殊的 变量句柄,它接受一个 MemorySegment 参数(要解引用的段)后跟一个或多个 long 坐标(解引用操作应发生的索引))来避免在代码中计算偏移量:

java
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,
                /* 索引 */ (long) i,
                /* 要写入的值 */ i); // x
    yHandle.set(segment,
                /* 索引 */ (long) i,
                /* 要写入的值 */ i); // y
}

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

资源作用域

上述所有示例都使用非确定性释放:与分配的内存段相关联的内存,在内存段实例变得不可达之后,由垃圾收集器进行释放。我们说这样的段是 隐式释放的

有些情况下,客户端可能希望控制内存释放的时机。例如,假设使用 MemorySegment::map 从文件中映射了一个大内存段。客户端可能更倾向于在不再需要该段时立即释放(即取消映射)与该段相关联的内存,而不是等待垃圾收集器来执行此操作,因为等待可能会对应用程序的性能产生不利影响。

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

java
try (ResourceScope scope = ResourceScope.newConfinedScope()) {
    MemorySegment s1 = MemorySegment.map(Path.of("someFile"),
                                         0, 100000,
                                         MapMode.READ_WRITE, scope);
    MemorySegment s2 = MemorySegment.allocateNative(100, scope);
    ...
} // 两个段在这里被释放

此代码创建了一个资源作用域,并使用它来创建两个段:一个映射段 (s1) 和一个原生段 (s2)。这两个段的生命周期与资源作用域的生命周期相关联,因此在 try-with-resources 语句外部访问这些段(例如,使用内存访问变量句柄取消引用它们)将导致抛出运行时异常。

除了管理内存段的生命周期外,资源作用域还控制哪些线程可以访问该段。一个 受限 的资源作用域将访问限制在创建该作用域的线程上,而一个 共享 的资源作用域则允许从任何线程进行访问。

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

段分配器

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

java
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 });
        ...
    }
    ...
} // 此处释放所有分配的内存

此代码创建了一个受限的资源作用域,然后创建了一个与该作用域关联的无界区域分配器。该分配器分配一段内存,并通过返回该预分配段的切片来响应分配请求。如果当前段没有足够的空间来满足分配请求,则会分配一个新的段。当与区域分配器相关联的资源作用域关闭时,该分配器创建的所有段(即在 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 相同的可预测方式被卸载。

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

  • 通过调用 SymbolLookup::loaderLookup,它返回一个符号查找器,该查找器定位当前类加载器加载的所有库中的所有符号;
  • 或者,通过获取 CLinker 实例,它实现了 SymbolLookup 接口,可用于在标准 C 库中查找特定于平台的符号。

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

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

将 Java 代码链接到外部函数

CLinker 接口是 Java 代码与本地代码互操作的核心。虽然 CLinker 主要关注 Java 与 C 库之间的互操作,但接口中的概念足够通用,未来可以支持其他非 Java 语言。该接口支持 下行调用(从 Java 代码调用本地代码)和 上行调用(从本地代码回调 Java 代码)。

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

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

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

假设我们希望从 Java 调用标准 C 库中定义的 strlen 函数:

c
size_t strlen(const char *s);

可以按以下方式获取一个暴露 strlen 的下行调用方法句柄(FunctionDescriptor 的详细信息稍后将介绍):

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

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

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

方法句柄非常适合用于暴露外部函数,因为 JVM 已经对方法句柄的调用进行了优化,直至本地代码。当方法句柄引用 class 文件中的方法时,调用方法句柄通常会导致目标方法被即时编译(JIT-compiled);随后,JVM 通过将对 MethodHandle::invokeExact 的 Java 字节码调用的控制权转移到为目标方法生成的汇编代码来执行它。因此,Java 中的传统方法句柄在幕后针对非 Java 代码;下行调用方法句柄是一个自然扩展,允许开发人员明确针对非 Java 代码。方法句柄还具有称为 签名多态性 的特性,允许使用原始参数进行无装箱调用。总之,方法句柄使 CLinker 能够以自然、高效和可扩展的方式暴露外部函数。

在 Java 中描述 C 类型

为了创建一个下行调用方法句柄,FFM(Foreign Function and Memory)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 视图。

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

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

再举一个例子,假设一个下行调用方法句柄应该公开一个接受指针的 void C 函数。在所有平台上,C 指针类型都与预定义布局 ADDRESS 相关联,因此所需的 FunctionDescriptor 可以通过 FunctionDescriptor.ofVoid(ADDRESS) 获得。CLinker 随后将安排下行调用方法句柄的类型为 Java 签名 AddressablevoidAddressable 是 FFM API 中可以通过引用传递的实体的常见超类型,如 MemorySegmentMemoryAddressNativeSymbol

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

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

java
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")
);

所需的 FunctionDescriptor 可以通过 FunctionDescriptor.ofVoid(SYSTEMTIME) 获得。CLinker 将安排下行调用方法句柄的类型为 Java 签名 MemorySegmentvoid

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

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

如前所述,CLinker 专注于提供 Java 和 C 库之间的互操作性,但它是语言中立的:它不了解 C 类型是如何定义的,因此客户端负责为 C 类型获取合适的布局定义。这一选择是有意为之的,因为 C 类型的布局定义(无论是简单的标量还是复杂的结构体)最终都依赖于平台,因此可以由对给定目标平台有深入了解的工具机械地生成。

为 C 函数打包 Java 参数

调用约定(Calling Convention)通过指定一种语言中的代码如何调用另一种语言中的函数、传递参数以及接收结果,来实现不同语言之间的互操作性。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 库中定义的以下函数:

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

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

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

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

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

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

java
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 比较器的方法句柄,我们可以使用 CLinker::upcallStub 来创建函数指针。与 downcall 一样,我们使用 FunctionDescriptor 来描述函数指针的签名:

java
NativeSymbol comparFunc =
  linker.upcallStub(comparHandle,
                    /* 一个用 Java 描述的 C 函数
                       由 Java 方法实现!*/
                    FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS),
                    newImplicitScope());
);

最后,我们得到了一个内存地址 comparFunc,它指向一个存根(stub),该存根可用于调用我们的 Java 比较器函数。因此,我们现在拥有了调用 qsort downcall 句柄所需的一切:

java
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 数组的内容复制到其中,然后将该数组与从 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 的一个主要用途是灵活的内存分配,FFM API 通过一个简单的方法 MemorySegment::allocateNative 支持这一用途,该方法不涉及本地代码,并且始终返回由 Java 运行时环境管理的内存。一般来说,使用 FFM API 的 Java 代码不会导致 JVM 崩溃。

然而,FFM API 的一部分本质上是不安全的。在与 CLinker 交互时,Java 代码可以通过指定与底层 C 函数参数类型不兼容的参数类型来请求回调方法句柄。在 Java 中调用回调方法句柄将导致与在 JNI 中调用 native 方法时相同的结果——VM 崩溃或未定义行为。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 的大多数方法都是安全的,Java 代码可以在不考虑是否给出 --enable-native-access 的情况下使用这些方法。

我们在这里不提出对 JNI 的任何方面进行限制。Java 中仍然可以调用 native 方法,本地代码也可以调用不安全的 JNI 函数。但是,在未来的版本中,我们可能会以某种方式限制 JNI。例如,与 FFM API 中的不安全方法一样,不安全的 JNI 函数(如 newDirectByteBuffer)可能会默认被禁用。更广泛地说,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 使用本地库的开销。