JEP 434: Foreign Function & Memory API (Second Preview) | 外部函数与内存 API(第二次预览)
摘要
引入一个 API,通过该 API,Java 程序可以与 Java 运行时外部的代码和数据进行交互。通过高效地调用外部函数(即 JVM 外部的代码)以及安全地访问外部内存(即不由 JVM 管理的内存),该 API 使 Java 程序能够调用本地库并处理本地数据,而不会有 Java 本地接口(JNI)的脆弱性和危险性。这是一个 预览 API。
历史
外部函数与内存(FFM)API 结合了两个早期的 孵化 API:外部内存访问 API(JEP 370、383 和 393)以及外部链接器 API(JEP 389)。FFM API 在 JDK 17 中通过 JEP 412 进行孵化,在 JDK 18 中通过 JEP 419 再次孵化,并在 JDK 19 中通过 JEP 424 首次进行预览。这个 JEP 提议根据反馈进行改进,并在 JDK 20 中重新预览该 API。在这个版本中:
MemorySegment
和MemoryAddress
抽象被统一(内存地址现在由零长度的内存段建模);- 密封的
MemoryLayout
层次结构得到增强,以便在switch
表达式和语句中与模式匹配一起使用(JEP 433),并且 MemorySession
已被拆分为Arena
和SegmentScope
,以便在维护边界之间共享内存段。
目标
- 易用性——用一个更好的纯 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 的“堆”中,当不再需要时,它们会受到垃圾回收。然而,与垃圾回收相关的成本和不可预测性对于性能关键的库(如 Tensorflow、Ignite、Lucene 和 Netty)来说是不可接受的。它们需要将数据存储在堆外,即它们自己分配和释放的“堆外”内存中。对堆外内存的访问还允许通过例如 mmap
将文件直接映射到内存中进行数据的序列化和反序列化。
Java 平台历史上提供了两个用于访问堆外内存的 API:
ByteBuffer API
提供“直接”字节缓冲区,它们是由固定大小的堆外内存区域支持的 Java 对象。然而,一个区域的最大大小限制为两吉字节,并且用于读写内存的方法是基本的且容易出错,仅提供对基本值的索引访问。更严重的是,支持直接字节缓冲区的内存仅在缓冲区对象被垃圾回收时才会被释放,而开发人员无法控制这一点。缺乏对及时释放的支持使得ByteBuffer API
不适合 Java 中的系统编程。sun.misc.Unsafe API
提供对堆上内存的低级访问,也适用于堆外内存。使用Unsafe
很快(因为其内存访问操作由 JVM 内联),允许巨大的堆外区域(理论上高达 16EB),并且对释放提供细粒度的控制(因为可以在任何时候调用Unsafe::freeMemory
)。然而,这种编程模型很弱,因为它给了开发人员太多的控制。一个长期运行的服务器应用程序中的库将随着时间的推移分配并与多个堆外内存区域进行交互;一个区域中的数据将指向另一个区域中的数据,并且必须以正确的顺序释放区域,否则悬空指针将导致使用后释放的错误。缺乏对安全释放的支持使得Unsafe API
不适合 Java 中的系统编程。(同样的批评也适用于 JDK 外部的 API,这些 API 通过包装调用
malloc
和free
的本地代码来提供细粒度的分配和释放。)
总之,复杂的客户端应该有一个 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 7 中引入的用于支持 JVM 上的快速动态语言的“方法句柄”。通过方法句柄公开本地代码将从根本上简化编写、构建和分发依赖于本地库的 Java 库的任务。此外,一个能够对外部函数(即本地代码)和外部内存(即堆外数据)进行建模的 API 将为第三方本地交互框架提供坚实的基础。
描述
外部函数与内存 API(FFM API)定义了类和接口,以便库和应用程序中的客户端代码可以:
- 分配外部内存(
MemorySegment
和SegmentAllocator
); - 操作和访问结构化的外部内存(
MemoryLayout
和VarHandle
); - 控制外部内存的分配和释放(
SegmentScope
和Arena
); - 调用外部函数(
Linker
、FunctionDescriptor
和SymbolLookup
)。
FFM API 位于 java.base
模块的 java.lang.foreign
包中。
示例
作为使用 FFM API 的一个简短示例,下面是一段 Java 代码,它获取一个 C 库函数 radixsort
的方法句柄,然后使用它对四个最初在 Java 数组中的字符串进行排序(省略了一些细节)。
因为 FFM API 是一个 预览 API,所以你必须在启用预览功能的情况下编译和运行代码,即 javac --release 20 --enable-preview...
和 java --enable-preview...
。
// 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.openConfined()) {
// 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 数组或字节缓冲区相关联的堆上内存区域包裹。
所有的内存段都提供空间、时间和线程限制保证,这使得内存访问操作是安全的。
一个段的“空间边界”决定了与该段相关联的内存地址范围。例如,下面的代码分配了一个原生段,其边界由一个“基地址”b
和一个字节大小(100)定义,结果是一个从 b
到 b
+ 99(包括 b
+ 99)的地址范围。
MemorySegment data = MemorySegment.allocateNative(100, SegmentScope.global());
一个段的“时间边界”决定了它的生命周期,即直到支持该段的内存区域被释放的时间段。时间边界在分配段时由一个 段作用域 指定。一个内存段只有在其作用域“活跃”时才能被访问,这表明支持该段的内存区域仍然被分配着。尝试访问一个作用域不活跃的内存段将抛出异常。
最简单的段作用域是“全局”作用域,它提供无限制的生命周期:它总是活跃的。像上面的代码那样用全局作用域分配的段总是可以访问的,并且支持该段的内存区域永远不会被释放。
然而,大多数程序需要在程序运行时释放堆外内存,因此需要有有限生命周期的内存段。
一个“自动”作用域提供有界的生命周期:它在 JVM 的垃圾回收器检测到该内存段不可达之前一直是活跃的,此时支持该段的内存区域将被释放。例如,这个方法用自动作用域分配一个段:
void processData() {
MemorySegment data = MemorySegment.allocateNative(100, SegmentScope.auto());
... 使用 'data' 变量...
... 再次使用 'data' 变量...
} // 支持 'data' 段的内存区域将在这里(或稍后)被释放
只要 data
变量没有从这个方法中泄漏出去,这个段最终将被检测为不可达,并且其支持区域将被释放。
自动作用域的有界但非确定性的生命周期并不总是足够的。例如,一个从文件映射内存段的 API 应该允许客户端确定性地释放支持该段的内存区域,因为等待垃圾回收器可能会对性能产生不利影响。
一个“竞技场”作用域提供有界且确定性的生命周期:它从客户端打开一个 “竞技场” 时开始活跃,直到客户端关闭这个竞技场。每个竞技场提供自己的作用域;用同一个竞技场作用域分配的多个段享有相同的有界生命周期,并且可以安全地包含相互引用。例如,这段代码打开一个竞技场,并使用竞技场的作用域来指定两个段的生命周期:
MemorySegment input = null, output = null;
try (Arena processing = Arena.openConfined()) {
input = MemorySegment.allocateNative(100, processing.scope());
... 在 'input' 中设置数据...
output = MemorySegment.allocateNative(100, processing.scope());
... 将数据从 'input' 处理到 'output'...
... 从 'output' 计算最终结果并存储在其他地方...
} // 支持这些段的内存区域在这里被释放
...
input.get(ValueLayout.JAVA_BYTE, 0); // 抛出 IllegalStateException(对 'output' 也是如此)
当通过 try
-with-resources 的操作关闭竞技场时,竞技场作用域不再活跃,这些段被无效化,并且支持这些段的内存区域被原子地释放。
竞技场提供强大的时间安全保证:用竞技场作用域分配的内存段在竞技场关闭后不能被访问,因为竞技场作用域不再活跃。这个保证的成本取决于访问该内存段的线程数量。如果一个竞技场由同一个线程打开和关闭,并且用竞技场作用域分配的所有内存段仅由该线程访问,那么确保正确性是很简单的。然而,如果用竞技场作用域分配的内存段被多个线程访问,那么确保正确性就很复杂;例如,一个用竞技场作用域分配的段可能被一个线程访问,而另一个线程试图关闭竞技场。为了在不使单线程客户端付出过高成本的情况下保证时间安全,有两种竞技场:“受限的”和“共享的”。
- 受限竞技场(
Arena::openConfined
)支持强大的线程限制保证。受限竞技场有一个“所有者线程”,通常是打开它的线程。在受限竞技场中分配的内存段(即,用受限竞技场的作用域)只能被所有者线程访问。任何从非所有者线程尝试关闭受限竞技场的操作都将抛出异常。 - 共享竞技场(
Arena::openShared
)没有所有者线程。在共享竞技场中分配的内存段可以被多个线程访问。此外,任何线程都可以关闭共享竞技场,并且关闭操作保证是安全且原子的,即使在竞争条件下也是如此。
总之,一个段作用域控制哪些线程可以在何时访问一个内存段。具有全局作用域或自动作用域的内存段可以被任何线程访问。相反,竞技场作用域限制对特定线程的访问,以提供强大的时间安全性和可预测的性能模型。
解引用段
为了解引用内存段中的一些数据,我们需要考虑几个因素:
- 要解引用的字节数;
- 解引用发生的地址的对齐约束;
- 内存段中字节的存储字节序;以及
- 在解引用操作中要使用的 Java 类型(例如,
int
与float
)。
所有这些特性都被捕获在 ValueLayout
抽象中。例如,预定义的 JAVA_INT
值布局是四个字节宽,在四字节边界上对齐,使用本地平台字节序(例如,在 Linux/x64 上是小端字节序),并且与 Java 类型 int
相关联。
内存段有简单的解引用方法来从内存段读取值和向内存段写入值。这些方法接受一个值布局,它唯一地指定了解引用操作的属性。例如,我们可以使用以下代码在一个内存段中连续的偏移处写入 25 个 int
值:
MemorySegment segment = MemorySegment.allocateNative(100, // 大小
ValueLayout.JAVA_INT.byteAlignment, // 对齐
SegmentScope.auto());
for (int i = 0; i < 25; i++) {
segment.setAtIndex(ValueLayout.JAVA_INT,
/* 索引 */ i,
/* 要写入的值 */ i);
}
内存布局与结构化访问
考虑以下 C 声明,它定义了一个 Point
结构体数组,其中每个 Point
结构体有两个成员,即 Point.x
和 Point.y
:
struct Point {
int x;
int y;
} pts[10];
使用上一节中展示的解引用方法,要初始化这样一个原生数组,我们将不得不编写以下代码(在下面我们假设 sizeof(int)==4
):
MemorySegment segment = MemorySegment.allocateNative(2 * ValueLayout.JAVA_INT.byteSize() * 10, // 大小
ValueLayout.JAVA_INT.byteAlignment, // 对齐
SegmentScope.auto());
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
以更声明式的方式描述一个内存段的内容。例如,上面例子中所需的原生内存段的布局可以用以下方式描述:
SequenceLayout ptsLayout
= MemoryLayout.sequenceLayout(10,
MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("x"),
ValueLayout.JAVA_INT.withName("y")));
这创建了一个包含十个重复的“结构体布局”的“序列内存布局”,其元素是两个分别名为 x
和 y
的 JAVA_INT
布局。有了这个布局,我们可以通过创建两个“内存访问变量句柄”来避免在我们的代码中计算偏移量,特殊的 变量句柄 接受一个 MemorySegment
参数(要解引用的段),后面跟着一个或多个 long
坐标(解引用操作应该发生的索引):
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, SegmentScope.auto());
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
相关联,所以结果变量句柄 xHandle
和 yHandle
的类型也将是 int
。此外,由于所选的值布局是在一个序列布局中定义的,所以变量句柄获得了一个额外的 long
类型的坐标,即要读取或写入其坐标的 Point
结构体的索引。ptsLayout
对象还驱动原生内存段的分配,这是基于从布局中派生的大小和对齐信息。在循环内部不再需要偏移计算,因为使用不同的变量句柄来初始化 Point.x
和 Point.y
元素。
段分配器
当客户端使用堆外内存时,内存分配通常是一个瓶颈。因此,FFM API 包括一个 SegmentAllocator
抽象来定义分配和初始化内存段的操作。为了方便起见,Arena
类实现了 SegmentAllocator
接口,这样竞技场就可以用于分配原生段。换句话说,Arena
是一个“一站式商店”,用于灵活分配和及时释放堆外内存:
try (Arena offHeap = Arena.openConfined()) {
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.scope());
...
} // 在这里释放内存
段分配器也可以通过 SegmentAllocator
接口中的工厂方法获得。其中一个工厂方法返回一个原生分配器,即一个分配与给定段作用域相关联的原生段的分配器。还提供了其他更优化的分配器。例如,以下代码创建一个“切片”分配器,并使用它来分配一个段,其内容从一个 Java int
数组初始化:
try (Arena arena = Arena.openConfined()) {
SegmentAllocator allocator = SegmentAllocator.slicingAllocator(arena.allocate(1024));
for (int i = 0 ; i < 10 ; i++) {
MemorySegment s = allocator.allocateArray(JAVA_INT, new int[] { 1, 2, 3, 4, 5 });
...
}
...
} // 在这里释放所有分配的内存
这段代码创建了一个大小为 1024 字节的原生段。然后,该段被用于创建一个切片分配器,它通过返回那个预分配段的切片来响应分配请求。如果当前段没有足够的空间来满足分配请求,就会抛出一个异常。当竞技场关闭时,由分配器创建的段所关联的所有内存(即在 for 循环体中)将被原子地释放。这种技术结合了由 Arena
抽象提供的确定性释放的优点,以及一个更灵活和可扩展的分配方案。当编写管理大量堆外段的代码时,它会非常有用。
查找外部函数
对外部函数的任何支持的第一个要素是一种在已加载的本地库中找到给定符号的地址的机制。这个由一个 SymbolLookup
对象表示的能力对于将 Java 代码链接到外部函数至关重要(见 下面)。FFM API 支持三种不同类型的符号查找对象:
SymbolLookup::libraryLookup(String, SegmentScope)
创建一个“库查找”,它在用户指定的本地库中定位所有的符号。创建查找对象会导致库被加载(例如,使用dlopen()
)并与一个SegmentScope
对象相关联。当提供的段作用域不再活跃时,库将被卸载(例如,使用dlclose()
)。SymbolLookup::loaderLookup()
创建一个“加载器查找”,它在当前类加载器中由类使用System::loadLibrary
和System::load
方法加载的所有本地库中定位所有的符号。Linker::defaultLookup()
创建一个“默认查找”,它在与Linker
实例相关联的操作系统和处理器组合上常用的库中定位所有的符号。
有了一个符号查找,客户端可以使用 SymbolLookup::find(String)
方法找到一个外部函数。如果命名的函数在符号查找看到的符号中存在,那么该方法将返回一个零长度的内存段(见 下面),其基地址指向函数的入口点。例如,以下代码使用一个加载器查找来加载 OpenGL 库并找到其 glGetString
函数的地址:
try (Arena arena = Arena.openConfined()) {
SymbolLookup opengl = SymbolLookup.libraryLookup("libGL.so", arena);
MemorySegment glVersion = opengl.find("glGetString").get();
...
} // 在这里卸载 libGL.so
SymbolLookup::libraryLookup(String, SegmentScope)
与 JNI 的库加载机制(即 System::loadLibrary
)在一个重要方面有所不同。设计用于与 JNI 一起工作的本地库可以使用 JNI 函数来执行 Java 操作,例如对象分配或方法访问,这可能会触发 类加载。因此,这样的与 JNI 相关联的库在被 JVM 加载时必须与一个类加载器相关联。然后,为了保持 类加载器的完整性,同一个与 JNI 相关联的库不能从不同类加载器中定义的类加载。相比之下,FFM API 不提供让本地代码访问 Java 环境的函数,并且不假设本地库是设计用于与 FFM API 一起工作的。通过 SymbolLookup::libraryLookup(String, SegmentScope)
加载的本地库不知道它们是从在 JVM 中运行的代码访问的,并且不会尝试执行 Java 操作。因此,它们不与特定的类加载器绑定,可以根据 FFM API 客户端在不同加载器中的需要被加载(重新加载)任意多次。
将 Java 代码链接到外部函数
Linker
接口是 Java 代码与外部代码交互的核心。虽然在本文档中我们经常提到 Java 与 C 库之间的交互操作,但这个接口中的概念足够通用,可以在未来支持其他非 Java 语言。Linker
接口支持“下调用”(从 Java 代码到原生代码的调用)和“上调用”(从原生代码回到 Java 代码的调用)。
interface Linker {
MethodHandle downcallHandle(Addressable func,
FunctionDescriptor function);
MemorySegment upcallStub(MethodHandle target,
FunctionDescriptor function,
SegmentScope scope);
}
对于下调用,downcallHandle
方法接受一个外部函数的地址——通常是从库查找中获得的一个 MemorySegment
——并将外部函数暴露为一个“下调用方法句柄”。稍后,Java 代码通过调用其 invoke
(或 invokeExact
)方法来调用下调用方法句柄,外部函数就会运行。传递给方法句柄的 invoke
方法的任何参数都会被传递给外部函数。
对于上调用,upcallStub
方法接受一个方法句柄——通常是一个引用 Java 方法的句柄,而不是一个下调用方法句柄——并将其转换为一个 MemorySegment
实例。稍后,当 Java 代码调用下调用方法句柄时,内存段将作为一个参数传递。实际上,内存段充当一个函数指针。(关于上调用的更多信息,见 下面)。
假设我们希望从 Java 下调用标准 C 库中定义的 strlen
函数:
size_t strlen(const char *s);
客户端可以使用“原生链接器”(见 Linker::nativeLinker
)来链接 C 函数,这是一个符合 JVM 运行的操作系统和 CPU 所确定的 ABI 的 Linker
实现。可以如下获得一个暴露 strlen
的下调用方法句柄(FunctionDescriptor
的细节将在稍后描述):
Linker linker = Linker.nativeLinker();
MethodHandle strlen = linker.downcallHandle(
linker.defaultLookup().find("strlen").get(),
FunctionDescriptor.of(JAVA_LONG, ADDRESS)
);
调用下调用方法句柄将运行 strlen
并使它的结果在 Java 中可用。对于 strlen
的参数,我们使用一个辅助方法将一个 Java 字符串转换为一个堆外内存段(使用一个受限竞技场),然后通过引用传递:
try (Arena arena = Arena.openConfined()) {
MemorySegment str = arena.allocateUtf8String("Hello");
long len = 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 函数,它接受一个 C int
并返回一个 C long
。在 Linux/x64 和 macOS/x64 上,C 类型 long
和 int
分别与预定义布局 JAVA_LONG
和 JAVA_INT
相关联,所以所需的 FunctionDescriptor
可以通过 FunctionDescriptor.of(JAVA_LONG, JAVA_INT)
获得。然后原生链接器将安排下调用方法句柄的类型为 Java 签名 int
到 long
。
如果客户端目标是使用标量类型如 long
、int
和 size_t
的 C 函数,他们必须知道当前平台。这是因为标量 C 类型与布局常量的关联因平台而异。在 Windows/x64 上,一个 C long
与 JAVA_INT
布局相关联,所以所需的 FunctionDescriptor
将是 FunctionDescriptor.of(JAVA_INT, JAVA_INT)
,并且下调用方法句柄的类型将是 Java 签名 int
到 int
。
再举一个例子,假设一个下调用方法句柄应该暴露一个 void C 函数,它接受一个指针。在所有平台上,一个 C 指针类型与预定义布局 ADDRESS
相关联,所以所需的 FunctionDescriptor
可以通过 FunctionDescriptor.ofVoid(ADDRESS)
获得。然后原生链接器将安排下调用方法句柄的类型为 Java 签名 MemorySegment
到 void
。也就是说,一个 MemorySegment
参数可以通过引用或值传递,这取决于相应函数描述符中指定的布局。
客户端可以使用 C 指针而无需知道当前平台。客户端不需要知道当前平台上指针的大小,因为 ADDRESS
布局的大小是从当前平台推断出来的,客户端也不需要区分 C 指针类型如 int*
和 char**
。
最后,与 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)
获得。Linker
将安排下调用方法句柄的类型为 Java 签名 MemorySegment
到 void
。
与 C 结构体类型相关联的内存布局必须是一个复合布局,它定义了 C 结构体中所有字段的子布局,包括本地编译器可能插入的任何平台相关的填充。
如果一个 C 函数返回一个按值传递的结构体(这里未显示),那么必须在堆外分配一个新的内存段并返回给 Java 客户端。为了实现这一点,由 downcallHandle
返回的方法句柄需要一个额外的 SegmentAllocator
参数,FFM API 使用它来分配一个内存段来容纳 C 函数返回的结构体。
如前所述,虽然原生链接器实现专注于提供 Java 与 C 库之间的交互操作,但 Linker
接口是语言无关的:它对 C 类型如何定义没有特定的知识,所以客户端负责为 C 类型获得合适的布局定义。这个选择是有意的,因为 C 类型的布局定义——无论是简单的标量还是复杂的结构体——最终都是平台相关的,因此可以由一个对给定目标平台有深入了解的工具机械地生成。
为 C 函数打包 Java 参数
一个“调用约定”通过指定一种语言中的代码如何调用另一种语言中的函数、传递参数和接收结果来实现不同语言之间的交互操作。Linker
API 对调用约定是中立的,但原生链接器实现开箱即用支持几种调用约定:Linux/x64、Linux/AArch64、macOS/x64、macOS/AArch64 和 Windows/x64。由于是用 Java 编写的,它比 JNI 更容易维护和扩展,JNI 的调用约定硬编码在 HotSpot 的 C++ 代码中。
考虑上面为 SYSTEMTIME
结构体 / 布局获得的 FunctionDescriptor
。根据 JVM 运行的操作系统和 CPU 的调用约定,原生链接器使用 FunctionDescriptor
来推断当用一个 MemorySegment
参数调用下调用方法句柄时,结构体的字段应该如何传递给 C 函数。对于一种调用约定,原生链接器实现可以安排分解传入的内存段,使用通用 CPU 寄存器传递前四个字段,并在 C 栈上传递剩余的字段。对于不同的调用约定,原生链接器实现可以安排通过分配一个内存区域间接传递结构体,将传入的内存段的内容批量复制到那个区域,并将指向那个区域的指针传递给 C 函数。这种最低级别的参数打包在幕后发生,无需客户端代码的监督。
零长度内存段
外部函数经常分配一个内存区域并返回一个指向该区域的指针。用一个内存段来建模这样一个区域是有挑战性的,因为该区域的大小对 Java 运行时不可用。例如,一个返回类型为 char*
的 C 函数可能返回一个指向包含一个单个 char
值的区域的指针,或者指向一个包含以 '\0'
结尾的 char
值序列的区域的指针。该区域的大小对于调用外部函数的代码来说不是很明显。
FFM API 将从外部函数返回的指针表示为一个“零长度内存段”。该段的地址是指针的值,并且该段的大小为零。类似地,当客户端从一个内存段读取一个地址时,也会返回一个零长度内存段。
一个零长度段具有平凡的空间边界,所以任何尝试访问这样一个段的操作都会失败并抛出 IndexOutOfBoundsException
。这是一个关键的安全特性:由于这些段与一个大小未知的内存区域相关联,涉及这些段的访问操作无法被验证。实际上,一个零长度内存段包裹一个地址,并且在没有明确意图的情况下不能被使用。
客户端有两种方式访问原生零长度内存段,这两种方式都是不安全的:
- 客户端可以通过
MemorySegment::ofAddress
工厂将一个原始内存地址(例如一个long
值)作为一个指定大小的段进行包裹。这个工厂为一个原本的原始内存地址附加新的空间和时间边界,以便允许解引用操作。这个工厂返回的内存段是不安全的:一个原始内存地址可能与一个长度为 10 字节的内存区域相关联,但客户端可能高估该区域的大小并创建一个长度为 100 字节的内存段。稍后,这可能导致尝试解引用该区域边界之外的内存,这可能导致 JVM 崩溃或者——更糟糕的是——导致无声的内存损坏。 - 或者,客户端可以通过
ValueLayout.OfAddress::asUnbounded
方法获得一个“无界”的地址值布局。当一个访问操作使用一个无界地址值布局时,FFM API 将一个相应的原始内存地址视为一个最大大小的原生段(即java.lang.Long.MAX_VALUE
)。因此,这个原生段可以直接被访问。
因为这些访问原生零长度内存段的方式是不安全的,在程序中使用它们会导致 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
对一个数组的内容进行排序,compar
作为一个函数指针传递。因此,为了调用下调用方法句柄,我们需要一个函数指针作为方法句柄的 invokeExact
方法的最后一个参数传递。Linker::upcallStub
帮助我们通过使用现有的方法句柄来创建函数指针,如下所示。
首先,我们在 Java 中编写一个 static
方法,它比较两个 int
值,间接表示为 MemorySegment
对象:
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 对一个由 Java 方法实现的 C 函数的描述! */
FunctionDescriptor.of(JAVA_INT, ADDRESS.asUnbounded(), ADDRESS.asUnbounded()),
SegmentScope.auto());
);
我们最终有了一个内存段 comparFunc
,它指向一个可以用来调用我们的 Java 比较器函数的存根,所以现在我们有了调用 qsort
下调用句柄所需的一切:
try (Arena arena = Arena.openConfined()) {
MemorySegment array = arena.allocateArray(
ValueLayout.JAVA_INT,
new 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 内部,通过使用诸如 getStaticField
和 callVirtualMethod
这样的函数。它也可以在 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
方法时可能发生的相同类型的结果——VM 崩溃或未定义行为。FFM API 也可以产生不安全的段,即空间和时间边界由用户提供并且不能被 Java 运行时验证的内存段(见 MemorySegment::ofAddress
)。
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
中围绕 malloc
和 free
的包装器(即 allocateMemory
、setMemory
、copyMemory
和 freeMemory
)的一个极好的替代方案。我们希望需要堆外存储的库和应用程序采用 FFM API,以便随着时间的推移,我们可以弃用并最终移除这些 sun.misc.Unsafe
方法。
替代方案
继续使用 java.nio.ByteBuffer
、sun.misc.Unsafe
、JNI 和其他第三方框架。
风险和假设
创建一个既安全又高效的访问外部内存的 API 是一项艰巨的任务。由于前几节中描述的空间和时间检查需要在每次访问时进行,因此 JIT 编译器能够通过例如将这些检查提升到热循环之外来优化掉这些检查是至关重要的。JIT 实现可能需要一些工作来确保该 API 的使用与现有 API(如 ByteBuffer
和 Unsafe
)的使用一样高效且可优化。JIT 实现还需要工作来确保从该 API 检索的本地方法句柄的使用至少与现有 JNI 本地方法的使用一样高效且可优化。
依赖项
外部函数与内存 API 可以以更通用和高效的方式访问非易失性内存,这已经可以通过 JEP 352(非易失性映射字节缓冲区) 实现。
这里描述的工作可能会使后续的工作能够提供一个工具 jextract,该工具从给定的本地库的头文件开始,机械地生成与该库进行交互操作所需的本地方法句柄。这将进一步减少从 Java 使用本地库的开销。