跳到主要内容

JEP 434:外部函数和内存 API(第二预览版)

概括

引入一个 API,Java 程序可以通过该 API 与 Java 运行时之外的代码和数据进行互操作。通过有效地调用外部函数(即 JVM 外部的代码),并通过安全地访问外部内存(即不由 JVM 管理的内存),API 使 Java 程序能够调用本机库并处理本机数据,而不会造成脆弱性和危险。 JNI。这是一个预览 API

历史

外部函数和内存 (FFM) API 结合了两个早期的孵化 API :外部内存访问 API(JEP 370、383393 和外部链接器 API(JEP 389)。 FFM API 通过JEP 412在 JDK 17 中孵化,通过JEP 419在 JDK 18 中重新孵化,并通过JEP 424在 JDK 19 中首次预览。此 JEP 建议根据反馈进行改进,并在 JDK 20 中重新预览 API。在此版本中:

  • MemorySegment和抽象MemoryAddress是统一的(内存地址现在由零长度内存段建模);
  • 密封的层次结构得到增强,以方便在表达式和语句MemoryLayout中使用模式匹配( JEP 433),并且switch
  • MemorySession已被分成ArenaSegmentScope促进跨维护边界的共享部分。

目标

  • 易于使用—用卓越的纯 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 的_堆中_,当不再需要时,它们将被垃圾回收。然而,与垃圾收集相关的成本和不可预测性对于TensorflowIgniteLuceneNetty等性能关键型库来说是不可接受的。它们需要将数据存储在堆外、它们自己分配和释放的_堆外内存_中。对堆外内存的访问还允许通过将文件直接映射到内存中来序列化和反序列化数据,例如mmap

Java 平台历史上提供了两个用于访问堆外内存的 API:

  • APIByteBuffer提供_直接_字节缓冲区,它们是由固定大小的堆外内存区域支持的 Java 对象。然而,区域的最大大小限制为 2 GB,并且读写内存的方法很初级且容易出错,仅提供对原始值的索引访问。更严重的是,仅当缓冲区对象被垃圾收集时,支持直接字节缓冲区的内存才会被释放,这是开发人员无法控制的。由于缺乏对及时释放的支持,使得该ByteBufferAPI 不适合 Java 系统编程。

  • sun.misc.UnsafeAPI提供对堆内内存的低级访问,也适用于堆外内存。使用Unsafe速度很快(因为它的内存访问操作是由 JVM 内在化的),允许巨大的堆外区域(理论上最多 16 艾字节),并提供对释放的细粒度控制(因为Unsafe::freeMemory可以随时调用)。然而,这种编程模型很弱,因为它给了开发人员太多的控制权。随着时间的推移,长时间运行的服务器应用程序中的库将分配堆外内存的多个区域并与之交互;一个区域中的数据将指向另一区域中的数据,并且必须以正确的顺序释放区域,否则悬空指针将导致释放后使用错误。由于缺乏对安全释放的支持,该UnsafeAPI 不太适合 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 对象都必须由本机代码费力地解包。例如,考虑PersonJava 中的记录类:将Person对象传递给native方法将要求本机代码使用 JNI 的 C API从对象中提取字段(例如firstName和)。lastName因此,Java 开发人员有时会将数据扁平化为单个对象(例如,字节数组或直接字节缓冲区),但更常见的是,由于通过 JNI 传递 Java 对象很慢,他们使用 APIUnsafe来分配堆外内存并将其地址native作为 a 传递给方法long— 这使得 Java 代码非常不安全!

多年来,出现了许多框架来填补 JNI 留下的空白,包括JNAJNRJavaCPP。虽然这些框架通常比 JNI 有显着改进,但情况仍然不太理想,特别是与提供一流本机互操作的语言相比。例如,Python 的ctypes包可以动态地将函数包装在本机库中,而无需任何粘合代码。其他语言(例如Rust)提供了从 C/C++ 头文件机械派生本机包装器的工具。

最终,Java 开发人员应该拥有一个受支持的 API,让他们可以直接使用任何被认为对特定任务有用的本机库,而无需使用 JNI 的繁琐粘合和笨拙。_方法句柄_是一个优秀的构建抽象,它是在 Java 7 中引入的,用于支持 JVM 上的快速动态语言。通过方法句柄公开本机代码将从根本上简化依赖本机库的编写、构建和分发 Java 库的任务。此外,能够对外部函数(即本机代码)和外部内存(即堆外数据)进行建模的 API 将为第三方本机互操作框架提供坚实的基础。

描述

外部函数和内存 API (FFM API) 定义类和接口,以便库和应用程序中的客户端代码可以

FFM API 驻留在模块java.lang.foreign的包中java.base

例子

作为使用 FFM API 的一个简短示例,下面是 Java 代码,它获取 C 库函数的方法句柄radixsort,然后使用它对在 Java 数组中开始生命的四个字符串进行排序(省略了一些细节)。

由于 FFM API 是预览 API,因此您必须在启用预览功能的情况下编译和运行代码,即javac --release 20 --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.openConfined()) {
// 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) 定义,从而产生从到+ 99(含)b的地址范围。b``b

MemorySegment data = MemorySegment.allocateNative(100, SegmentScope.global());

_段的时间范围_决定了它的生命周期,即支持该段的内存区域被释放之前的时间段。分配段时,时间范围由段范围指定。内存段只有在其作用域处于_活动_状态时才能被访问,这表明支持该段的内存区域仍然被分配。尝试访问作用域不存在的内存段将失败并出现异常。

最简单的段作用域是_全局_作用域,它提供无限的生命周期:它始终处于活动状态。如上面的代码所示,分配有全局范围的段始终是可访问的,并且支持该段的内存区域永远不会被释放。

然而,大多数程序需要在程序运行时释放堆外内存,因此需要具有有限生命周期的内存段。

自动作用域提供了有限的生命周期:它一直处于活动状态_,_直到 JVM 的垃圾收集器检测到内存段不可访问,此时支持该段的内存区域将被释放。例如,此方法分配一个具有自动作用域的段:

void processData() {
MemorySegment data = MemorySegment.allocateNative(100, SegmentScope.auto());
... use the 'data' variable ...
... use the 'data' variable some more ...
} // The region of memory backing the 'data' segment will be deallocated here (or later)

只要data变量没有泄漏出方法,该段最终将被检测为不可访问,并且其后备区域将被释放。

自动作用域的有限但不确定的生命周期并不总是足够的。例如,从文件映射内存段的 API 应允许客户端确定性地释放支持该段的内存区域,因为等待垃圾收集器可能会对性能产生不利影响。

arena作用域提供了有界且确定的生命周期:从客户端打开arena 的时间到客户端关闭 arena 的时间,_它_都处于活动状态。每个竞技场都有自己的范围;分配有相同竞技场范围的多个段享有相同的有界生命周期,并且可以安全地包含相互引用。例如,此代码打开一个arena并使用arena的范围来指定两个段的生命周期:

MemorySegment input = null, output = null;
try (Arena processing = Arena.openConfined()) {
input = MemorySegment.allocateNative(100, processing.scope());
... set up data in 'input' ...
output = MemorySegment.allocateNative(100, processing.scope());
... 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')

当通过 -with-resources 操作关闭 arena 时,tryarena 作用域不再存在,段将失效,并且支持段的内存区域将被原子地释放。

Arena 提供了强大的临时安全保证:在 arena 关闭后,分配给 arena 作用域的内存段将无法被访问,因为 arena 作用域不再存在。这种保证的成本取决于有权访问内存段的线程数量。如果竞技场由同一线程打开和关闭,并且使用竞技场范围分配的所有内存段只能由该线程访问,那么确保正确性就很简单。然而,如果用 arena 范围分配的内存段被多个线程访问,那么确保正确性就很复杂;例如,分配有arena范围的段可能会被一个线程访问,而另一个线程则尝试关闭arena。为了保证时间安全而不让单线程客户端付出不适当的成本,有两种竞技场:受限的_和_共享的

  • 受限区域 ( Arena::openConfined) 支持强大的线程限制保证。封闭的 arena 有一个_所有者线程_,通常是打开它的线程。在受限区域(即,具有受限区域的范围)中分配的内存段只能由所有者线程访问。任何从所有者线程以外的线程关闭受限区域的尝试都将失败并出现异常。

  • 共享竞技场 ( Arena::openShared) 没有所有者线程。在共享区域中分配的内存段可以由多个线程访问。此外,共享竞技场可以由任何线程关闭,并且即使在竞争下,也可以保证关闭的安全性和原子性。

总之,段作用域控制哪些线程可以访问内存段以及何时访问。具有全局作用域或自动作用域的内存段可以被任何线程访问。相反,arena 范围限制对特定线程的访问,以提供强大的时间安全性和可预测的性能模型。

取消引用段

要取消引用内存段中的某些数据,我们需要考虑以下几个因素:

  • 要取消引用的字节数,
  • 发生取消引用的地址的对齐约束,
  • 字节存储在内存段中的字节顺序,以及
  • 取消引用操作中要使用的 Java 类型(例如intvs float)。

所有这些特征都在抽象中得到体现ValueLayout。例如,预定义JAVA_INT值布局是四个字节宽,在四字节边界上对齐,使用本机平台字节序(例如,Linux/x64 上的小字节序),并且与 Java 类型相关联int

内存段具有简单的取消引用方法来从内存段读取值或向内存段写入值。这些方法接受一个值布局,该布局唯一指定取消引用操作的属性。例如,我们可以int使用以下代码在内存段中的连续偏移处写入 25 个值:

MemorySegment segment = MemorySegment.allocateNative(100, // size
ValueLayout.JAVA_INT.byteAlignment, // alignment
SegmentScope.auto());
for (int i = 0; i < 25; i++) {
segment.setAtIndex(ValueLayout.JAVA_INT,
/* index */ i,
/* value to write */ i);
}

内存布局和结构化访问

考虑以下 C 声明,它定义了一个结构Point体数组,其中每个Point结构体都有两个成员,即Point.xPoint.y

struct Point {
int x;
int y;
} pts[10];

使用上一节中显示的取消引用方法,要初始化这样的本机数组,我们必须编写以下代码(在下文中我们假设sizeof(int)==4):

MemorySegment segment = MemorySegment.allocateNative(2 * ValueLayout.JAVA_INT.byteSize() * 10, // size
ValueLayout.JAVA_INT.byteAlignment, // alignment
SegmentScope.auto());
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, SegmentScope.auto());
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.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());
...
} // memory released here

段分配器也可以通过接口中的工厂获得SegmentAllocator。一个这样的工厂返回一个本机分配器,即分配与给定段范围关联的本机段的分配器。还提供了其他更优化的分配器。例如,以下代码创建一个_切片_分配器并使用它来分配一个段,该段的内容是从 Javaint数组初始化的:

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 });
...
}
...
} // all memory allocated is released here

此代码创建一个大小为 1024 字节的本机段。然后,该段用于创建切片分配器,该分配器通过返回该预分配段的切片来响应分配请求。如果当前段没有足够的空间来容纳分配请求,则会抛出异常。当 arena 关闭时,与分配器创建的段相关联的所有内存(即,在 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 unloaded here

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(或) 方法来调用 downcall 方法句柄invokeExact,并且外部函数将运行。传递给方法句柄的invoke方法的任何参数都将传递给外部函数。

对于向上调用,该upcallStub方法采用一个方法句柄(通常是指 Java 方法,而不是向下调用方法句柄)并将其转换为实例MemorySegment。随后,当 Java 代码调用向下调用方法句柄时,内存段将作为参数传递。实际上,内存段充当函数指针。 (有关上行调用的更多信息,请参阅下文。)

假设我们希望从 Java 向下调用strlen标准 C 库中定义的函数:

size_t strlen(const char *s);

客户端可以使用_本机链接器_(请参阅 参考资料Linker::nativeLinker)来链接 C 函数,Linker该实现符合由运行 JVM 的操作系统和 CPU 确定的 ABI。strlen可以如下获取公开的向下调用方法句柄(FunctionDescriptor稍后将详细描述):

Linker linker = Linker.nativeLinker();
MethodHandle strlen = linker.downcallHandle(
linker.defaultLookup().find("strlen").get(),
FunctionDescriptor.of(JAVA_LONG, ADDRESS)
);

调用 downcall 方法句柄将运行strlen并使其结果在 Java 中可用。对于 的参数strlen,我们使用辅助方法将 Java 字符串转换为堆外内存段(使用受限区域),然后通过引用传递:

try (Arena arena = Arena.openConfined()) {
MemorySegment str = arena.allocateUtf8String("Hello");
long len = strlen.invoke(str); // 5
}

方法句柄非常适合公开外部函数,因为 JVM 已经优化了方法句柄的调用,一直到本机代码。当方法句柄引用文件中的方法时class,调用方法句柄通常会导致目标方法被 JIT 编译;随后,JVMMethodHandle::invokeExact通过将控制权转移到为目标方法生成的汇编代码来解释调用的 Java 字节码。因此,Java 中的传统方法句柄在幕后针对非 Java 代码;向下调用方法句柄是一种自然的扩展,它允许开发人员显式地瞄准非 Java 代码。方法句柄还具有称为签名多态性的属性,它允许使用原始参数进行无框调用。总之,方法句柄让Linker外部函数以自然、高效且可扩展的方式公开。

在 Java 中描述 C 类型

要创建向下调用方法句柄,FFM API 要求客户端提供FunctionDescriptor描述目标 C 函数的 C 参数类型和 C 返回类型的句柄。 C 类型在 FFM API 中通过MemoryLayout对象进行描述,例如ValueLayout标量 C 类型和GroupLayoutC 结构类型。客户端通常MemoryLayout手头有对象来取消引用外部内存中的数据,并且可以重用它们来获取FunctionDescriptor.

FFM API 还使用 来FunctionDescriptor派生向下调用方法句柄的类型。每个方法句柄都是强类型的,这意味着它对invokeExact运行时可以传递给其方法的参数的数量和类型很严格。例如,为获取一个MemorySegment参数而创建的方法句柄不能通过 调用invokeExact(<MemorySegment>, <MemorySegment>),即使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)``int``long

long如果客户端以使用标量类型(例如、intsize_t)的 C 函数为目标,则必须了解当前平台。这是因为标量 C 类型与布局常量的关联因平台而异。在 Windows/x64 上,Clong与布局相关联JAVA_INT,因此所需的FunctionDescriptor将是FunctionDescriptor.of(JAVA_INT, JAVA_INT)并且向下调用方法句柄的类型将是int的Java 签名int

作为另一个示例,假设向下调用方法句柄应该公开一个采用指针的 void C 函数。在所有平台上,C 指针类型与预定义布局相关联,因此可以使用 获得ADDRESS所需的布局。然后,本机链接器将把向下调用方法句柄的类型安排为的Java 签名。也就是说,参数可以通过引用或值传递,具体取决于相应函数描述符中指定的布局。FunctionDescriptor``FunctionDescriptor.ofVoid(ADDRESS)``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安排向下调用方法句柄的类型作为MemorySegment的Java 签名void

与 C 结构类型关联的内存布局必须是复合布局,它定义 C 结构中所有字段的子布局,包括本机编译器可能插入的任何与平台相关的填充。

如果 C 函数返回按值结构(此处未显示),则必须在堆外分配新的内存段并将其返回到 Java 客户端。为了实现这一点,返回的方法句柄downcallHandle需要一个额外的SegmentAllocator参数,FFM API 使用该参数来分配内存段来保存 C 函数返回的结构。

如前所述,虽然本机链接器实现的重点是提供 Java 和 C 库之间的互操作,但该Linker接口是语言中立的:它没有关于如何定义 C 类型的具体知识,因此客户端负责获取适合 C 的布局定义类型。这种选择是经过深思熟虑的,因为 C 类型的布局定义(无论是简单标量还是复杂结构)最终都是依赖于平台的,因此可以由对给定目标平台有深入了解的工具自动生成。

为 C 函数打包 Java 参数

_调用约定_通过指定一种语言中的代码如何调用另一种语言中的函数、传递参数和接收结果来实现不同语言之间的互操作。该LinkerAPI 在调用约定方面是中立的,但本机链接器实现支持多种开箱即用的调用约定:Linux/x64、Linux/AArch64、macOS/x64、macOS/AArch64 和 Windows/x64。由于用 Java 编写,它比 JNI 更容易维护和扩展,JNI 的调用约定被硬连线到 HotSpot 的 C++ 代码中。

考虑FunctionDescriptor上面获得的SYSTEMTIME结构/布局。给定运行 JVM 的操作系统和 CPU 的调用约定,本机链接器使用 来FunctionDescriptor推断当使用参数调用向下调用方法句柄时应如何将结构体的字段传递给 C 函数MemorySegment。对于一种调用约定,本机链接器实现可以安排分解传入的内存段,使用通用 CPU 寄存器传递前四个字段,并传递 C 堆栈上的其余字段。对于不同的调用约定,本机链接器实现可以通过分配内存区域、将传入内存段的内容批量复制到该区域并将指向该区域的指针传递给 C 函数来安排间接传递结构。这种最低级别的参数打包发生在幕后,不需要客户端代码的监督。

零长度内存段

外部函数通常分配一个内存区域并返回指向该区域的指针。使用内存段对此类区域进行建模具有挑战性,因为 Java 运行时无法获得该区域的大小。例如,具有返回类型的 C 函数char*可能返回指向包含单个值的区域或包含以 结尾的值char序列的区域的指针。该区域的大小对于调用外部函数的代码来说并不明显。char``'\0'

FFM API 将从外部函数返回的指针表示为零_长度内存段_。段的地址是指针的值,段的大小为零。类似地,当客户端从内存段读取地址时,将返回零长度内存段。

零长度段具有微不足道的空间边界,因此任何访问此类段的尝试都会失败并显示IndexOutOfBoundsException。这是一个至关重要的安全功能:由于这些段与大小未知的内存区域相关联,因此无法验证涉及这些段的访问操作。实际上,零长度内存段包装了一个地址,如果没有明确的意图,则无法使用它。

客户端有两种方法来访问本机零长度内存段,这两种方法都是不安全的:

  • 客户端可以通过工厂将原始内存地址(例如,值long)包装为指定大小的段MemorySegment::ofAddress。该工厂将新的空间和时间边界附加到原始内存地址,以允许取消引用操作。此工厂返回的内存段是不安全的:原始内存地址可能与 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布局来映射 Csize_t类型,并且我们ADDRESS对第一个指针参数(数组指针)和最后一个参数(函数指针)都使用布局。

qsort``compar使用作为函数指针传递的自定义比较器函数对数组的内容进行排序。因此,要调用向下调用方法句柄,我们需要一个函数指针作为最后一个参数传递给方法句柄的invokeExact方法。Linker::upcallStub帮助我们使用现有的方法句柄创建函数指针,如下所示。

首先,我们static用 Java 编写一个方法来比较两个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,
/* A Java description of a C function
implemented by a Java method! */
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通过使用诸如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 的一部分本质上是不安全的。当与 交互时Linker,Java 代码可以通过指定与底层外部函数的参数类型不兼容的参数类型来请求向下调用方法句柄。在 Java 中调用向下调用方法句柄将导致与native在 JNI 中调用方法时可能发生的相同结果 - VM 崩溃或未定义的行为。 FFM API 还可以生成不安全段,即空间和时间边界由用户提供且无法由 Java 运行时验证的内存段(请参阅 参考资料MemorySegment::ofAddress)。

FFM API 中的不安全方法不会造成与 JNI 函数相同的风险;例如,它们不能更改finalJava 对象中字段的值。另一方面,FFM API 中的不安全方法很容易从 Java 代码中调用。因此,在 FFM API 中使用不安全方法受到_限制:_允许使用它们,但默认情况下,每次此类使用都会导致在运行时发出警告。要允许模块中的代码M使用不安全方法而不发出警告,请--enable-native-access=M在命令行上指定该选项java。 (用逗号分隔的列表指定多个模块;指定ALL-UNNAMED为类路径上的所有代码启用无警告使用。)当存在此选项时,从指定模块列表之外使用任何不安全方法都将IllegalCallerException导致抛出,而不是发出警告。在未来的版本中,可能需要此选项才能使用不安全的方法。

我们不建议在这里限制 JNI 的任何方面。仍然可以调用nativeJava 中的方法,以及本机代码调用不安全的 JNI 函数。然而,我们很可能会在未来的版本中以某种方式限制 JNI。例如,不安全的 JNI 函数newDirectByteBuffer可能默认被禁用,就像 FFM API 中的不安全方法一样。更广泛地说,JNI 机制是如此不可救药的危险,以至于我们希望库更喜欢纯 Java FFM API 来进行安全和不安全的操作,以便及时我们可以默认禁用所有 JNI。这与更广泛的 Java 路线图一致,即使平台开箱即用安全,要求最终用户选择不安全的活动,例如破坏强封装或链接到未知代码。

我们不建议sun.misc.Unsafe以任何方式改变。 FFM API 对堆外内存的支持是mallocfree中包装器sun.misc.Unsafe(即allocateMemorysetMemorycopyMemory和 )的绝佳替代品freeMemory。我们希望需要堆外存储的库和应用程序采用 FFM API,以便我们及时弃用并最终删除这些sun.misc.Unsafe方法。

备择方案

继续使用java.nio.ByteBuffersun.misc.Unsafe、 JNI 等第三方框架。

风险和假设

创建一个 API 来以既安全又高效的方式访问外部内存是一项艰巨的任务。由于前面几节中描述的空间和时间检查需要在每次访问时执行,因此 JIT 编译器能够通过例如将它们提升到热循环之外来优化这些检查至关重要。 JIT 实现可能需要做一些工作来确保 API 的使用与现有 API(例如ByteBuffer和)的使用一样高效和可优化Unsafe。 JIT 实现还需要确保从 API 检索的本机方法句柄的使用至少与现有 JNI 本机方法的使用一样高效和可优化。

依赖关系

  • 外部函数和内存 API 可用于以更通用和更有效的方式访问非易失性内存,这已经可以通过JEP 352(非易失性映射字节缓冲区)实现。

  • 这里描述的工作可能会使后续工作能够提供一个工具jextract,该工具从给定本机库的头文件开始,机械地生成与该库互操作所需的本机方法句柄。这将进一步减少使用 Java 本地库的开销。