JEP 419:外部函数和内存 API(第二个孵化器)
概括
引入一个 API,Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过有效地调用外部函数(即 JVM 外部的代码),并通过安全地访问外部内存(即不由 JVM 管理的内存),API 使 Java 程序能够调用本机库并处理本机数据,而不会造成脆弱性和危险。 JNI。
历史
外部函数和内存 API 由JEP 412提出,目标是在 2021 年中期作为 Java 17 的孵化 API。它结合了两个早期孵化的 API:外部内存访问 API 和外部链接器 API。此 JEP 建议根据反馈进行改进,并在 Java 18 中重新孵化 API。此次更新包含以下更改:
- 在内存访问var句柄中支持更多的载体,例如
boolean
和MemoryAddress
; - 更通用的取消引用 API,可在
MemorySegment
和MemoryAddress
接口中使用; - 一个更简单的 API,用于获取向下调用方法句柄,
MethodType
不再需要传递参数; - 更简单的 API 来管理资源范围之间的时间依赖性;和
- 用于将 Java 数组复制到内存段或从内存段复制 Java 数组的新 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
), - 管理外部资源的生命周期 (
ResourceScope
),以及 - 调用外部函数(
SymbolLookup
、CLinker
和NativeSymbol
)。
FFM API 驻留在模块jdk.incubator.foreign
的包中jdk.incubator.foreign
。
例子
作为使用 FFM API 的一个简短示例,下面是 Java 代码,它获取 C 库函数的方法句柄radixsort
,然后使用它对在 Java 数组中开始生命的四个字符串进行排序(省略了一些细节):
// 1. Find foreign function on the C library path
CLinker linker = CLinker.getInstance();
MethodHandle radixSort = linker.downcallHandle(
linker.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
MemorySegment offHeap = MemorySegment.allocateNative(
MemoryLayout.ofSequence(javaStrings.length,
ValueLayout.ADDRESS), ...);
// 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 = implicitAllocator().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,
newImplicitScope());
段的空间边界_确定与该段关联的存储器地址的范围。上面代码中段的边界由表示为实例的_基地址 和以字节为单位的大小 (100) 定义,从而产生从到+ 99(含)的地址范围。b``MemoryAddress``b``b
段的时间范围_决定了段的生命周期,即段将被释放的时间。段的生命周期和线程限制状态由ResourceScope
抽象建模,如下所述。上面代码中的资源作用域是一个新的_共享MemorySegment
作用域,它确保当垃圾收集器认为该对象无法访问时,与该段关联的内存被释放。共享作用域还确保内存段可从多个线程访问。
换句话说,上面的代码创建了一个段,其行为ByteBuffer
与allocateDirect
工厂分配的段非常匹配。 FFM API 还支持确定性内存释放和其他线程限制选项,如下所述。
取消引用段
要取消引用内存段中的某些数据,我们需要考虑以下几个因素:
- 要取消引用的字节数,
- 发生取消引用的地址的对齐约束,
- 字节存储在所述存储区域中的字节顺序,以及
- 取消引用操作中使用的 Java 类型(例如
int
vsfloat
)。
所有这些特征都在抽象中得到体现ValueLayout
。例如,预定义JAVA_INT
值布局是四个字节宽,没有对齐约束,使用本机平台字节序(例如,Linux/x64 上的小字节序),并且与 Java 类型相关联int
。
内存段具有简单的取消引用方法来从内存段读取值或向内存段写入值。这些方法接受一个值布局,该布局唯一指定取消引用操作的属性。例如,我们可以int
使用以下代码在内存段中的连续偏移处写入 25 个值:
MemorySegment segment = MemorySegment.allocateNative(100,
newImplicitScope());
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,
newImplicitScope());
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,
newImplicitScope());
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
.客户端可能更愿意在不再需要该段时立即释放(即取消映射)与该段关联的内存,而不是等待垃圾收集器这样做,因为等待可能会对应用程序的性能产生不利影响。
内存段支持通过_资源范围_进行确定性释放。资源范围对一个或多个_资源_(例如内存段)的生命周期进行建模。新创建的资源作用域处于_活动_状态,这意味着它管理的所有资源都可以安全地访问。根据客户端的请求,可以_关闭_资源范围,以便不再允许访问该范围管理的资源。该类ResourceScope
实现该AutoCloseable
接口,以便资源范围与try-with-resources语句一起使用:
try (ResourceScope scope = ResourceScope.newConfinedScope()) {
MemorySegment s1 = MemorySegment.map(Path.of("someFile"),
0, 100000,
MapMode.READ_WRITE, scope);
MemorySegment s2 = MemorySegment.allocateNative(100, scope);
...
} // both segments released here
此代码创建一个资源范围并使用它创建两个段:映射段 ( s1
) 和本机段 ( s2
)。这两个段的生命周期与资源范围的生命周期相关,因此在 try-with-resources 语句之外访问这些段(例如,使用内存访问 var 句柄取消引用它们)将导致抛出运行时异常。
除了管理内存段的生命周期之外,资源作用域还控制哪些线程可以访问该段。_受限_资源作用域限制对创建该作用域的线程的访问,而_共享_资源作用域允许从任何线程进行访问。
资源作用域(无论是受限的还是共享的)都可以与执行隐式释放的对象关联,java.lang.ref.Cleaner
以防资源作用域在作用域仍然存在时变得不可访问,从而防止意外的内存泄漏。
段分配器
当客户端使用堆外内存时,内存分配通常会成为瓶颈。因此,FFM API 包含一个SegmentAllocator
抽象,它定义了分配和初始化内存段的有用操作。段分配器是通过SegmentAllocator
接口中的工厂获得的。一个这样的工厂返回_隐式分配器_,即分配由新的隐式作用域支持的本机段的分配器。还提供了其他更优化的分配器。例如,以下代码创建一个基于 arena 的分配器,并使用它来分配一个段,该段的内容是从 Javaint
数组初始化的:
try (ResourceScope scope = ResourceScope.newConfinedScope()) {
SegmentAllocator allocator = SegmentAllocator.newNativeArena(scope);
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 分配器关联的资源范围关闭时,与分配器创建的段关联的所有内存(即,在循环体中)将被自动释放。该技术将抽象提供的确定性解除分配的优点ResourceScope
与更灵活和可扩展的分配方案结合起来。在编写管理大量堆外段的代码时,它非常有用。
不安全的内存段
到目前为止,我们已经了解了内存段、内存地址和内存布局。解引用操作只能在内存段上进行。由于内存段具有空间和时间界限,因此 Java 运行时可确保安全地取消引用与给定段关联的内存。但是,在某些情况下,客户端可能只有一个MemoryAddress
实例,与本机代码交互时经常出现这种情况。要取消引用内存地址,客户端有两种选择:
-
首先,客户端可以使用类中定义的取消引用方法之一
MemoryAddress
。这些方法是不安全的,因为内存地址没有空间或时间界限,因此 FFM API 无法确保取消引用的内存位置有效。 -
或者,客户端可以不安全地通过工厂将地址转换为段
MemorySegment::ofAddressNative
。该工厂将新的空间和时间边界附加到原始内存地址,以允许取消引用操作。此工厂返回的内存段是_不安全的:_原始内存地址可能与 10 字节长的内存区域关联,但客户端可能会意外高估该区域的大小并创建 100 字节长的不安全内存段。稍后,这可能会导致尝试取消引用与不安全段关联的内存区域边界之外的内存,这可能会导致 JVM 崩溃,或者更糟糕的是,导致无提示内存损坏。
这两个选项都是不安全的,因此被视为_受限操作_,默认情况下禁用(请参阅下面的更多内容)。
查找外部函数
对外部函数的任何支持的第一个要素是加载本机库的机制。 JNI 使用System::loadLibrary
和System::load
方法来完成此操作,这些方法在内部映射到对dlopen
或其等效项的调用。使用这些方法加载的库总是与类加载器相关联,即调用该方法的类的加载器。库和类加载器之间的关联至关重要,因为它控制着加载的库的生命周期:只有当类加载器不再可访问时,它的所有库才能被安全卸载。
FFM API 不提供加载本机库的新方法。开发人员使用System::loadLibrary
和System::load
方法加载要通过 FFM API 调用的本机库。库和类加载器之间的关联被保留,因此库将以与 JNI 相同的可预测方式卸载。
与 JNI 不同,FFM API 提供了在加载的库中查找给定符号的地址的功能。这种由对象表示的功能SymbolLookup
对于将 Java 代码链接到外部函数至关重要(见下文)。获取对象有两种方式SymbolLookup
:
-
通过调用
SymbolLookup::loaderLookup
,它返回一个符号查找,该查找查找当前类加载器加载的所有库中的所有符号,或者 -
通过获取一个
CLinker
实例,该实例实现了SymbolLookup
接口并可用于在标准 C 库中查找特定于平台的符号。
给定符号查找,客户端可以使用该SymbolLookup::lookup(String)
方法找到外部函数。如果命名函数存在于符号查找所看到的符号中,则该方法返回NativeSymbol
指向函数入口点的 a 。例如,以下代码加载OpenGL库,使其与当前类加载器关联,并找到其glGetString
函数的地址:
System.loadLibrary("GL");
SymbolLookup loaderLookup = SymbolLookup.loaderLookup();
NativeSymbol clangVersion = loaderLookup.lookup("glGetString").get();
将 Java 代码链接到外部函数
接口CLinker
是 Java 代码与本机代码交互操作的核心。虽然CLinker
重点是提供 Java 和 C 库之间的互操作,但接口中的概念足够通用,足以支持将来的其他非 Java 语言。该接口支持_向下调用_(从 Java 代码调用本机代码)和_向上调用_(从本机代码调用回 Java 代码)。
interface CLinker {
MethodHandle downcallHandle(NativeSymbol func,
FunctionDescriptor function);
NativeSymbol upcallStub(MethodHandle target,
FunctionDescriptor function,
ResourceScope scope);
}
对于向下调用,该downcallHandle
方法获取外部函数的地址(通常是NativeSymbol
从库查找中获得的地址)并将外部函数公开为_向下调用方法句柄_。随后,Java 代码通过调用其invoke
(或) 方法来调用 downcall 方法句柄invokeExact
,并且外部函数将运行。传递给方法句柄的invoke
方法的任何参数都将传递给外部函数。
对于向上调用,该upcallStub
方法采用一个方法句柄(通常是指 Java 方法,而不是向下调用方法句柄)并将其转换为实例NativeSymbol
。稍后,当 Java 代码调用向下调用方法句柄时,本机符号将作为参数传递。实际上,本机符号充当函数指针。 (有关上行调用的更多信息,请参阅下文。)
假设我们希望从 Java 向下调用strlen
标准 C 库中定义的函数:
size_t strlen(const char *s);
strlen
可以如下获取公开的向下调用方法句柄(FunctionDescriptor
稍后将详细描述):
CLinker linker = CLinker.systemCLinker();
MethodHandle strlen = linker.downcallHandle(
linker.lookup("strlen").get(),
FunctionDescriptor.of(JAVA_LONG, ADDRESS)
);
调用 downcall 方法句柄将运行strlen
并使其结果在 Java 中可用。对于 的参数strlen
,我们使用辅助方法将 Java 字符串转换为堆外内存段(使用隐式分配器),然后通过引用传递:
MemorySegment str = implicitAllocator().allocateUtf8String("Hello");
long len = strlen.invoke(cString); // 5
方法句柄非常适合公开外部函数,因为 JVM 已经优化了方法句柄的调用,一直到本机代码。当方法句柄引用文件中的方法时class
,调用方法句柄通常会导致目标方法被 JIT 编译;随后,JVMMethodHandle::invokeExact
通过将控制权转移到为目标方法生成的汇编代码来解释调用的 Java 字节码。因此,Java 中的传统方法句柄在幕后针对非 Java 代码;向下调用方法句柄是一种自然的扩展,它允许开发人员显式地瞄准非 Java 代码。方法句柄还具有称为签名多态性的属性,它允许使用原始参数进行无框调用。总之,方法句柄让CLinker
外部函数以自然、高效且可扩展的方式公开。
在 Java 中描述 C 类型
要创建向下调用方法句柄,FFM API 要求客户端提供FunctionDescriptor
描述目标 C 函数的 C 参数类型和 C 返回类型的句柄。 C 类型在 FFM API 中通过MemoryLayout
对象进行描述,例如ValueLayout
标量 C 类型和GroupLayout
C 结构类型。客户端通常MemoryLayout
手头有对象来取消引用外部内存中的数据,并且可以重用它们来获取FunctionDescriptor
.
FFM API 还使用 来FunctionDescriptor
派生向下调用方法句柄的类型。每个方法句柄都是强类型的,这意味着它对invokeExact
运行时可以传递给其方法的参数的数量和类型很严格 。例如,为获取一个MemoryAddress
参数而创建的方法句柄不能通过 调用invokeExact(<MemoryAddress>, <MemoryAddress>)
,即使invokeExact
它是一种 varargs 方法。向下调用方法句柄的类型描述了客户端在调用向下调用方法句柄时必须使用的 Java 签名。实际上,它是 C 函数的 Java 视图。
例如,假设向下调用方法句柄应公开一个 C 函数,该函数接受 Cint
并返回 C long
。在 Linux/x64 和 macOS/x64 上,C 类型long
和分别与预定义布局和int
关联,因此可以使用.然后,将安排向下调用方法句柄的类型作为的Java 签名。JAVA_LONG``JAVA_INT``FunctionDescriptor``FunctionDescriptor.of(JAVA_LONG, JAVA_INT)``CLinker``int``long
long
如果客户端以使用标量类型(例如、int
和size_t
)的 C 函数为目标,则必须了解当前平台。这是因为标量 C 类型与布局常量的关联因平台而异。在 Windows/x64 上,C long 与JAVA_INT
布局相关联,因此所需的FunctionDescriptor
将是FunctionDescriptor.of(JAVA_INT, JAVA_INT)
并且向下调用方法句柄的类型将int
是int
.
作为另一个示例,假设向下调用方法句柄应该公开一个采用指针的 void C 函数。在所有平台上,C 指针类型与预定义布局相关联,因此可以使用 获得ADDRESS
所需的布局。然后,将安排向下调用方法句柄的类型作为的Java 签名。是 FFM API 中实体的常见超类型,可以通过引用传递,例如、和。FunctionDescriptor``FunctionDescriptor.ofVoid(ADDRESS)``CLinker``Addressable``void``Addressable``MemorySegment``MemoryAddress``NativeSymbol
客户端可以在不知道当前平台的情况下使用 C 指针。客户端不需要知道当前平台上指针的大小,因为布局 的大小ADDRESS
是从当前平台推断的,客户端也不需要区分 C 指针类型,例如int*
和char**
。
最后,与 JNI 不同的是,它CLinker
支持将结构化数据传递给外部函数。假设向下调用方法句柄应该公开一个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)
。将CLinker
安排向下调用方法句柄的类型作为MemorySegment
的Java 签名void
。
与 C 结构类型关联的内存布局必须是复合布局,它定义 C 结构中所有字段的子布局,包括本机编译器可能插入的任何与平台相关的填充。
如果 C 函数返回按值结构(此处未显示),则必须在堆外分配新的内存段并将其返回到 Java 客户端。为了实现这一点,返回的方法句柄downcallHandle
需要一个额外的SegmentAllocator
参数,FFM API 使用该参数来分配内存段来保存 C 函数返回的结构。
如前所述,它的CLinker
重点是提供 Java 和 C 库之间的互操作,但它是语言中立的:它没有关于如何定义 C 类型的具体知识,因此客户端负责获取 C 类型的合适布局定义。这种选择是经过深思熟虑的,因为 C 类型的布局定义(无论是简单标量还是复杂结构)最终都是依赖于平台的,因此可以由对给定目标平台有深入了解的工具自动生成。
为 C 函数打包 Java 参数
_调用约定_通过指定一种语言中的代码如何调用另一种语言中的函数、传递参数和接收结果来实现不同语言之间的互操作。该CLinker
API 在调用约定方面是中立的,但该CLinker
实现实现了 几种开箱即用的调用约定:Linux/x64、Linux/AArch64、macOS/x64 和 Windows/x64。由于用 Java 编写,它比 JNI 更容易维护和扩展,JNI 的调用约定被硬连线到 HotSpot 的 C++ 代码中。
考虑FunctionDescriptor
上面获得的SYSTEMTIME
结构/布局。给定运行 JVM 的操作系统和 CPU 的调用约定,当使用参数调用向下调用方法句柄时CLinker
,使用FunctionDescriptor
来推断结构体的字段应如何传递给 C 函数MemorySegment
。对于一种调用约定,CLinker
可以安排分解传入的内存段,使用通用 CPU 寄存器传递前四个字段,并在 C 堆栈上传递其余字段。对于不同的调用约定,CLinker
可以安排通过分配内存区域、将传入内存段的内容批量复制到该区域并将指向该内存区域的指针传递给 C 函数来间接传递结构。这种最低级别的参数打包发生在幕后,不需要客户端代码的监督。
上行呼叫
有时,将 Java 代码作为函数指针传递给某个外部函数很有用。我们可以通过使用CLinker
对上行调用的支持来做到这一点。在本节中,我们将逐步构建一个更复杂的示例,该示例展示了 的全部功能CLinker
,以及跨 Java/本机边界的代码和数据的完全双向互操作。
考虑标准 C 库中定义的以下函数:
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
要从 Java调用qsort
,我们首先需要创建一个向下调用方法句柄:
CLinker linker = CLinker.systemCLinker();
MethodHandle qsort = linker.downcallHandle(
linker.lookup("qsort").get(),
FunctionDescriptor.ofVoid(ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS)
);
和之前一样,我们使用JAVA_LONG
布局来映射 Csize_t
类型,并且我们ADDRESS
对第一个指针参数(数组指针)和最后一个参数(函数指针)都使用布局。
qsort``compar
使用作为函数指针传递的自定义比较器函数对数组的内容进行排序。因此,要调用向下调用方法句柄,我们需要一个函数指针作为最后一个参数传递给方法句柄的invokeExact
方法。CLinker::upcallStub
帮助我们使用现有的方法句柄创建函数指针,如下所示。
首先,我们static
用 Java 编写一个方法来比较两个long
值,间接表示为MemoryAddress
对象:
class Qsort {
static int qsortCompare(MemoryAddress addr1, MemoryAddress addr2) {
return addr1.get(JAVA_INT, 0) - addr2.get(JAVA_INT, 0);
}
}
其次,我们创建一个指向 Java 比较器方法的方法句柄:
MethodHandle comparHandle
= MethodHandles.lookup()
.findStatic(Qsort.class, "qsortCompare",
MethodType.methodType(int.class,
MemoryAddress.class,
MemoryAddress.class));
第三,现在我们有了 Java 比较器的方法句柄,我们可以使用CLinker::upcallStub
.就像向下调用一样,我们使用 来描述函数指针的签名FunctionDescriptor
:
NativeSymbol comparFunc =
linker.upcallStub(comparHandle,
/* A Java description of a C function
implemented by a Java method! */
FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS),
newImplicitScope());
);
我们终于有了一个内存地址,comparFunc
它指向一个可用于调用 Java 比较器函数的存根,因此我们现在拥有了调用qsort
向下调用句柄所需的一切:
MemorySegment array = implicitAllocator().allocateArray(
ValueLayout.JAVA_INT,
new int[] { 0, 9, 3, 4, 6, 5, 1, 8, 2, 7 });
qsort.invoke(array, 10L, 4L, comparFunc);
int[] sorted = array.toIntArray(); // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
此代码创建一个堆外数组,将 Java 数组的内容复制到其中,然后将该数组qsort
以及我们从CLinker
.调用后,堆外数组的内容将根据我们用 Java 编写的比较器函数进行排序。然后,我们从段中提取一个新的 Java 数组,其中包含已排序的元素。
安全
从根本上来说,Java 代码和本机代码之间的任何交互都可能损害 Java 平台的完整性。链接到预编译库中的 C 函数本质上是不可靠的,因为 Java 运行时无法保证该函数的签名与 Java 代码的期望相匹配,甚至不能保证 C 库中的符号确实是一个函数。此外,即使链接了合适的函数,实际调用该函数也可能导致低级故障,例如分段错误,最终导致虚拟机崩溃。 Java 运行时无法阻止此类故障,也无法被 Java 代码捕获。
使用 JNI 函数的本机代码尤其危险。此类代码可以--add-opens
通过使用诸如getStaticField
和 之类的函数来访问 JDK 内部结构,而无需命令行标志(例如) 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 的一部分本质上是不安全的。与 交互时CLinker
,Java 代码可以通过指定与底层 C 函数的参数类型不兼容的参数类型来请求向下调用方法句柄。在 Java 中调用向下调用方法句柄将导致与native
在 JNI 中调用方法时可能发生的相同结果 - VM 崩溃或未定义的行为。 FFM API 还可以生成不安全段,即空间和时间边界由用户提供且无法由 Java 运行时验证的内存段(请参阅 参考资料MemorySegment::ofAddressNative
)。
FFM API 中的不安全方法不会造成与 JNI 函数相同的风险;例如,它们不能更改final
Java 对象中字段的值。另一方面,FFM API 中的不安全方法很容易从 Java 代码中调用。因此,在 FFM API 中使用不安全方法受到_限制:_默认情况下禁用对不安全方法的访问,因此调用此类方法会抛出IllegalAccessException
.要启用对某些模块 M 中代码的不安全方法的访问,请java --enable-native-access=M
在命令行上指定。 (在逗号分隔的列表中指定多个模块;指定ALL-UNNAMED
以启用对类路径上所有代码的访问。)FFM API 的大多数方法都是安全的,Java 代码可以使用这些方法,无论是否--enable-native-access
给出。
我们不建议在这里限制 JNI 的任何方面。仍然可以调用native
Java 中的方法,以及本机代码调用不安全的 JNI 函数。然而,我们很可能会在未来的版本中以某种方式限制 JNI。例如,不安全的 JNI 函数newDirectByteBuffer
可能默认被禁用,就像 FFM API 中的不安全方法一样。更广泛地说,JNI 机制是如此不可救药的危险,以至于我们希望库更喜欢纯 Java FFM API 来进行安全和不安全的操作,以便及时我们可以默认禁用所有 JNI。这与更广泛的 Java 路线图一致,即使平台开箱即用安全,要求最终用户选择不安全的活动,例如破坏强封装或链接到未知代码。
我们不建议sun.misc.Unsafe
以任何方式改变。 FFM API 对堆外内存的支持是malloc
和free
中包装器sun.misc.Unsafe
(即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 本地库的开销。