JEP 454: Foreign Function & Memory API | 外部函数与内存 API
摘要
介绍一个 API,通过该 API,Java 程序可以与 Java 运行时环境之外的代码和数据互操作。通过有效地调用外部函数(即 JVM 之外的代码)并安全地访问外部内存(即不由 JVM 管理的内存),该 API 使 Java 程序能够调用本地库并处理本地数据,同时避免 JNI 的脆弱性和危险性。
历史
Foreign Function & Memory (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 运行的每个平台上启用本地库的发现和调用。
一致性——提供在多种类型的内存(如本地内存、持久内存和托管堆内存)中操作无限制大小的结构化和非结构化数据的方法。
健全性——即使在跨多个线程分配和释放内存时,也保证不会出现使用后释放错误。
完整性——允许程序对本地代码和数据执行不安全操作,但默认情况下会警告用户此类操作。
非目标
以下不是本项目的目标:
- 在此 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 的 堆 中,当这些对象不再需要时,它们将受到垃圾回收的影响。然而,对于性能关键型库(如 Tensorflow、Ignite、Lucene 和 Netty)而言,垃圾回收的成本和不可预测性是不可接受的。它们需要将数据存储在堆之外的 堆外内存 中,由它们自己分配和释放。对堆外内存的访问还允许通过映射文件直接到内存(例如,使用 mmap
)来序列化和反序列化数据。
Java 平台历来提供了两个用于访问堆外内存的 API:
ByteBuffer
API 提供了 直接 字节缓冲区,这些缓冲区是由固定大小的堆外内存区域支持的 Java 对象。然而,一个区域的最大大小限制为两吉字节,并且用于读写内存的方法非常基础且容易出错,只能提供对原始值的索引访问。更严重的是,只有当缓冲区对象被垃圾回收时,支持直接字节缓冲区的内存才会被释放,而开发人员无法控制这一点。sun.misc.Unsafe
API 提供了对堆上内存的低级访问,该访问也适用于堆外内存。使用Unsafe
是快速的(因为其内存访问操作是 JVM 固有的),允许巨大的堆外区域(理论上可达 16 艾字节),并提供了对释放的细粒度控制(因为可以随时调用Unsafe::freeMemory
)。然而,这种编程模型很脆弱,因为它给开发人员过多的控制权。在长时间运行的应用程序中,库可以随着时间的推移分配和与多个堆外内存区域进行交互;一个区域中的数据可以指向另一个区域中的数据,并且必须按正确的顺序释放区域,否则悬空指针将导致使用后释放错误。
(对 JDK 之外的 API 的相同批评也适用,这些 API 通过包装调用 malloc
和 free
的本机代码来提供细粒度的分配和释放。)
总之,经验丰富的开发人员需要一个 API,该 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 从对象中提取字段(如firstName
和lastName
)。因此,Java 开发人员有时会将其数据展平为单个对象(如字节数组或直接字节缓冲区),但更常见的是,由于通过 JNI 传递 Java 对象速度较慢,他们使用Unsafe
API 分配堆外内存并将其地址作为long
传递给native
方法,但这会使 Java 代码变得极其不安全!
多年来,为了弥补 JNI 留下的空白,出现了许多框架,包括 JNA、JNR 和 JavaCPP。这些框架通常是 JNI 的显著改进,但情况仍然不尽如人意——特别是与提供一流本地互操作性的语言相比。例如,Python 的 ctypes 包可以无需任何粘合代码即可动态包装本地库中的函数。其他语言,如 Rust,提供了可以机械地从 C/C++ 头文件中派生本地包装器的工具。
最终,Java 开发人员应该拥有一个受支持的 API,使他们能够直接调用任何被认为对特定任务有用的本地库,而无需 JNI 带来的繁琐和笨拙。两个极好的抽象基础是 方法句柄,即对方法式实体的直接引用,以及 变量句柄,即对变量式实体的直接引用。通过方法句柄暴露本地代码,通过变量句柄暴露本地数据,将极大地简化编写、构建和分发依赖于本地库的 Java 库的任务。此外,一个能够模拟外部函数(即本地代码)和外部内存(即堆外数据)的 API 将为第三方本地互操作框架提供坚实的基础。
描述
外部函数和内存 API(FFM API)定义了类和接口,以便库和应用程序中的客户端代码可以
- 控制外部内存的分配和释放(
MemorySegment
、Arena
和SegmentAllocator
), - 操作和访问结构化外部内存(
MemoryLayout
和VarHandle
),以及 - 调用外部函数(
Linker
、[SymbolLookup
](https://cr.openjdk.org/~mcimadamore/jdk/FFM_22_PR/javadoc/java.base/java/lang/foreign/SymbolLookup.html
示例
作为使用 FFM API 的一个简短示例,这里有一段 Java 代码,它获取了一个 C 库函数 radixsort
的方法句柄,然后使用它来对四个字符串进行排序,这些字符串最初位于一个 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.allocate(ValueLayout.ADDRESS, javaStrings.length);
// 5. 将字符串从堆内复制到堆外
for (int i = 0; i < javaStrings.length; i++) {
MemorySegment cString = offHeap.allocateFrom(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.reinterpret(...).getString(0);
}
} // 8. 这里释放所有堆外内存
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 保证在支持该段的内存区域被释放后,无法再访问该内存段。
段的时间边界由用于分配该段的 区域 确定。在同一个区域中分配的多个段具有相同的时间边界,并且可以安全地包含相互引用:段 A
可以持有一个指向段 B
中地址的指针,段 B
也可以持有一个指向段 A
中地址的指针,并且这两个段将同时被释放,因此两个段都不会有悬垂指针。
最简单的区域是 全局 区域,它提供了无限制的生命周期:它始终存在。如上述代码所示,在全局区域中分配的一个段始终可访问,并且支持该段的内存区域永远不会被释放。
然而,大多数程序需要在程序运行时释放堆外内存,因此需要具有有限生命周期的内存段。
自动 区域 提供了有限的生命周期:自动区域分配的一个段在 JVM 的垃圾收集器检测到该内存段不可达之前都可以访问,此时支持该段的内存区域将被释放。例如,此方法在自动区域中分配一个段:
void processData() {
MemorySegment data = Arena.ofAuto().allocate(100);
... 使用 'data' 变量 ...
... 再次使用 'data' 变量 ...
} // 支持 'data' 段的内存区域
// 在此处(或稍后)被释放
只要 data
变量没有从方法中泄露,该段最终将被检测为不可达,并且其支持区域将被释放。
自动区域的有限但非确定性的生命周期并不总是足够的。例如,一个从文件中映射内存段的 API 应该允许客户端确定性地释放支持该段的内存区域,因为等待垃圾收集器这样做可能会影响性能。
受限 区域 提供了有限且确定性的生命周期:它从客户端打开区域时开始,到客户端关闭区域时结束。在受限区域中分配的内存段只能在区域关闭之前访问,此时支持该段的内存区域将被释放。在区域关闭后尝试访问内存段将引发异常。例如,以下代码打开一个区域并使用该区域分配两个段:
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 类型(例如,
int
与float
)。
所有这些特性都在 ValueLayout
抽象中捕获。例如,预定义的 JAVA_INT
值布局为四字节宽,按四字节边界对齐,使用本地平台的字节序(例如,Linux/x64 上为小端),并与 Java 类型 int
相关联。
内存段具有简单的解除引用方法来从内存段读取值和向内存段写入值。这些方法接受一个值布局,该布局指定了解除引用操作的属性。例如,我们可以在内存段的连续偏移量处写入 25 个 int
值:
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
结构体有两个成员:
struct Point {
int x;
int y;
} pts[10];
使用上一节中展示的方法,我们可以为数组分配本地内存,并使用以下代码初始化这十个 Point
结构体中的每一个(我们假设 sizeof(int) == 4
):
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_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,
/* 基地址 */ 0L,
/* 索引 */ (long) i,
/* 要写入的值 */ i); // x
yHandle.set(segment,
/* 基地址 */ 0L,
/* 索引 */ (long) i,
/* 要写入的值 */ i); // y
}
段分配器
当客户端使用堆外内存时,内存分配常常成为瓶颈。因此,FFM API 包括了一个 SegmentAllocator
抽象,用于定义分配和初始化内存段的操作。为了方便起见,Arena
类实现了 SegmentAllocator
接口,这样,arena 就可以用来从各种现有源中分配本地段。换句话说,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!");
...
} // 在这里释放内存
还可以通过 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);
...
}
段分配器可以用作构建块,以创建支持自定义分配策略的 arena。例如,如果大量本地段将共享相同的有限生命周期,则自定义 arena 可以使用切片分配器来有效地分配这些段。这样,开发人员既可以享受可扩展的分配(得益于切片),又可以享受确定的释放(得益于 arena)。
作为示例,以下代码定义了一个 切片 arena,其行为类似于受限 arena,但在内部使用切片分配器来响应分配请求。当切片 arena 关闭时,底层受限 arena 也会被关闭,从而使切片 arena 中分配的所有段都失效。(省略了一些细节。)
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);
...
}
} // 在这里释放所有分配的内存
查找外部函数
支持外部函数的第一个要素是在已加载的本地库中查找给定符号地址的机制。这种能力由 SymbolLookup
对象表示,对于将 Java 代码链接到外部函数至关重要(见下文 将 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
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
方法的任何参数都将传递给外部函数。
对于上调(upcalls),upcallStub
方法接受一个方法句柄(通常指向 Java 方法,而不是下调方法句柄),并将其转换为 MemorySegment
实例。之后,当 Java 代码调用下调方法句柄时,该内存段将作为参数传递。实际上,内存段充当函数指针的作用。(有关上调的更多信息,请参见 下文。)
客户端使用通过 Linker::nativeLinker()
获得的 原生链接器(native linker)链接到 C 函数。原生链接器是 Linker
接口的一个实现,它符合 JVM 正在运行的原生平台的应用程序二进制接口(ABI)。ABI 指定了 调用约定(calling convention),该约定允许用一种语言编写的代码将参数传递给用另一种语言编写的代码并接收结果。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)
);
调用下调方法句柄将运行 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
,用于描述如 int
和 float
等标量 C 类型,以及 StructLayout
,用于描述 C 结构体类型。与 C 结构体类型相关联的内存布局必须是复合布局,该布局定义了 C 结构体中所有字段的子布局,包括原生编译器可能插入的任何与平台相关的填充。
本地链接器使用 FunctionDescriptor
来推导下调用方法句柄的 类型。每个方法句柄都是强类型的,这意味着它对可以在运行时传递给其 invokeExact
方法的参数的数量和类型有严格的要求。例如,创建为接受一个 MemorySegment
参数的方法句柄不能通过 invokeExact(<MemorySegment>, <MemorySegment>)
调用,即使 invokeExact
是一个可变参数方法。下调用方法句柄的类型描述了开发者在调用下调用方法句柄时必须使用的 Java 签名。它实际上是 C 函数在 Java 层面的视图。
如果目标 C 函数使用如 long
、int
和 size_t
等标量类型,开发者必须了解当前的本地平台。这是因为标量 C 类型与预定义值布局的关联因平台而异。当前平台中标量 C 类型与 JAVA_*
值布局的关联由 Linker::canonicalLayouts()
暴露。
例如,假设一个下调用方法句柄应该暴露一个 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
来排序数组的内容。因此,为了调用下调方法句柄,我们需要一个函数指针来作为方法句柄的 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
来创建一个函数指针。与下调一样,我们使用 FunctionDescriptor
来描述函数指针的签名:
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
下调方法句柄所需的一切:
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(... 文件路径 ...)) {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
int bytesRead;
while ((bytesRead = channel.read(buffer)) != -1) {
... 提取并处理缓冲区内容 ...
buffer.clear();
}
}
由于字节缓冲区可能小于文件,因此代码必须反复从通道读取数据,然后清除字节缓冲区,以便为下一次读取操作做准备。
在这种低级别的缓冲区分配和处理背景下,开发人员常常 惊讶地发现,他们无法控制堆外字节缓冲区的释放;相反,他们必须等待垃圾收集器回收它们。如果绝对需要立即释放,则只能求助于非标准、非确定性的技术,如调用 sun.misc.Unsafe::invokeCleaner
。
FFM API 允许开发人员将通道的功能与内存段和区域提供的标准、确定性释放功能相结合。
FFM API 包括一个 MemorySegment::asByteBuffer
方法,允许将任何内存段用作字节缓冲区。生成的字节缓冲区的生命周期由内存段的时间界限确定,而内存段的时间界限则由用于分配内存段的区域设置。客户端继续使用字节缓冲区读取和写入通道,但现在可以控制何时释放字节缓冲区的内存。以下是之前的示例,已修改为使用在 try
-with-resources 块关闭区域时释放内存的字节缓冲区:
// try-with-resources 管理两个资源:一个通道和一个区域
try (FileChannel channel = FileChannel.open(... 文件路径 ...);
Arena offHeap = Arena.ofConfined()) {
ByteBuffer buffer = offHeap.allocate(1024).asByteBuffer();
int readBytes;
while ((readBytes = channel.read(buffer)) != -1) {
... 解包并处理缓冲区内容 ...
buffer.clear();
}
} // 在这里释放缓冲区的内存
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
请求一个下调用方法句柄,但指定的参数类型可能与底层外部函数的参数类型不兼容。调用生成的方法句柄将产生与在 JNI 中调用 native
方法时可能发生的相同类型的失败——VM 崩溃或由本地代码引起的未定义行为。Java 运行时无法防止此类失败,Java 代码也无法捕获它们。FFM API 还可以用于生成不安全的段,即其空间和时间边界由用户提供且无法由 Java 运行时验证的内存段(请参阅 MemorySegment::reinterpret
)。
换句话说,Java 代码与本地代码之间的任何交互都可能破坏 Java 平台的完整性。因此,FFM API 中的不安全方法受到 限制。这意味着允许使用它们,但默认情况下会在运行时发出警告。例如:
警告:java.lang.foreign.Linker 中已调用受限方法
警告:com.foo.Server 在未命名模块中调用了 Linker::downcallHandle
警告:使用 --enable-native-access=ALL-UNNAMED 以避免此模块中调用者的警告
警告:除非启用本地访问,否则将在未来的版本中阻止受限方法
这些警告被写入标准错误流,并且每个调用受限方法的模块的代码最多只发出一次。
要允许模块 M
中的代码使用不安全方法而不发出警告,请在 java
启动器命令行上指定 --enable-native-access=M
选项。使用逗号分隔的列表指定多个模块;指定 ALL-UNNAMED
以允许类路径上所有代码无警告地使用。此外,可以在可执行 JAR 文件的 JAR 文件清单属性中使用 Enable-Native-Access: ALL-UNNAMED
,以允许类路径上所有代码无警告地使用;属性的值不能是其他模块名。
当存在 --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 代码使用本地库的开销。