Skip to content

JEP 442: Foreign Function & Memory API (Third Preview) | 外部函数与内存 API(第三次预览)

摘要

引入一个 API,使 Java 程序能够与 Java 运行时外部的代码和数据进行交互。通过高效地调用外部函数(即 JVM 外部的代码)以及安全地访问外部内存(即不受 JVM 管理的内存),该 API 使 Java 程序能够调用本地库并处理本地数据,而不会有 JNI 的脆弱性和危险性。这是一个 预览 API

历史

外部函数和内存(FFM)API 首先在 JDK 19 中通过 JEP 424 进行预览,然后在 JDK 20 中通过 JEP 434 再次进行预览。这个 JEP 提出了第三次预览,以根据反馈进行改进。在这个版本中,我们有:

  • Arena 接口中集中管理本地段的生命周期;
  • 用一个新元素增强布局路径以解引用地址布局;
  • 提供一个链接器选项以优化对生命周期短且不会回调到 Java 的函数(例如 clock_gettime)的调用;
  • 提供一个基于 libffi 的备用本地链接器实现,以方便移植;以及
  • 删除了 VaList 类。

目标

  • 易用性——用一个更好的纯 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 运行时外部的代码和数据。

外部内存

new 关键字创建的对象存储在 JVM 的 中,当不再需要时,它们会被垃圾回收。然而,对于性能关键的库,如 TensorflowIgniteLuceneNetty,垃圾回收的成本和不可预测性是不可接受的。它们需要将数据存储在堆外,在 堆外内存 中,它们自己分配和释放这些内存。对堆外内存的访问还允许通过例如 mmap 将文件直接映射到内存中来序列化和反序列化数据。

Java 平台历史上提供了两个 API 用于访问堆外内存:

  • ByteBuffer API 提供 直接 字节缓冲区,它们是由固定大小的堆外内存区域支持的 Java 对象。然而,一个区域的最大大小限制为两吉字节,并且读写内存的方法是基本的且容易出错,提供的仅仅是对基本值的索引访问。更严重的是,支持直接字节缓冲区的内存仅在缓冲区对象被垃圾回收时才会被释放,而这是开发人员无法控制的。缺乏对及时释放的支持使得 ByteBuffer API 不适合 Java 中的系统编程。

  • sun.misc.Unsafe API 提供对堆内内存的低级访问,也适用于堆外内存。使用 Unsafe 很快(因为它的内存访问操作被 JVM 内联),允许巨大的堆外区域(理论上高达 16 EB),并且对释放提供精细的控制(因为 Unsafe::freeMemory 可以在任何时候被调用)。然而,这个编程模型很弱,因为它给了开发人员太多的控制。一个长期运行的服务器应用程序中的库将随着时间的推移分配并与多个堆外内存区域交互;一个区域中的数据将指向另一个区域中的数据,并且区域必须以正确的顺序释放,否则悬空指针将导致释放后使用的错误。缺乏对安全释放的支持使得 Unsafe API 不适合 Java 中的系统编程。

(同样的批评也适用于 JDK 外部的 API,这些 API 通过包装调用 mallocfree 的本地代码来提供细粒度的分配和释放。)

总之,复杂的客户端应该有一个 API,能够以与堆内内存相同的流畅性和安全性分配、操作和共享堆外内存。这样的 API 应该在对可预测释放的需求与防止可能导致 JVM 崩溃或更糟糕的是无声内存损坏的不及时释放的需求之间取得平衡。

外部函数

自 Java 1.1 以来,JNI 就支持调用本地代码(即外部函数),但由于许多原因,它是不够的。

  • JNI 涉及几个繁琐的工件:一个 Java API(native 方法)、一个从 Java API 派生的 C 头文件以及一个调用感兴趣的本地库的 C 实现。Java 开发人员必须跨多个工具链工作,以保持依赖于平台的工件同步,当本地库快速发展时,这尤其繁重。

  • JNI 只能与通常用 C 和 C++ 编写的、使用构建 JVM 的操作系统和 CPU 的调用约定的库进行交互。一个 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)定义了类和接口,以便库和应用程序中的客户端代码可以:

FFM API 位于 java.base 模块的 java.lang.foreign 包中。

示例

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

因为 FFM API 是一个 预览 API,你必须在启用预览功能的情况下编译和运行代码,即 javac --release 21 --enable-preview...java --enable-preview...

java
// 1. 在 C 库路径上找到外部函数
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
MethodHandle radixsort = linker.downcallHandle(stdlib.find("radixsort"),...);
// 2. 分配堆上内存来存储四个字符串
String[] javaStrings = { "mouse", "cat", "dog", "car" };
// 3. 使用 try-with-resources 来管理堆外内存的生命周期
try (Arena offHeap = Arena.ofConfined()) {
    // 4. 分配一个堆外内存区域来存储四个指针
    MemorySegment pointers
        = offHeap.allocateArray(ValueLayout.ADDRESS, javaStrings.length);
    // 5. 将字符串从堆上复制到堆外
    for (int i = 0; i < javaStrings.length; i++) {
        MemorySegment cString = offHeap.allocateUtf8String(javaStrings[i]);
        pointers.setAtIndex(ValueLayout.ADDRESS, i, cString);
    }
    // 6. 通过调用外部函数对堆外数据进行排序
    radixsort.invoke(pointers, javaStrings.length, MemorySegment.NULL, '\0');
    // 7. 将(重新排序的)字符串从堆外复制到堆上
    for (int i = 0; i < javaStrings.length; i++) {
        MemorySegment cString = pointers.getAtIndex(ValueLayout.ADDRESS, i);
        javaStrings[i] = cString.getUtf8String(0);
    }
} // 8. 所有堆外内存在这里被释放
assert Arrays.equals(javaStrings,
                     new String[] {"car", "cat", "dog", "mouse"});  // true

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

内存段和区域

一个 内存段 是由一块连续的内存区域支持的抽象概念,位于堆外或堆上。一个内存段可以是:

  • 一个 原生 段,在堆外内存中从头开始分配(就像通过 malloc 分配一样),
  • 一个 映射 段,围绕一个映射的堆外内存区域包裹(就像通过 mmap 分配一样),或者
  • 一个 数组缓冲区 段,分别围绕与现有 Java 数组或字节缓冲区相关联的堆上内存区域包裹。

所有内存段都提供空间和时间边界,以确保内存访问操作是安全的。简而言之,这些边界保证不使用未分配的内存,也不在释放后使用。

一个段的 空间边界 决定了与该段相关联的内存地址范围。例如,下面的代码分配一个 100 字节的原生段,所以相关联的地址范围是从某个 基地址bb + 99(包括 b + 99)。

java
MemorySegment data = Arena.global().allocate(100);

一个段的 时间边界 决定了它的生命周期,即直到支持该段的内存区域被释放的时间段。FFM API 保证在一个内存段的支持内存区域被释放后,不能再访问该段。

一个段的时间边界是由用于分配该段的 区域 决定的。在同一个区域中分配的多个段具有相同的时间边界,并且可以安全地包含相互引用:段 A 可以持有指向段 B 中的一个地址的指针,段 B 可以持有指向段 A 中的一个地址的指针,并且两个段将同时被释放,这样两个段都不会有悬空指针。

最简单的区域是 全局 区域,它提供一个无界的生命周期:它总是活着的。像上面的代码那样在全局区域中分配的段总是可访问的,并且支持该段的内存区域永远不会被释放。

然而,大多数程序需要在程序运行时释放堆外内存,因此需要具有有界生命周期的内存段。

一个 自动 区域提供一个有界的生命周期:由一个自动区域分配的段可以被访问,直到 JVM 的垃圾收集器检测到该内存段不可达,此时支持该段的内存区域将被释放。例如,这个方法在一个自动区域中分配一个段:

java
void processData() {
    MemorySegment data = Arena.ofAuto().allocateNative(100);
   ... 使用 'data' 变量...
   ... 再次使用 'data' 变量...
}  // 支持 'data' 段的内存区域在这里(或稍后)被释放

只要 data 变量不泄漏出这个方法,该段最终将被检测为不可达,并且其支持区域将被释放。

一个自动区域的有界但非确定性的生命周期并不总是足够的。例如,一个从文件映射内存段的 API 应该允许客户端确定性地释放支持该段的内存区域,因为等待垃圾收集器这样做可能会对性能产生不利影响。

一个 受限 区域提供一个有界且确定性的生命周期:它从客户端打开区域的时间开始,到客户端 关闭 区域的时间结束。在一个受限区域中分配的内存段只能在区域关闭之前被访问,此时支持该段的内存区域将被释放。在区域关闭后尝试访问一个内存段将抛出异常。例如,这段代码打开一个区域并使用该区域分配两个段:

java
MemorySegment input = null, output = null;
try (Arena processing = Arena.ofConfined()) {
    input = processing.allocate(100);
   ... 在 'input' 中设置数据...
    output = processing.allocate(100);
   ... 将数据从 'input' 处理到 'output'...
   ... 从 'output' 计算最终结果并存储在其他地方...
}  // 支持这些段的内存区域在这里被释放
...
input.get(ValueLayout.JAVA_BYTE, 0);  // 抛出 IllegalStateException
                                      //(对于 'output' 也是如此)

退出 try-with-resources 块会关闭区域,此时由该区域分配的所有段都会被原子性地无效化,并且支持这些段的内存区域将被释放。

一个受限区域的确定性生命周期是有代价的:只有一个线程可以访问在一个受限区域中分配的内存段。如果多个线程需要访问一个段,那么可以使用一个 共享 区域。在一个共享区域中分配的内存段可以被多个线程访问,并且任何线程——无论它是否访问该区域——都可以关闭区域以释放这些段。关闭区域会原子性地使这些段无效,尽管支持这些段的内存区域的释放可能不会立即发生,因为需要一个昂贵的同步操作来检测并取消对这些段的未决并发访问操作。

总之,一个区域控制哪些线程可以访问一个内存段以及何时访问,以提供强大的时间安全性和可预测的性能模型。FFM API 提供了多种区域选择,以便客户端可以在访问范围和释放及时性之间进行权衡。

解引用段

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

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

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

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

java
MemorySegment segment
    = Arena.ofAuto().allocate(100,                                 // 大小
                              ValueLayout.JAVA_INT.byteAlignment); // 对齐
for (int i = 0; i < 25; i++) {
    segment.setAtIndex(ValueLayout.JAVA_INT,
                       /* 索引 */ i,
                       /* 要写入的值 */ i);
}

内存布局和结构化访问

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

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

使用上一节中展示的解引用方法,要初始化这样一个原生数组,我们将不得不编写以下代码(我们假设 sizeof(int) == 4):

java
MemorySegment segment
    = Arena.ofAuto().allocate(2 * ValueLayout.JAVA_INT.byteSize() * 10, // 大小
                              ValueLayout.JAVA_INT.byteAlignment);      // 对齐
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")));

这创建了一个 序列内存布局,其中包含十个重复的 结构体布局,其元素分别是两个名为 xyJAVA_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 = Arena.ofAuto().allocate(ptsLayout);
for (int i = 0; i < ptsLayout.elementCount(); 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 元素。

段分配器

当客户端使用堆外内存时,内存分配通常是一个瓶颈。因此,FFM API 包括一个 SegmentAllocator 抽象来定义分配和初始化内存段的操作。为了方便起见,Arena 类实现了 SegmentAllocator 接口,这样区域就可以用于分配原生段。换句话说,Arena 是一个用于灵活分配和及时释放堆外内存的“一站式商店”:

java
try (Arena offHeap = Arena.ofConfined()) {
    MemorySegment nativeArray  = offHeap.allocateArray(ValueLayout.JAVA_INT,
                                                       0, 1, 2, 3, 4, 5, 6, 7, 8, 9);
    MemorySegment nativeString = offHeap.allocateUtf8String("Hello!");
    MemorySegment upcallStub   = linker.upcallStub(handle, desc, offHeap);
  ...
} // 内存在这里释放

段分配器也可以通过 SegmentAllocator 接口中的工厂方法获得。例如,一个工厂创建一个 切片分配器,它通过返回作为先前分配的段的一部分的内存段来响应分配请求;因此,许多请求可以在不实际分配更多内存的情况下得到满足。下面的代码在一个现有段上获得一个切片分配器,然后使用它来分配一个从 Java 数组初始化的段:

java
MemorySegment segment =...
SegmentAllocator allocator = SegmentAllocator.slicingAllocator(segment);
for (int i = 0 ; i < 10 ; i++) {
    MemorySegment s = allocator.allocateArray(JAVA_INT,
                                              1, 2, 3, 4, 5);
   ...
}

段分配器可以用作构建块来创建支持自定义分配策略的区域。例如,如果大量的原生段将共享相同的有界生命周期,那么一个自定义区域可以使用一个切片分配器来有效地分配这些段。这让客户端既享受可扩展的分配(由于切片)又享受确定性的释放(由于区域)。

作为一个例子,下面的代码定义了一个 切片区域,它的行为类似于一个受限区域,但在内部使用一个切片分配器来响应分配请求。当切片区域关闭时,底层的受限区域也被关闭,使在切片区域中分配的所有段无效。(一些细节被省略。)

java
class SlicingArena implements Arena {
     final Arena arena = Arena.ofConfined();
     final SegmentAllocator slicingAllocator;

     SlicingArena(long size) {
         slicingAllocator = SegmentAllocator.slicingAllocator(arena.allocate(size));
     }

     public void allocate(long byteSize, long byteAlignment) {
         return slicingAllocator.allocate(byteSize, byteAlignment);
    }

     public void close() {
         return arena.close();
     }
}

前面直接使用切片分配器的代码现在可以更简洁地编写:

java
try (Arena slicingArena = new SlicingArena(1000)) {
     for (int i = 0 ; i < 10 ; i++) {
         MemorySegment s = slicingArena.allocateArray(JAVA_INT,
                                                      1, 2, 3, 4, 5);
        ...
     }
} // 所有分配的内存在这里释放

查找外部函数

对外部函数的任何支持的第一个要素是一种在加载的本地库中找到给定符号的地址的机制。这个功能由一个 SymbolLookup 对象表示,对于将 Java 代码链接到外部函数至关重要(见 下面)。FFM API 支持三种不同类型的符号查找对象:

  • SymbolLookup::libraryLookup(String, Arena) 创建一个 库查找,它定位用户指定的本地库中的所有符号。创建查找对象会导致库被加载(例如,使用 dlopen())并与一个 Arena 对象相关联。当提供的区域被关闭时,库被卸载(例如,使用 dlclose())。

  • SymbolLookup::loaderLookup() 创建一个 加载器查找,它定位当前类加载器中通过 System::loadLibrarySystem::load 方法加载的所有本地库中的所有符号。

  • Linker::defaultLookup() 创建一个 默认查找,它定位与 Linker 实例相关联的操作系统和处理器组合上常用的库中的所有符号。

给定一个符号查找,客户端可以使用 SymbolLookup::find(String) 方法找到一个外部函数。如果命名的函数存在于符号查找看到的符号中,那么该方法返回一个零长度的内存段(见 下面),其基地址指向函数的入口点。例如,下面的代码使用一个加载器查找来加载 OpenGL 库并找到其 glGetString 函数的地址:

java
try (Arena arena = Arena.ofConfined()) {
    SymbolLookup opengl = SymbolLookup.libraryLookup("libGL.so", arena);
    MemorySegment glVersion = opengl.find("glGetString").get();
   ...
} // libGL.so 在这里被卸载

SymbolLookup::libraryLookup(String, Arena) 与 JNI 的库加载机制(即 System::loadLibrary)在一个重要方面不同。设计用于与 JNI 一起工作的本地库可以使用 JNI 函数来执行 Java 操作,例如对象分配或方法访问,这可能会触发 类加载。因此,这样的与 JNI 相关的库在被 JVM 加载时必须与一个类加载器相关联。然后,为了保持 类加载器完整性,同一个与 JNI 相关的库不能从不同类加载器中定义的类加载。

相比之下,FFM API 不提供让本地代码访问 Java 环境的函数,并且不假设本地库是为与 FFM API 一起工作而设计的。通过 SymbolLookup::libraryLookup(String, Arena) 加载的本地库不知道它们正在被在 JVM 中运行的代码访问,并且不会尝试执行 Java 操作。因此,它们不与特定的类加载器绑定,可以根据 FFM API 客户端在不同加载器中的需要被多次(重新)加载。

将 Java 代码链接到外部函数

Linker 接口是 Java 代码与外部代码交互的核心。虽然在本文档中我们经常提到 Java 和 C 库之间的交互,但这个接口中的概念足够通用,可以在未来支持其他非 Java 语言。Linker 接口支持 下调用(从 Java 代码到本地代码的调用)和 上调用(从本地代码回到 Java 代码的调用)。

java
interface Linker {
    MethodHandle downcallHandle(MemorySegment address,
                                FunctionDescriptor function);
    MemorySegment upcallStub(MethodHandle target,
                          FunctionDescriptor function,
                          Arena arena);
}

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

对于上调用,upcallStub 方法接受一个方法句柄——通常是一个引用 Java 方法而不是下调用方法句柄的方法句柄——并将其转换为一个 MemorySegment 实例。稍后,当 Java 代码调用下调用方法句柄时,这个内存段作为参数传递。实际上,这个内存段充当一个函数指针。(关于上调用的更多信息,见 下面。)

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

c
size_t strlen(const char *s);

客户端可以使用 原生链接器(见 Linker::nativeLinker)来链接 C 函数,这是一个符合 JVM 正在运行的操作系统和 CPU 确定的 ABI 的 Linker 实现。可以如下获得一个暴露 strlen 的下调用方法句柄(FunctionDescriptor 的细节将在稍后描述):

java
Linker linker = Linker.nativeLinker();
MethodHandle strlen = linker.downcallHandle(
    linker.defaultLookup().find("strlen").get(),
    FunctionDescriptor.of(JAVA_LONG, ADDRESS)
);

调用下调用方法句柄将运行 strlen 并使它的结果在 Java 中可用。对于 strlen 的参数,我们使用一个辅助方法将一个 Java 字符串转换为一个堆外内存段,使用一个受限区域,然后通过引用传递:

java
try (Arena arena = Arena.ofConfined()) {
    MemorySegment str = arena.allocateUtf8String("Hello");
    long len          = (long) strlen.invoke(str);  // 5
}

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

在 Java 中描述 C 类型

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

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

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

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

再举一个例子,假设一个下调用方法句柄应该暴露一个接受一个指针的 void C 函数。在所有平台上,一个 C 指针类型与预定义布局 ADDRESS 相关联,所以所需的 FunctionDescriptor 可以通过 FunctionDescriptor.ofVoid(ADDRESS) 获得。然后原生链接器将安排下调用方法句柄的类型为 Java 签名 MemorySegmentvoid。也就是说,一个 MemorySegment 参数可以通过引用或值传递,这取决于相应函数描述符中指定的布局。

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

最后,与 JNI 不同,原生链接器支持将结构化数据传递给外部函数。假设一个下调用方法句柄应该暴露一个接受一个结构体的 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) 获得。Linker 将安排下调用方法句柄的类型为 Java 签名 MemorySegmentvoid

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

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

如前所述,虽然原生链接器实现专注于提供 Java 和 C 库之间的交互,但 Linker 接口是语言中立的:它没有关于 C 类型如何定义的特定知识,所以客户端负责为 C 类型获得合适的布局定义。这个选择是有意的,因为 C 类型的布局定义——无论是简单的标量还是复杂的结构体——最终都是依赖于平台的,因此可以由一个对给定目标平台有深入了解的工具机械地生成。

为 C 函数打包 Java 参数

一个 调用约定 通过指定一种语言中的代码如何调用另一种语言中的函数、传递参数和接收结果来实现不同语言之间的交互。Linker API 在调用约定方面是中立的,但原生链接器实现直接支持几种调用约定:Linux/x64、Linux/AArch64、Linux/RISC-V、macOS/x64、macOS/AArch64、Windows/x64 和 Windows/AArch64。其他平台 通过 回退链接器 支持,这是一个基于 libffi 的原生链接器实现。由于是用 Java 编写的,Linker API 比 JNI 更容易维护和扩展,JNI 的调用约定硬编码在 HotSpot 的 C++ 代码中。

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

零长度内存段

外部函数经常分配一个内存区域并返回一个指向该区域的指针。用一个内存段来建模这样一个区域是有挑战性的,因为 Java 运行时不知道该区域的大小。例如,一个返回类型为 char* 的 C 函数可能返回一个指向包含一个单一 char 值的区域的指针,或者指向一个以 '\0' 结尾的 char 值序列的区域的指针。该区域的大小对于调用外部函数的代码来说不是很明显。

FFM API 将从外部函数返回的指针表示为一个 零长度内存段。该段的地址是指针的值,段的大小是零。类似地,当客户端从一个内存段读取一个指针时,会返回一个零长度内存段。

一个零长度段具有微不足道的空间边界,所以任何尝试访问这样一个段都会抛出 IndexOutOfBoundsException。这是一个关键的安全特性:由于这些段与一个大小未知的内存区域相关联,涉及这些段的访问操作不能被验证。实际上,一个零长度内存段包裹一个地址,并且在没有明确意图的情况下不能使用。

客户端可以通过方法 MemorySegment::reinterpret 将一个零长度内存段转换为他们选择大小的原生段。这个方法将新的空间和时间边界附加到一个零长度内存段上,以便允许解引用操作。这个方法返回的内存段是不安全的:一个零长度内存段可能由一个 10 字节长的内存区域支持,但客户端可能高估该区域的大小,并使用 MemorySegment::reinterpret 来获得一个 100 字节长的段。稍后,这可能会导致尝试解引用该区域边界之外的内存,这可能会导致 JVM 崩溃或者——更糟糕的是——导致无声的内存损坏。

覆盖一个零长度内存段的空间和时间边界是不安全的。出于这个原因,MemorySegment::reinterpret 方法是 受限的,在程序中使用它会导致 Java 运行时发出警告(见下面更多关于 安全 的内容)。

上调用

有时候,将 Java 代码作为函数指针传递给一些外部函数是很有用的。我们可以通过使用 Linker 对上调用的支持来做到这一点。在这一节中,我们一步一步地构建一个更复杂的例子,展示 Linker 的全部威力,在 Java/ 原生边界上实现代码和数据的完全双向交互。

考虑在标准 C 库中定义的这个函数:

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

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

java
Linker linker = Linker.nativeLinker();
MethodHandle qsort = linker.downcallHandle(
    linker.defaultLookup().find("qsort").get(),
    FunctionDescriptor.ofVoid(ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS)
);

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

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

首先,我们在 Java 中编写一个 static 方法,它比较两个 int 值,间接表示为 MemorySegment 对象:

java
class Qsort {
    static int qsortCompare(MemorySegment elem1, MemorySegment elem2) {
        return Integer.compare(elem1.get(JAVA_INT, 0), elem2.get(JAVA_INT, 0));
    }
}

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

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

第三,现在我们有了一个用于我们的 Java 比较器的方法句柄,我们可以使用 Linker::upcallStub 创建一个函数指针。就像下调用一样,我们使用一个 FunctionDescriptor 来描述函数指针的签名:

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

我们最终有了一个内存段 comparFunc,它指向一个可以用来调用我们的 Java 比较器函数的存根,所以现在我们有了调用 qsort 下调用句柄所需的一切:

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

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

安全

从根本上说,Java 代码和原生代码之间的任何交互都可能损害 Java 平台的完整性。链接到预编译库中的 C 函数本质上是不可靠的,因为 Java 运行时不能保证函数的签名与 Java 代码的期望匹配,甚至不能保证 C 库中的一个符号真的是一个函数。此外,即使链接了一个合适的函数,实际调用这个函数也可能导致低级故障,如段错误,最终导致虚拟机崩溃。这样的故障不能被 Java 运行时阻止,也不能被 Java 代码捕获。

使用 JNI 函数的原生代码特别危险。这样的代码可以在没有命令行标志(例如 --add-opens)的情况下访问 JDK 内部,通过使用诸如 getStaticFieldcallVirtualMethod 这样的函数。它也可以在 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 的一部分本质上是不安全的。当与 Linker 交互时,Java 代码可以通过指定与底层外部函数不兼容的参数类型来请求一个下调用方法句柄。在 Java 中调用下调用方法句柄将导致与在 JNI 中调用 native 方法时可能发生的相同类型的结果——虚拟机崩溃或未定义行为。FFM API 也可以产生不安全的段,即空间和时间边界由用户提供并且不能被 Java 运行时验证的内存段(见 MemorySegment::reinterpret)。

FFM API 中的不安全方法不像 JNI 函数那样构成相同的风险;它们不能,例如,改变 Java 对象中 final 字段的值。另一方面,FFM API 中的不安全方法很容易从 Java 代码中调用。出于这个原因,FFM API 中不安全方法的使用是 受限的:它们的使用是被允许的,但是默认情况下,每次这样的使用都会在运行时发出一个警告。要允许模块 M 中的代码使用不安全方法而不发出警告,在 java 命令行上指定 --enable-native-access=M 选项。(用逗号分隔的列表指定多个模块;指定 ALL-UNNAMED 以允许在类路径上的所有代码无警告使用。)当这个选项存在时,从指定模块列表之外的任何对不安全方法的使用都会导致抛出一个 IllegalCallerException,而不是发出一个警告。在未来的版本中,很可能需要这个选项才能使用不安全方法。

我们在这里不提议限制 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 使用原生库的开销。