跳到主要内容

JEP 454: 外部函数与内存 API

QWen Max 中英对照 JEP 454: Foreign Function & Memory API

总结​

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

历史​

外部函数和内存(FFM)API 最初由 JEP 424(JDK 19)作为预览功能提出,随后由 JEP 434(JDK 20)和 JEP 442(JDK 21)进行了改进。本 JEP 提议在持续的经验和反馈基础上,通过进一步的小幅改进来最终确定 FFM API。在此版本中,我们有:

  • 提供了一个新的链接器选项,允许客户端将堆段传递给下调用方法句柄;
  • 引入了 Enable-Native-Access JAR 文件清单属性,允许可执行 JAR 文件中的代码调用受限制的方法,而无需使用 --enable-native-access 命令行选项;
  • 使客户端能够以编程方式构建 C 语言函数描述符,避免使用特定于平台的常量;
  • 改进了对本地内存中变长数组的支持;以及
  • 增加了对本地字符串任意字符集的支持。

目标​

  • 生产力 — 用简洁、易读且纯 Java 的 API 替换 native 方法和 Java Native Interface (JNI) 的脆弱机制。

  • 性能 — 提供对外部函数和内存的访问,其开销与 JNI 和 sun.misc.Unsafe 相当,甚至更优。

  • 广泛的平台支持 — 在 JVM 运行的每个平台上,支持发现和调用本地库。

  • 一致性 — 提供操作结构化和非结构化数据的方式,支持无限大小的数据,并兼容多种内存类型(例如,本地内存、持久内存和托管堆内存)。

  • 可靠性 — 即使在多个线程中分配和释放内存,也能保证不会出现释放后使用(use-after-free)的错误。

  • 完整性 — 允许程序通过本地代码和数据执行不安全的操作,但默认情况下会向用户发出警告。

非目标​

这不是一个目标

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

动机​

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

外部内存​

通过 new 关键字创建的对象存储在 JVM 的 堆 中,当这些对象不再被需要时,它们会被垃圾回收机制处理。然而,对于像 Tensorflow、Ignite、Lucene 和 Netty 这样对性能要求极高的库来说,垃圾回收的成本和不可预测性是不可接受的。它们需要将数据存储在堆外的 堆外内存 中,并自行分配和释放这些内存。访问堆外内存还允许通过例如 mmap 将文件直接映射到内存中,从而实现数据的序列化和反序列化。

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

  • ByteBuffer API 提供了直接字节缓冲区,它们是通过堆外固定大小的内存区域支持的 Java 对象。然而,一个区域的最大大小被限制为 2 GB,并且用于读写内存的方法较为基础且容易出错,几乎仅提供了对原始值的索引访问。更严重的是,支持直接字节缓冲区的内存在缓冲区对象被垃圾回收之前不会被释放,而开发者无法控制垃圾回收。

  • sun.misc.Unsafe API 提供了对堆内内存的低级访问,同时也适用于堆外内存。使用 Unsafe 是快速的(因为它的内存访问操作是 JVM 内在的),允许巨大的堆外内存区域(理论上可达 16 EB),并提供了对释放内存的细粒度控制(因为可以随时调用 Unsafe::freeMemory)。然而,这种编程模型较弱,因为它赋予了开发者过多的控制权。在一个长时间运行的应用程序中,某个库可能会随时间分配并与多个堆外内存区域交互;一个区域中的数据可能指向另一个区域中的数据,如果这些区域没有按照正确的顺序释放,就会导致悬空指针引发释放后使用(use-after-free)的错误。

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

总之,成熟的开发者应该拥有一个可以像在堆内存中一样流畅且安全地分配、操作和共享堆外内存的 API。这样的 API 应该在可预测的释放需求与防止过早释放的需求之间取得平衡,过早释放可能会导致 JVM 崩溃,甚至更糟,导致悄无声息的内存损坏。

外部函数​

自 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 从对象中提取字段(例如,firstName 和 lastName)。结果是,Java 开发人员有时会将其数据扁平化为单个对象(例如,字节数组或直接字节缓冲区),但更多时候,由于通过 JNI 传递 Java 对象速度较慢,他们会使用 Unsafe API 分配堆外内存,并将其地址作为 long 类型传递给 native 方法 —— 这使得 Java 代码变得异常不安全!

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

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

描述​

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

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

示例​

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

// 1. Find foreign function on the C library path
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
MethodHandle radixsort = linker.downcallHandle(stdlib.find("radixsort"), ...);
// 2. Allocate on-heap memory to store four strings
String[] javaStrings = { "mouse", "cat", "dog", "car" };
// 3. Use try-with-resources to manage the lifetime of off-heap memory
try (Arena offHeap = Arena.ofConfined()) {
// 4. Allocate a region of off-heap memory to store four pointers
MemorySegment pointers
= offHeap.allocate(ValueLayout.ADDRESS, javaStrings.length);
// 5. Copy the strings from on-heap to off-heap
for (int i = 0; i < javaStrings.length; i++) {
MemorySegment cString = offHeap.allocateFrom(javaStrings[i]);
pointers.setAtIndex(ValueLayout.ADDRESS, i, cString);
}
// 6. Sort the off-heap data by calling the foreign function
radixsort.invoke(pointers, javaStrings.length, MemorySegment.NULL, '\0');
// 7. Copy the (reordered) strings from off-heap to on-heap
for (int i = 0; i < javaStrings.length; i++) {
MemorySegment cString = pointers.getAtIndex(ValueLayout.ADDRESS, i);
javaStrings[i] = cString.reinterpret(...).getString(0);
}
} // 8. All off-heap memory is deallocated here
assert Arrays.equals(javaStrings,
new String[] {"car", "cat", "dog", "mouse"}); // true

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

内存段与竞技场​

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

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

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

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

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

一个段的时间界限决定了其生命周期,即支持该段的内存区域被释放之前的时段。FFM API 保证在支持段的内存区域被释放后,无法再访问该内存段。

段的时域界限由用于分配该段的arena 决定。在相同 arena 中分配的多个段具有相同的时域界限,并且可以安全地包含相互引用:段 A 可以持有指向段 B 中地址的指针,段 B 也可以持有指向段 A 中地址的指针,两个段会在同一时间被释放,从而避免了悬空指针的出现。

最简单的区域是全局区域,它提供无限制的生命周期:它始终处于活动状态。在全局区域中分配的段(如上面的代码所示)始终可访问,并且支持该段的内存区域永远不会被释放。

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

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

void processData() {
MemorySegment data = Arena.ofAuto().allocate(100);
... use the 'data' variable ...
... use the 'data' variable some more ...
} // the region of memory backing the 'data' segment
// is deallocated here (or later)

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

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

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

MemorySegment input = null, output = null;
try (Arena processing = Arena.ofConfined()) {
input = processing.allocate(100);
... set up data in 'input' ...
output = processing.allocate(100);
... process data from 'input' to 'output' ...
... calculate the ultimate result from 'output' and store it elsewhere ...
} // the regions of memory backing the segments are deallocated here
...
input.get(ValueLayout.JAVA_BYTE, 0); // throws IllegalStateException
// (also for 'output')

退出 try-with-resources 代码块时,会关闭 arena(资源管理器),此时由 arena 分配的所有 segments(段)都会被原子性地失效,并且支持这些 segments 的内存区域会被释放。

受限区域的确定性生命周期是有代价的:只有一个线程可以访问在受限区域中分配的内存段。如果多个线程需要访问一个段,则可以使用共享区域。在共享区域中分配的内存段可以被多个线程访问,并且任何线程——无论是否访问该区域——都可以关闭该区域以释放这些段。关闭区域会自动使这些段失效,但由于需要昂贵的同步操作来检测并取消对这些段的待处理并发访问操作,因此支持这些段的内存区域的释放可能不会立即发生。

总之,arena 控制哪些线程可以访问内存段,以及何时访问,从而提供强大的时间安全性和可预测的性能模型。FFM API 提供了 arena 的选择,以便开发者可以在访问范围和释放及时性之间进行权衡。

解引用段​

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

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

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

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

MemorySegment segment
= Arena.ofAuto().allocate(100, // size
ValueLayout.JAVA_INT.byteAlignment()); // alignment
for (int i = 0; i < 25; i++) {
segment.setAtIndex(ValueLayout.JAVA_INT,
/* index */ i,
/* value to write */ i);
}

内存布局与结构化访问​

考虑以下 C 语言声明,它定义了一个包含十个 Point 结构体的数组,每个 Point 结构体有两个成员:

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

使用上一节中展示的方法,我们可以为数组分配原生内存,并使用以下代码初始化十个 Point 结构体(我们假设 sizeof(int) == 4):

MemorySegment segment
= Arena.ofAuto().allocate(2 * ValueLayout.JAVA_INT.byteSize() * 10, // size
ValueLayout.JAVA_INT.byteAlignment()); // alignment
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
}

为了减少对繁琐的内存布局计算(例如,上面示例中的 (i * 2) + 1)的需求,我们可以使用 MemoryLayout 以更声明式的方式描述内存段的内容。一个包含十个结构体的原生内存段,每个结构体是一对整数,可以用*序列布局来描述,该序列布局包含十个结构体布局*的实例,每个结构体布局是一对 JAVA_INT 布局:

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

从序列布局中,我们可以获得一个变量句柄,该句柄可以在具有相同布局的任何内存段中获取和设置数据元素。我们希望设置的一种元素是结构体序列中任意结构体里名为 x 的成员。因此,我们通过提供一个*布局路径*来获取此类元素的变量句柄,该路径先导航到一个结构体,然后导航到其成员 x:

VarHandle xHandle = ptsLayout.varHandle(PathElement.sequenceElement(),
PathElement.groupElement("x"));

相应地,对于成员 y:

VarHandle yHandle = ptsLayout.varHandle(PathElement.sequenceElement(),
PathElement.groupElement("y"));

我们现在可以通过分配具有结构体序列布局的原生段,然后通过两个变量句柄在每个连续的结构体中设置这两个成员,来分配和初始化一个包含十个 Point 结构体的数组。每个句柄接受要操作的 MemorySegment、结构体序列在段内的基地址,以及一个索引,该索引表示结构体序列中哪个结构体的成员将被设置。

MemorySegment segment = Arena.ofAuto().allocate(ptsLayout);
for (int i = 0; i < ptsLayout.elementCount(); i++) {
xHandle.set(segment,
/* base */ 0L,
/* index */ (long) i,
/* value to write */ i); // x
yHandle.set(segment,
/* base */ 0L,
/* index */ (long) i,
/* value to write */ i); // y
}

段分配器​

当客户端使用堆外内存时,内存分配往往是一个瓶颈。因此,FFM API 包含了一个 SegmentAllocator 抽象,用于定义分配和初始化内存段的操作。为了方便起见,Arena 类实现了 SegmentAllocator 接口,以便可以从各种现有来源分配原生内存段。换句话说,Arena 是一个灵活分配和及时释放堆外内存的“一站式解决方案”:

try (Arena offHeap = Arena.ofConfined()) {
MemorySegment nativeInt = offHeap.allocateFrom(ValueLayout.JAVA_INT, 42);
MemorySegment nativeIntArray = offHeap.allocateFrom(ValueLayout.JAVA_INT,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9);
MemorySegment nativeString = offHeap.allocateFrom("Hello!");
...
} // memory released here

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

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

段分配器可以用作构建块来创建支持自定义分配策略的区域。例如,如果有大量原生段共享相同的有限生命周期,那么自定义区域可以使用切片分配器来高效地分配这些段。这使得开发者既能享受可扩展的分配(得益于切片),又能享受确定性的释放(得益于区域)。

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

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();
}
}

之前使用切片分配器的代码现在可以写得更加简洁:

try (Arena slicingArena = new SlicingArena(1000)) {
for (int i = 0 ; i < 10 ; i++) {
MemorySegment s = slicingArena.allocateFrom(ValueLayout.JAVA_INT, 1, 2, 3, 4, 5);
...
}
} // all memory allocated is released here

查找外部函数​

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

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

  • SymbolLookup::loaderLookup() 创建一个加载器查找,它定位当前类加载器使用 System::loadLibrary 和 System::load 方法加载的所有原生库中的所有符号。

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

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

try (Arena arena = Arena.ofConfined()) {
SymbolLookup opengl = SymbolLookup.libraryLookup("libGL.so", arena);
MemorySegment glVersion = opengl.find("glGetString").get();
...
} // libGL.so unloaded here

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

相比之下,FFM API 并未提供用于本地代码访问 Java 环境的函数,也不假设本地库是为与 FFM API 配合使用而设计的。通过 SymbolLookup::libraryLookup(String, Arena) 加载的本地库不一定是为了从 Java 代码访问而编写的,也不会尝试执行 Java 操作。因此,它们不绑定到特定的类加载器,并且可以由不同加载器中的 FFM API 客户端根据需要多次(重新)加载。

将 Java 代码链接到外部函数​

Linker 接口是 Java 代码与原生代码互操作的核心。虽然在本文档中我们经常提到 Java 代码与 C 库之间的互操作,但此接口中的概念足够通用,未来可以支持其他非 Java 语言。Linker 接口既支持 下调用(从 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 代码调用下行调用方法句柄时,该内存段作为参数传递。实际上,内存段充当了函数指针的作用。(有关上行调用的更多信息,请参见下文。)

客户端通过 本地链接器 链接到 C 函数,该链接器通过 Linker::nativeLinker() 获取。本地链接器是 Linker 接口的一种实现,它符合运行 JVM 的本地平台的应用程序二进制接口(ABI)。ABI 规定了 调用约定,使得用一种语言编写的代码能够将参数传递给用另一种语言编写的代码,并接收结果。ABI 还规定了标量 C 类型的大小、对齐方式和字节序,如何处理可变参数调用以及其他细节。尽管 Linker 接口在调用约定方面保持中立,但本地链接器针对许多平台的调用约定进行了优化:

  • Linux/x64
  • Linux/AArch64
  • Linux/RISC-V
  • Linux/PPC64
  • Linux/s390
  • macOS/x64
  • macOS/AArch64
  • Windows/x64
  • Windows/AArch64
  • AIX/ppc64

原生链接器通过委托给 libffi 来支持 其他平台 的调用约定。

作为一个示例,假设我们希望从 Java 代码下调到标准 C 库中定义的 strlen 函数:

size_t strlen(const char *s);

获取一个暴露 strlen 的下调方法句柄的方式如下(FunctionDescriptor 的细节将很快描述):

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

调用 downcall 方法句柄会运行 strlen 并将其结果提供给 Java 代码:

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

对于传递给 strlen 的参数,我们使用 Arena 的 allocateFrom 辅助方法之一,将 Java 字符串转换为堆外内存段。将此内存段传递给 strlen.invoke 时,会将内存段的基地址作为 char * 参数传递给 strlen 函数。

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

在 Java 代码中描述 C 类型​

要创建一个下调用方法句柄,本地链接器要求客户端提供一个 FunctionDescriptor,它描述了目标 C 函数的 C 参数类型和 C 返回类型。C 类型由 MemoryLayout 对象描述,主要是 ValueLayout,用于标量 C 类型(例如 int 和 float),以及 StructLayout,用于 C 结构体类型。与 C 结构体类型关联的内存布局必须是一个复合布局,它定义了 C 结构体中所有字段的子布局,包括任何平台相关的填充,这些填充可能是由本地编译器插入的。

原生链接器使用 FunctionDescriptor 来推导下调用方法句柄的 类型。每个方法句柄都是强类型的,这意味着它对可以传递给其 invokeExact 方法的参数数量和类型有严格要求。例如,为接受一个 MemorySegment 参数而创建的方法句柄不能通过 invokeExact(<MemorySegment>, <MemorySegment>) 调用,即使 invokeExact 是一个可变参数方法。下调用方法句柄的类型描述了开发者在调用下调用方法句柄时必须使用的 Java 签名。实际上,这是 C 函数在 Java 层面上的视图。

如果开发者针对使用诸如 long、int 和 size_t 等标量类型的 C 函数,他们必须了解当前的原生平台。这是因为标量 C 类型与预定义值布局的关联因平台而异。当前平台的标量 C 类型与 JAVA_* 值布局之间的关联由 Linker::canonicalLayouts() 暴露。

例如,假设一个 downcall 方法句柄应该公开一个 C 函数,该函数接受一个 C int 并返回一个 C long:

  • 在 Linux/x64 和 macOS/x64 上,C 类型 long 和 int 分别与预定义布局 JAVA_LONG 和 JAVA_INT 相关联,因此可以通过 FunctionDescriptor.of(JAVA_LONG, JAVA_INT) 获取所需的 FunctionDescriptor。然后,本地链接器会将下调方法句柄的类型安排为从 Java 签名 int 到 long。

  • 在 Windows/x64 上,C 类型 long 与预定义布局 JAVA_INT 相关联,因此必须通过 FunctionDescriptor.of(JAVA_INT, JAVA_INT) 获取所需的 FunctionDescriptor。然后,本地链接器会将下调方法句柄的类型安排为从 Java 签名 int 到 int。

开发者可以针对使用指针的 C 函数,而无需了解当前本地平台或当前平台上指针的大小。在所有平台上,C 指针类型都与预定义布局 ADDRESS 相关联,其大小在运行时确定。开发者不需要区分诸如 int* 和 char** 这样的 C 指针类型。

例如,假设一个下调方法句柄应公开一个接受指针的 void C 函数。由于每个 C 指针类型都与布局 ADDRESS 相关联,因此可以通过 FunctionDescriptor.ofVoid(ADDRESS) 获取所需的 FunctionDescriptor。然后,本地链接器会将下调方法句柄的类型安排为 Java 签名 MemorySegment 到 void。当将 MemorySegment 传递给下调方法句柄时,该段的基本地址将被传递给目标 C 函数。

最后,与 JNI 不同,本地链接器支持将结构化数据传递给外部函数。假设一个下调用方法句柄应该暴露一个 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")
);

所需的 FunctionDescriptor 可以通过 FunctionDescriptor.ofVoid(SYSTEMTIME) 获取。本地链接器会将下调方法句柄的类型安排为 Java 签名 MemorySegment 到 void。

鉴于原生平台的调用约定,当使用 MemorySegment 参数调用下向方法句柄时,原生链接器会使用 FunctionDescriptor 来确定如何将结构体的字段传递给 C 函数。对于一种调用约定,原生链接器可以安排分解传入的内存段,使用通用 CPU 寄存器传递前四个字段,并将剩余字段传递到 C 栈上。对于另一种调用约定,原生链接器可以安排通过分配一块内存区域来间接传递结构体,将传入内存段的内容批量复制到该区域中,并将指向该区域的指针传递给 C 函数。这种底层的参数打包过程在幕后进行,无需客户端代码的任何干预。

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

如前所述,虽然原生链接器专注于提供 Java 代码与 C 库之间的互操作性,但 Linker 接口是与语言无关的:它并未指定任何原生数据类型的定义方式,因此开发者需要负责获取适用于 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 库中定义的这个函数:

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

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

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 对数组的内容进行排序。因此,要调用 downcall 方法句柄,我们需要一个函数指针,作为方法句柄的 invokeExact 方法的最后一个参数。Linker::upcallStub 帮助我们通过使用现有的方法句柄来创建函数指针,如下所示。

首先,我们编写一个 static 方法,该方法比较两个由 MemorySegment 对象间接表示的 int 值:

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

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

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

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

MemorySegment comparFunc
= linker.upcallStub(comparHandle,
/* A Java description of a C function
implemented by a Java method! */
FunctionDescriptor.of(JAVA_INT,
ADDRESS.withTargetLayout(JAVA_INT),
ADDRESS.withTargetLayout(JAVA_INT)),
Arena.ofAuto());

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

try (Arena arena = Arena.ofConfined()) {
MemorySegment array
= arena.allocateFrom(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.toArray(JAVA_INT); // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
}

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

内存段和字节缓冲区​

java.nio.channels API 提供了对文件和套接字执行 I/O 操作的广泛功能。在该 API 中,I/O 操作是通过 ByteBuffer 对象而不是简单的字节数组来表示的。客户端在向通道写入数据时,必须先将数据放入字节缓冲区;从通道读取数据后,客户端必须从字节缓冲区中提取数据。例如,以下代码使用 FileChannel 将文件内容读取到堆外字节缓冲区中,每次读取 1024 字节:

try (FileChannel channel = FileChannel.open(... a file path ...)) {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
int bytesRead;
while ((bytesRead = channel.read(buffer)) != -1) {
... extract and process buffer contents ...
buffer.clear();
}
}

由于字节缓冲区很可能比文件小,因此代码必须重复从通道读取数据,然后清除字节缓冲区,以为下一次读取操作做好准备。

鉴于低级缓冲区分配和处理这一背景,开发者常常会感到惊讶,因为他们无法控制堆外字节缓冲区的释放;相反,他们必须等待垃圾回收器来回收这些缓冲区。如果确实需要及时释放,那么他们只能求助于非标准、非确定性的技术,例如调用 sun.misc.Unsafe::invokeCleaner。

FFM API 使开发者能够将通道的功能与内存段和区域提供的标准、确定性释放功能结合起来。

FFM API 包含一个 MemorySegment::asByteBuffer 方法,该方法允许将任何内存段用作字节缓冲区。生成的字节缓冲区的生命周期由内存段的时间范围决定,而这些时间范围又由用于分配内存段的 arena(区域)设定。客户端继续使用字节缓冲区读取和写入通道,但现在可以控制字节缓冲区内存何时被释放。以下是前面示例的修订版,改为使用一个字节缓冲区,其内存会在 try-with-resources 块关闭 arena 时被释放:

// 示例代码保持不变,无需翻译
java
// try-with-resources manages two resources: a channel and an arena
try (FileChannel channel = FileChannel.open(... a file path ...);
Arena offHeap = Arena.ofConfined()) {
ByteBuffer buffer = offHeap.allocate(1024).asByteBuffer();
int readBytes;
while ((readBytes = channel.read(buffer)) != -1) {
... unpack and process the contents of buffer ...
buffer.clear();
}
} // buffer’s memory is deallocated here

FFM API 还包含一个 MemorySegment::ofBuffer 方法,该方法允许将任何字节缓冲区用作内存段,方法是创建一个由相同内存区域支持的内存段。例如,以下方法调用原生 strlen 函数,该函数接受一个 char * 类型参数,并传入一个由堆外字节缓冲区生成的内存段:

void readString(ByteBuffer offheapString) {
MethodHandle strlen = ...
long len = strlen.invokeExact(MemorySegment.ofBuffer(offheapString));
...
}

许多 Java 程序中都可以找到字节缓冲区,因为它们长期以来一直是将堆外数据传递给原生代码的唯一受支持方式。然而,原生代码访问字节缓冲区中的数据却很麻烦,因为原生代码必须首先调用一个 JNI 函数,以获取支持该字节缓冲区的内存区域的指针。相比之下,原生代码很容易访问内存段中的数据,因为当 Java 代码将一个 MemorySegment 对象传递给原生代码时,FFM API 会传递内存段的基地址 —— 即指向其数据的指针,而不是 MemorySegment 对象本身的地址。

安全性​

大多数 FFM API 从设计上就是安全的。过去许多需要使用 JNI 和原生代码的场景,现在可以通过调用 FFM API 中的方法来解决,这些方法绝不会破坏 Java 平台的完整性。例如,JNI 的一个重要使用场景——灵活的内存分配与释放——现在可以通过 Java 代码中的内存段和区域来实现,而无需任何原生代码支持。

然而,FFM API 的一部分本质上是不安全的。例如,Java 代码可以请求从 Linker 获取一个 downcall 方法句柄,但指定的参数类型与底层外部函数的参数类型不兼容。调用生成的方法句柄将产生与在 JNI 中调用 native 方法时可能出现的相同类型的失败 —— 即 VM 崩溃或本地代码的未定义行为。这些失败无法被 Java 运行时阻止,也无法被 Java 代码捕获。FFM API 还可用于生成不安全的段,即空间和时间边界由用户提供且无法被 Java 运行时验证的内存段(参见 MemorySegment::reinterpret)。

换句话说,Java 代码和原生代码之间的任何交互都可能破坏 Java 平台的完整性。因此,FFM API 中的不安全方法是受限制的。这意味着允许使用这些方法,但默认情况下会在运行时发出警告。例如:

WARNING: A restricted method in java.lang.foreign.Linker has been called
WARNING: Linker::downcallHandle has been called by com.foo.Server in an unnamed module
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

此类警告将写入标准错误流,每个代码调用受限制方法的模块最多发出一次警告。

为了允许模块 M 中的代码在不发出警告的情况下使用非安全方法,请在 java 启动器命令行中指定 --enable-native-access=M 选项。可以使用逗号分隔的列表指定多个模块;指定 ALL-UNNAMED 可以让类路径上的所有代码在不发出警告的情况下使用非安全方法。此外,JAR 文件清单属性 Enable-Native-Access: ALL-UNNAMED 可用于可执行 JAR 中,以允许类路径上的所有代码在不发出警告的情况下使用非安全方法;该属性的值不能是其他任何模块名称。

当存在 --enable-native-access 选项时,任何来自指定模块列表之外的不安全方法的使用都会导致抛出 IllegalCallerException,而不是发出警告。在未来的版本中,很可能需要此选项才能使用不安全方法;也就是说,如果该选项不存在,使用不安全方法将不会产生警告,而是抛出 IllegalCallerException。

为了确保 Java 代码与原生代码交互方式的一致性,一个相关的 JEP 提案 建议以类似的方式限制 JNI 的使用。虽然仍然可以从 Java 代码调用 native 方法,并且原生代码可以调用不安全的 JNI 函数,但为了避免警告以及后续的异常,将需要使用 --enable-native-access 选项。这与 让 Java 平台开箱即用更安全 的更广泛路线图保持一致,要求最终用户或应用程序开发者主动选择参与不安全的操作,例如破坏强封装或链接到未知代码。

风险与假设​

创建一个既能安全又能高效访问外部内存的 API 是一项艰巨的任务。由于每次访问时都需要检查空间和时间界限,因此 JIT 编译器能够通过例如将这些检查移出热点循环等方式来优化掉这些检查是至关重要的。JIT 实现可能需要一些工作,以确保 API 的使用与现有的 API(如 ByteBuffer 和 Unsafe)一样高效且可优化。JIT 实现还需要一些工作,以确保由 API 生成的本地方法句柄的使用至少与现有的 JNI 本地方法的使用一样高效且可优化。

依赖​

jextract 工具依赖于 FFM API。它接收一个本地库的头文件,并自动生成与该库进行互操作所需的下调用方法句柄。这减少了从 Java 代码中使用本地库的开销。