JEP 424:外部函数和内存 API(预览版)
概括
引入一个 API,Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过有效地调用外部函数(即 JVM 外部的代码),并通过安全地访 问外部内存(即不由 JVM 管理的内存),API 使 Java 程序能够调用本机库并处理本机数据,而不会造成脆弱性和危险。 JNI。这是一个预览 API。
历史
外部函数和内存 (FFM) API 结合了两个早期的孵化 API :外部内存访问 API(JEP 370、383和393 )和外部链接器 API(JEP 389)。 FFM API 通过JEP 412在 JDK 17 中孵化,并通过JEP 419在 JDK 18 中重新孵化。该 JEP 根据 FFM API 作为孵化 API 期间的反馈对 FFM API 进行了改进。在 JDK 19 中,外部函数和内存 API 不再孵化;相反,它是一个预览 API。
目标
-
易于使用—用卓越的纯 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 运行时之外的代码和数据。
外来记忆
Java 运行时之外存储在内存中的数据称为_堆外_数据。 (_堆_是 Java 对象所在的位置——_堆上_数据——也是垃圾收集器工作的地方。)访问堆外数据对于流行 Java 库(如Tensorflow、Ignite、Lucene和Netty )的性能至关重要,主要是因为它可以让他们避免与垃圾收集相关的成本和不可预测性。它还允许通过例如将文件映射到内存来序列化和反序列化数据结构mmap
。然而,Java 平台并没有提供令人满意的访问堆外数据的解决方案。
-
该
ByteBuffer
API允许创建在堆外分配的_直接_字节缓冲区,但它们的最大大小为 2 GB,并且不会立即释放。这些和其他限制源于这样一个事实:APIByteBuffer
不仅设计用于堆外内存访问,还设计用于字符集编码/解码和部分 I/O 操作等领域的批量数据的生产者/消费者交换。在这种情况下,不可能满足多年来提出的堆外增强的许多请求(例如,4496703、6558368、4837564和5029431)。 -
该
sun.misc.Unsafe
API公开了堆内数据的内存访问操作,这些操作也适用于堆外数据。使用Unsafe
是高效的,因为它的内存访问操作被定义为 HotSpot JVM 内在函数并由 JIT 编译器优化。然而,使用Unsafe
是危险的,因为它允许访问任何内存位置。这意味着 Java 程序可以通过访问已释放的位置来使 JVM 崩溃;由于这个和其他原因,Unsafe
一直强烈反对使用. -
使用 JNI 调用本机库,然后访问堆外数据是可能的,但性能开销很少使其适用:从 Java 到本机比访问内存慢几个数量级,因为 JNI 方法调用不能从许多常见的方法中受益JIT 优化,例如内联。
总而言之,当涉及到访问堆外数据时,Java 开发人员面临着一个两难境地:他们应该选择安全但低效的路径(ByteBuffer
)还是应该放弃安全而追求性能(Unsafe
)?他们实际上需要的是一个受支持的 API,用于访问堆外数据(即外部内存),该 API 是从头开始设计的,旨在确保安全并考虑到 JIT 优化。
涉外职能
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
、MemoryAddress
、 和SegmentAllocator
), - 操作和访问结构化外部内存
(MemoryLayout
,VarHandle
), - 控制外部内存的分配和释放
(MemorySession
),并且 - 调用外部函数(
Linker
、FunctionDescriptor
和SymbolLookup
)。
FFM API 驻留在模块java.lang.foreign
的包中java.base
。
例子
作为使用 FFM API 的一个简短示例,下面是 Java 代码,它获取 C 库函数的方法句柄radixsort
,然后使用它对在 Java 数组中开始生命 的四个字符串进行排序(省略了一些细节)。
由于 FFM API 是预览 API,因此您必须在启用预览功能的情况下编译和运行代码,即javac --release 19 --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.lookup("radixsort"), ...);
// 2. Allocate on-heap memory to store four strings
String[] javaStrings = { "mouse", "cat", "dog", "car" };
// 3. Allocate off-heap memory to store four pointers
SegmentAllocator allocator = SegmentAllocator.implicitAllocator();
MemorySegment offHeap = allocator.allocateArray(ValueLayout.ADDRESS, javaStrings.length);
// 4. Copy the strings from on-heap to off-heap
for (int i = 0; i < javaStrings.length; i++) {
// Allocate a string off-heap, then store a pointer to it
MemorySegment cString = allocator.allocateUtf8String(javaStrings[i]);
offHeap.setAtIndex(ValueLayout.ADDRESS, i, cString);
}
// 5. Sort the off-heap data by calling the foreign function
radixSort.invoke(offHeap, javaStrings.length, MemoryAddress.NULL, '\0');
// 6. Copy the (reordered) strings from off-heap to on-heap
for (int i = 0; i < javaStrings.length; i++) {
MemoryAddress cStringPtr = offHeap.getAtIndex(ValueLayout.ADDRESS, i);
javaStrings[i] = cStringPtr.getUtf8String(0);
}
assert Arrays.equals(javaStrings, new String[] {"car", "cat", "dog", "mouse"}); // true
该代码比任何使用 JNI 的解决方案都清晰得多,因为原本隐藏在native
方法调用后面的隐式转换和内存取消引用现在直接用 Java 表达。也可以使用现代 Java 习惯用法;例如,流可以允许多个线程在堆内和堆外内存之间并行复制数据。
内存段
内存_段_是对位于堆外或堆上的连续内存区域进行建模的抽象。内存段可以是
- _本机_段,在本机内存中从头开始分配(例如,通过
malloc
), - _映射_段,围绕映射本机内存区域(例如,via
mmap
),或 - _数组_或_缓冲区_段,分别包裹在与现有 Java 数组或字节缓冲区关联的内存周围。
所有内存段都提供强有力的空间、时间和线程限制保证,使内存取消引用操作安全。例如,以下代码在堆外分配 100 字节:
MemorySegment segment = MemorySegment.allocateNative(100,
MemorySession.openImplicit());
段的空间边界_确定与该段关联的存储器地址的范围。上面代码中段的边界由表示为实例的_基地址 和以字节为单位的大小 (100) 定义,从而产生从到+ 99(含)的地址范围。b``MemoryAddress``b``b
段的时间范围_决定了段的生命周期,即段将被释放的时间。段的生命周期和线程限制状态由MemorySession
抽象建模,如下所述。上面代码中的内存会话是一个新的_隐式MemorySegment
会话,它确保当垃圾收集器认为对象无法访问时,与该段关联的内存被释放。隐式会话还确保内存段可从多个线程访问。
换句话说,上面的代码创建了一个段,其行为ByteBuffer
与allocateDirect
工厂分配的段非常匹配。 FFM API 还支持确定性内存释放和 其他线程限制选项,如下所述。
取消引用段
要取消引用内存段中的某些数据,我们需要考虑以下几个因素:
- 要取消引用的字节数,
- 发生取消引用的地址的对齐约束,
- 字节存储在所述存储区域中的字节顺序,以及
- 取消引用操作中要使用的 Java 类型(例如
int
vsfloat
)。
所有这些特征都在抽象中得到体现ValueLayout
。例如,预定义JAVA_INT
值布局是四个字节宽,在四字节边界上对齐,使用本机平台字节序(例如,Linux/x64 上的小字节序),并且与 Java 类型相关联int
。
内存段具有简单的取消引用方法来从内存段读取值或向内存段写入值。这些方法接受一个值布局,该布局唯一指定取消引用操作的属性。例如,我们可以int
使用以下代码在内存段中的连续偏移处写入 25 个值:
MemorySegment segment = MemorySegment.allocateNative(100,
MemorySession.openImplicit());
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];
使用上一节中显示的取消引用方法,要初始化这样的本机数组,我们必须编写以下代码:
MemorySegment segment = MemorySegment.allocateNative(2 * 4 * 10,
MemorySession.openImplicit());
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 = MemorySegment.allocateNative(ptsLayout,
MemorySession.openImplicit());
for (int i = 0; i < ptsLayout.elementCount().getAsLong(); 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
记忆会话
上面的所有示例都使用非确定性释放:在内存段实例 变得无法访问后,垃圾收集器将释放与已分配段关联的内存。我们说这些段被_隐式释放_。
在某些情况下,客户端可能希望控制何时发生内存释放。例如,假设一个大内存段是使用MemorySegment::map
.客户端可能更愿意在不再需要该段时立即释放(即取消映射)与该段关联的内存,而不是等待垃圾收集器这样做,因为等待可能会对应用程序的性能产生不利影响。
内存段通过_内存会话_支持确定性的释放。内存会话对一个或多个内存段的生命周期进行建模。新创建的内存会话处于_活动_状态,这意味着它管理的所有段都可以安全地访问。根据客户端的请求,可以_关闭_内存会话,以便不再允许访问该会话管理的段。该类MemorySession
实现了该AutoCloseable
接口,以便内存会话与try-with-resources语句一起使用:
try (MemorySession session = MemorySession.openConfined()) {
MemorySegment s1 = MemorySegment.map(Path.of("someFile"),
0, 100000,
MapMode.READ_WRITE, session);
MemorySegment s2 = MemorySegment.allocateNative(100, session);
...
} // both segments released here
此代码创建一个内存会话并使用它创建两个段:映射段 ( s1
) 和本机段 ( s2
)。这两个段的生命周期与内存会话的生命周期相关,因此在 try-with-resources 语句之外访问这些段(例如,使用内存访问 var 句柄取消引用它们)将导致抛出运行时异常。
除了管理内存段的生命周期之外,内存会话还控制哪些线程可以访问该段。_受限_内存会话限制对创建该会话的线程的访问,而_共享_内存会话允许从任何线程进行访问。
内存会话(无论是受限的还是共享的)都可以与执行隐式释放的对象关联,java.lang.ref.Cleaner
以防在会话仍处于活动状态时内存会话变得不可访问,从而防止意外的内存泄漏。
段分配器
当客户端使用堆外内存时,内存分配通常会成为瓶颈。因此,FFM API 包含一个SegmentAllocator
抽象,它定义了分配和初始化内存段的有用操作。段分配器是通过SegmentAllocator
接口中的工厂获得的。其中一个工厂返回_隐式分配器_,即分配由新隐式会话支持的本机段的分配器。还提供了其他更优化的分配器。例如,以下代码创建一个基于 arena 的分配器,并使用它来分配一个段,该段的内容是从 Javaint
数组初始化的:
try (MemorySession session = MemorySession.openConfined()) {
SegmentAllocator allocator = SegmentAllocator.newNativeArena(session);
for (int i = 0 ; i < 100 ; i++) {
MemorySegment s = allocator.allocateArray(JAVA_INT,
new int[] { 1, 2, 3, 4, 5 });
...
}
...
} // all memory allocated is released here
此代码创建一个受限内存会话,然后创建与该会话关联的_无界竞技场分配器_。该分配器分配一段内存并通过返回该预分配段的切片来响应分配请求。如果当前段没有足够的空间来容纳分配请求,则分配新段。for
当与arena分配器相关联的内存会话关闭时,与分配器创建的段相关联的所有内存(即,在循环体中)被原子地释放。该技术将抽象提供的确定性解除分配的优点MemorySession
与更灵活和可扩展的分配方案结合起来。在编写管理大量堆外段的代码时,它非常有用。
不安全的内存段
到目前为止,我们已经了解了内存段、内存地址和内存布局。解引用操作只能在内存段上进行。由于内存段具有空间和时间界限,因此 Java 运行时可确保安全地取消引用与给定段关联的内存。但是,在某些情况下,客户端可能只有一个MemoryAddress
实例,与 本机代码交互时经常出现这种情况。要取消引用内存地址,客户端有两种选择:
-
首先,客户端可以使用类中定义的取消引用方法之一
MemoryAddress
。这些方法是不安全的,因为内存地址没有空间或时间界限,因此 FFM API 无法确保取消引用的内存位置有效。 -
或者,客户端可以不安全地通过工厂将地址转换为段
MemorySegment::ofAddress
。该工厂将新的空间和时间边界附加到原始内存地址,以允许取消引用操作。此工厂返回的内存段是_不安全的:_原始内存地址可能与 10 字节长的内存区域关联,但客户端可能会意外高估该区域的大小并创建 100 字节长的不安全内存段。稍后,这可能会导致尝试取消引用与不安全段关联的内存区域边界之外的内存,这可能会导致 JVM 崩溃,或者更糟糕的是,导致无提示内存损坏。
这两个选项都是不安全的,因此_受到限制_,这意味着它们的使用会导致在运行时显示警告(请参阅下面的更多内容)。
查找外部函数
对外部函数的任何支持的第一个要素是一种在加载的本机库中查找给定符号的地址的机制。这种由对象表示的功能SymbolLookup
对于将 Java 代码链接到外部函数至关重要(见下文)。 FFM API 支持三种不同类型的符号查找对象:
-
SymbolLookup::libraryLookup(String, MemorySession)
创建一个_库查找_,它在用户指定的本机库中查找所有符号。创建查找对象会导致库被加载(例如,使用dlopen()
)并与MemorySession
对象关联。关闭该会话会导致库被卸载(例如,使用dlclose()
)。 -
SymbolLookup::loaderLookup()
创建一个_加载器查找_System::loadLibrary
,它使用和方法查找当前类加载器中的类已加载的所有本机库中的所有符号System::load
。 -
Linker::defaultLookup()
创建一个_默认查找_,该查找查找与实例关联的操作系统和处理器组合上常用的库中的所有符号Linker
。
给定符号查找,客户端可以使用该SymbolLookup::lookup(String)
方法找到外部函数。如果命名函数存在于符号查找所看到的符号中,则该方法返回一个零长度内存段,其基地址指向函数的入口点。例如,以下代码使用加载器查找来加载 OpenGL 库并查找其glGetString
函数的地址:
try (MemorySession session = MemorySession.openConfined()) {
SymbolLookup opengl = SymbolLookup.libraryLookup("libGL.so", session);
MemorySegment glVersion = opengl.lookup("glGetString").get();
...
} // libGL.so unloaded here
SymbolLookup::libraryLookup(String, MemorySession)
与 JNI 的库加载机制(即System::loadLibrary
)有一个重要的区别。设计用于使用 JNI 的本机库可以使用 JNI 函数来执行 Java 操作,例如对象分配或方法访问,这可能会触发类加载。因此,此类 JNI 附属库在被 JVM 加载时必须与类加载器关联。然后,为了保持类加载器的完整性,不能从不同类加载器中定义的类加载相同的 JNI 附属库。相比之下,FFM API 不提供本机代码访问 Java 环境的功能,并且不假设本机库被设计为与 FFM API 一起使用。通过加载的本机库SymbolLookup::libraryLookup(String, MemorySession)
不知道它们是从 JVM 中运行的代码访问的,并且不尝试执行 Java 操作。因此,它们不依赖于特定的类加载器,并且可以由不同加载器中的 FFM API 客户端根据需要多次(重新)加载。