JEP 442:外部函数和内存 API(第三次预览版)
概括
引入一个 API,Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过有效地调用外部函数(即 JVM 外部的代码),并通过安全地访问外部内存(即不由 JVM 管理的内存),API 使 Java 程序能够调用本机库并处理本机数据,而不会造成脆弱性和危险。 JNI。这是一个预览 API。
历史
外部函数和内存 (FFM) API 首先通过JEP 424在 JDK 19 中预览,然后通过JEP 434在 JDK 20 中再次预览。此 JEP 提出了第三个预览,以纳入基于反馈的改进。在此版本中,我们有:
- 集中管理接口中原生段的生命周期
Arena
; - 使用新元素增强布局路径以取消引用地址布局;
- 提供了一个链接器选项来优化对短暂且不会向上调用 Java 的函数的调用(例如,
clock_gettime
); - 提供了基于 的后备原生链接器实现,
libffi
以方便移植;和 - 删除了
VaList
类。
目标
-
易于使用—用卓越的纯 Java 开发模型替换 Java 本机接口 ( JNI )。
-
性能——提供与现有 API(如 JNI 和
sun.misc.Unsafe
. -
通用性——提供在不同类型的外部内存(例如,本机内存、持久内存和托管堆内存)上进行操作的方法,并随着时间的推移,适应其他平台(例如,32 位 x86)和用其他语言编写的外部函数优于 C(例如 C++、Fortran)。
-
安全- 允许程序对外部内存执行不安全的操作,但默认警告用户此类操作。
非目标
这不是一个目标
- 在此 API 之上重新实现 JNI,或以任何方式更改 JNI;
sun.misc.Unsafe
在此 API 之上重新实现旧版 Java API,例如;- 提供从本机代码头文件机械生成 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:
-
API
ByteBuffer
提供_直接_字节缓冲区,它们是由固定大小的堆外内存区域支持的 Java 对象。然而,区域的最大大小限制为 2 GB,并且读写内存的方法很初级且容易出错,仅提供对原始值的索引访问。更严重的是,仅当缓冲区对象被垃圾收集时,支持直接字节缓冲区的内存才会被释放,这是开发人员无法控制的。由于缺乏对及时释放的支持,使得该ByteBuffer
API 不适合 Java 系统编程。 -
该
sun.misc.Unsafe
API提供对堆内内存的低级访问,也适用于堆外内存。使用Unsafe
速度很快(因为它的内存访问操作是由 JVM 内在化的),允许巨大的堆外区域(理论上最多 16 艾字节),并提供对释放的细粒度控制(因为Unsafe::freeMemory
可以随时调用)。然而,这种编程模型很弱,因为它给了开发人员太多的控制权。随着时间的推移,长时间运行的服务器应用程序中的库将分配堆外内存的多个区域并与之交互;一个区域中的数据将指向另一区域中的数据,并且必须以正确的顺序释放区域,否则悬空指针将导致释放后使用错误。由于缺乏对安全释放的支持,该Unsafe
API 不太适合 Java 中的系统编程。malloc
(同样的批评也适用于 JDK 之外的 API,它们通过包装调用和的本机代码来提供细粒度的分配和释放free
。)
总之,成熟的客户端需要一个能够分配、操作和共享堆外内存的 API,并且具有与堆内内存相同的流动性和安全性。这样的 API 应该平衡可预测的释放的需求和防止不合时宜的释放的需要,否则可能导致 JVM 崩溃,或者更糟糕的是,导致静默内存损坏。
涉外职能
JNI从Java 1.1开始就支持本地代码(即外部函数)的调用,但由于多种原因它还不够。
-
JNI 涉及几个繁琐的工件:Java API(
native
方法)、从 Java API 派生的 C 头文件以及调用感兴趣的本机库的 C 实现。 Java 开发人员必须跨多个工具链工作,以保持平台相关的工件同步,当本机库快速发展时,这尤其繁重。 -
JNI 只能与用语言(通常是 C 和 C++)编写的库进行互操作,这些语言使用构建 JVM 的操作系统和 CPU 的调用约定。方法
native
不能用于调用使用不同约定的语言编写的函数。 -
JNI 不协调 Java 类型系 统与 C 类型系统。 Java 中的聚合数据用对象表示,但 C 中的聚合数据用结构表示,因此传递给
native
方法的任何 Java 对象都必须由本机代码费力地解包。例如,考虑Person
Java 中的记录类:将Person
对象传递给native
方法将要求本机代码使用 JNI 的 C API从对象中提取字段(例如firstName
和)。lastName
因此,Java 开发人员有时会将数据扁平化为单个对象(例如,字节数组或直接字节缓冲区),但更常见的是,由于通过 JNI 传递 Java 对象很慢,他们使用 APIUnsafe
来分配堆外内存并将其地址native
作为 a 传递给方法long
— 这使得 Java 代码非常不安全!
多年来,出现了许多框架来填补 JNI 留下的空白,包括JNA、JNR和JavaCPP。这些框架通常比 JNI 有了显着的改进,但情况仍然不太理想——尤其是与提供一流本机互操作的语言相比。例如,Python 的ctypes包可以动态地将函数包装在本机库中,而无需任何粘合代码。其他语言(例如Rust)提供了从 C/C++ 头文件机械派生本机包装器的工具。
最终,Java 开发人员应该拥有一个受支持的 API,让他们可以直接使用任何被认为对特定任务有用的本机库,而无需使用 JNI 的繁琐粘合和笨拙。_方法句柄_是一个优秀的构建抽象,它是在 Java 7 中引入的,用于支持 JVM 上的 快速动态语言。通过方法句柄公开本机代码将从根本上简化依赖本机库的编写、构建和分发 Java 库的任务。此外,能够对外部函数(即本机代码)和外部内存(即堆外数据)进行建模的 API 将为第三方本机互操作框架提供坚实的基础。
描述
外部函数和内存 API (FFM API) 定义类和接口,以便库和应用程序中的客户端代码可以
- 控制外部内存的分配和释放
(MemorySegment
、Arena
、 和SegmentAllocator
), - 操作和访问结构化外部内存
(MemoryLayout
和VarHandle
),以及 - 调用外部函数(
Linker
、FunctionDescriptor
和SymbolLookup
)。
FFM API 驻留在模块java.lang.foreign
的包中java.base
。
例子
作为使用 FFM API 的一个简短示例,下面是 Java 代码,它获取 C 库函数的方法句柄radixsort
,然后使用它对在 Java 数组中开始生命的四个字符串进行排序(省略了一些细节)。
由于 FFM API 是预览 API,因此您必须在启用预览功能的情况下编译和运行代码,即javac --release 21 --enable-preview ...
和java --enable-preview ...
。
// 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.allocateArray(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.allocateUtf8String(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.getUtf8String(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
), - 一个_映射的_段,包裹在映射的堆外内存区域周围(就像 via
mmap
),或者 - 数组或_缓冲区段,分别包裹在与现有 Java 数组或字节缓冲区关联__的_堆上内存区域。
所有内存段都提供空间和时间界限,确保内存访问操作的安全。简而言之,边界保证不使用未分配的内存,并且释放后不使用。
段的空间边界_确定与该段关联的存储器地址的范围。例如,下面的代码分配了 100 字节的本机段,因此关联的地址范围是从某个_基地址 b
到b + 99
包含在内的地址范围。
MemorySegment data = Arena.global().allocate(100);
_段的时间范围_决定了它的生命周期,即支持该段的内存区域被释放之前的时间段。 FFM API 保证内存段在其后备内存区域被释放后无法被访问。
段的时间边界由用于分配该段的区域确定。在同一 arena 中分配的多个段具有相同的时间范围,并且可以安全地包含相互引用: 段A
可以保存指向 段 中地址的指针B
,段B
可以保存指向 段 中地址的指针A
,并且两个段都将在同时,这样两个段都没有悬空指针。
最简单的竞技场是_全局_竞技场,它提供无限的生命周期:它总是活着的。如上面的代码所示,在全局区域中分配的段始终是可访问的,并且支持该段的内存区域永远不会被释放。
然而,大多数程序需要在程序运行时释放堆外内存,因此需要具有有限生命周期的内存段。
自动竞技场提供有界的生命周期:可以访问由_自动_竞技场分配的段,直到 JVM 的垃圾收集器检测到该内存段不可访问,此时支持该段的内存区域将被释放。例如,此方法在自动区域中分配一个段:
void processData() {
MemorySegment data = Arena.ofAuto().allocateNative(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 应允许客户端确定性地释放支持该段的内存区域,因为等待垃圾收集器这样做可能会对性能产生不利影响。
_受限的_arena提供了有界且确定的生命周期:从客户端打开 arena 到客户端_关闭_arena为止,它一直处于活动状态。在受限区域中分配的内存段只能在区域关闭之前访问,此时支持该段的内存区域将被释放。在竞技场关闭后尝试访问内存段将失败并出现异常。例如,此代码打开一个arena并使用arena分配两个段:
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 分配的所有段都将自动失效,并且支持这些段的内存区域将被释放。
受限区域的确定性生命周期是有代价的:只有一个线程可以访问在受限区域中分配的内存段。如果多个线程需要访问一个段,则可以使用_共享_区域。在共享区域中分配的内存段可以由多个线程访问,并且任何线程(无论是否访问该区域)都可以关闭该区域以释放这些段。尽管由于需要昂贵的同步操作来检测和取消段上挂起的并发访问操作,所以支持段的内存区域的重新分配可能不会立即发生,但以原子方式关闭竞技场会使段无效。
总之,arena 控制哪些线程可以访问内存段以及何时访问,以便提供强大的时间安全性和可预测的性能模型。 FFM API 提供了多种领域的选择,以便客户端可以在访问的广 度和释放的及时性之间进行权衡。
取消引用段
要取消引用内存段中的某些数据,我们需要考虑几个因素:
- 要取消引用的字节数,
- 发生取消引用的地址的对齐约束,
- 字节存储在内存段中的字节顺序,以及
- 取消引用操作中要使用的 Java 类型(例如
int
vsfloat
)。
所有这些特征都在抽象中得到体现ValueLayout
。例如,预定义JAVA_INT
值布局是四个字节宽,在四字节边界上对齐,使用本机平台字节序(例如,Linux/x64 上的小字节序),并且与 Java 类型相关联int
。
内存段具有简单的取消引用方法来从内存段读取值或向内存段写入值。这些方法接受一个值布局,该布局唯一指定取消引用操作的属性。例如,我们可以int
在内存段的连续偏移处写入 25 个值:
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
结构体都有两个成员,即Point.x
和Point.y
:
struct Point {
int x;
int y;
} pts[10];
使用上一节中显示的取消引用方法,要初始化这样的本机数组,我们必须编写以下代码(我们假设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
可以使用 a 以更具声明性的方式描述内存段的内容。例如,上面示例中本机内存段的所需布局可以通过以下方式描述:
SequenceLayout ptsLayout
= MemoryLayout.sequenceLayout(10,
MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("x"),
ValueLayout.JAVA_INT.withName("y")));
这将创建一个_序列内存布局,其中包含十个重复的_结构布局,其元素分别是JAVA_INT
名为x
和 的两个布局y
。给定这种布局,我们可以通过创建两个_内存访问 var 句柄_(特殊的var 句柄)来避免计算代码中的偏移量,它们接受一个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 = Arena.ofAuto().allocate(ptsLayout);
for (int i = 0; i < ptsLayout.elementCount(); i++) {
xHandle.set(segment,
/* index */ (long) i,
/* value to write */ i); // x
yHandle.set(segment,
/* index */ (long) i,
/* value to write */ i); // y
}
该对象通过创建_布局路径_ptsLayout
来驱动内存访问变量句柄的创建,该布局路径用于从复杂的布局表达式中选择嵌套布局。由于选定的值布局与 Java 类型相关联,因此生成的 var 句柄的类型也将是。此外,由于所选值布局是在序列布局内定义的,因此 var 句柄获取类型 的额外坐标,即要读取或写入其坐标的结构体的索引。该对象还驱动本机内存段的分配,该分配基于从布局导出的大小和对齐信息。循环内不再需要偏移计算,因为使用不同的 var 句柄来初始化和元素。int``xHandle``yHandle``int``long``Point``ptsLayout``Point.x``Point.y
段分配器
当客户端使用堆外内存时,内存分配通常是一个瓶颈。因此,FFM API 包含一个SegmentAllocator
抽象来定义分配和初始化内存段的操作。为了方便起见,该类Arena
实现了该SegmentAllocator
接口,以便可以使用 arena 来分配本机段。换句话说,Arena
是一个“一站式商店”,用于灵活分配和及时释放堆外内存:
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);
...
} // memory released here
段分配器也可以通过接口中的工厂获得SegmentAllocator
。例如,一个工厂创建了一个切片分配器,它通过返回属于先前分配的段的一部分的内存段来响应分配请求;因此,无需物理分配更多内存即可满足许多请求。以下代码在现有段上获取切片分配器,然后使用它来分配从 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);
...
}
段分配器可以用作构建块来创建支持自定义分配策略的竞技场。例如,如果大量本机段将共享相同的有界生命周期,则自定义 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.allocateArray(JAVA_INT,
1, 2, 3, 4, 5);
...
}
} // all memory allocated is released here
查找外部函数
对外部函数的任何支持的第一个要素是一种在加载的本机库中查找给定符号的地址的机制。这种由对象表示的功能SymbolLookup
对于将 Java 代码链接到外部函数至关重要(见下文)。 FFM API 支持三种不同类型的符号查找对象:
-
SymbolLookup::libraryLookup(String, Arena)
创建一个_库查找_,它在用户指定的本机库中查找所有符号。创建查找对象会导致库被加载(例如,使用dlopen()
)并与Arena
对象关联。dlclose()
当提供的 arena 关闭时,库被卸载(例如,使用)。 -
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 操作,例如对象分配或方法访问,这可能会触发类加载。因此,此类 JNI 附属库在被 JVM 加载时必须与类加载器关联。然后,为了保持类加载器的完整性,不能从不同类加载器中定义的类加载相同的 JNI 附属库。
相比之下,FFM API 不提供本机代码访问 Java 环境的功能,并且不假设本机库被设计为与 FFM API 一起使用。通过加载的本机库SymbolLookup::libraryLookup(String, Arena)
不知道它们是从 JVM 中运行的代码访问的,并且不尝试执行 Java 操作。因此,它们不依赖于特定的类加载器,并且可以由不同加载器中的 FFM API 客户端根据需要多次(重新)加载。