跳到主要内容

JEP 454:外部函数和内存 API

概括

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

历史

外部函数和内存 (FFM) API 最初由JEP 424 (JDK 19)作为预览功能提出,随后由 JEP JEP 434 (JDK 20) 和JEP 442 (JDK 21) 进行完善。该 JEP 建议根据持续的经验和反馈进一步进行小幅改进,最终确定 FFM API。在此版本中,我们有:

  • 提供了一个新的链接器选项,允许客户端将堆段传递给向下调用方法句柄;
  • 引入了Enable-Native-AccessJAR 文件清单属性,允许可执行 JAR 文件中的代码调用受限方法,而无需使用--enable-native-access命令行选项;
  • 使客户端能够以编程方式构建 C 语言函数描述符,避免特定于平台的常量;
  • 改进了对本机内存中可变长度数组的支持;和
  • 添加了对本机字符串的任意字符集的支持。

目标

  • 生产力——用简洁、可读和纯 Java API取代脆弱的方法机制nativeJava 本机接口(JNI)。

  • 性能——提供对外部函数和内存的访问,其开销与 JNI 和sun.misc.Unsafe.

  • 广泛的平台支持— 允许在 JVM 运行的每个平台上发现和调用本机库。

  • 一致性——提供在多种内存(例如,本机内存、持久内存和托管堆内存)中操作无限大小的结构化和非结构化数据的方法。

  • 健全性——保证没有释放后使用错误,即使在多个线程之间分配和释放内存时也是如此。

  • 完整性——允许程序使用本机代码和数据执行不安全的操作,但默认警告用户此类操作。

非目标

这不是一个目标

  • 在此 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,并且读写内存的方法很初级且容易出错,仅提供对原始值的索引访问。更严重的是,仅当缓冲区对象被垃圾收集时,支持直接字节缓冲区的内存才会被释放,这是开发人员无法控制的。

  • sun.misc.UnsafeAPI提供对堆内内存的低级访问,也适用于堆外内存。使用Unsafe速度很快(因为它的内存访问操作是 JVM 固有的),允许巨大的堆外区域(理论上最多 16 艾字节),并提供对释放的细粒度控制(因为Unsafe::freeMemory可以随时调用)。然而,这种编程模型很弱,因为它给了开发人员太多的控制权。长时间运行的应用程序中的库可以随着时间的推移分配堆外内存的多个区域并与之交互;一个区域中的数据可以指向另一区域中的数据,并且区域必须以正确的顺序释放,否则悬空指针将导致释放后使用错误。

    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 代码用结构表示聚合数据,因此传递给方法的任何 Java 对象native都必须由本机代码费力地解包。例如,考虑一个 Java 记录类Person:将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 库的编写、构建和分发任务。此外,能够对外部函数(即本机代码)和外部内存(即堆外数据)进行建模的 API 将为第三方本机互操作框架提供坚实的基础。

描述

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

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

例子

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

// 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.allocate(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.allocateFrom(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.reinterpret(...).getString(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 字节的本机段,因此关联的地址范围是从某个_基地址 bb + 99包含在内的地址范围。

MemorySegment data = Arena.global().allocate(100);

_段的时间范围_决定了它的生命周期,即支持该段的内存区域被释放之前的时间段。 FFM API 保证内存段在其后备内存区域被释放后无法被访问。

段的时间边界由用于分配该段的区域确定。在同一 arena 中分配的多个段具有相同的时间范围,并且可以安全地包含相互引用: 段A可以保存指向 段 中地址的指针B,段B可以保存指向 段 中地址的指针A,并且两个段都将在同时,这样两个段都没有悬空指针。

最简单的 arena 是global arena,它提供无限的生命周期:它总是活着的。如上面的代码所示,在全局区域中分配的段始终是可访问的,并且支持该段的内存区域永远不会被释放。

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

自动竞技场提供有界的生命周期:可以访问由自动竞技场分配的段,直到 JVM 的垃圾收集器检测到该内存段不可访问,此时支持该段的内存区域将被释放。例如,此方法在自动区域中分配一个段:

void processData() {
MemorySegment data = Arena.ofAuto().allocate(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 类型(例如intvs float)。

所有这些特征都在抽象中得到体现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结构体有两个成员:

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

使用上一节中所示的方法,我们可以为数组分配本机内存,并Point使用以下代码初始化十个结构中的每一个(我们假设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在上面的示例中),我们可以使用 aMemoryLayout以更具声明性的方式描述内存段的内容。包含十个结构体(每个结构体都是一对整数)的本机内存段由包含十次出现的_结构体布局(每个结构体都是一对布局)的序列布局_来描述:JAVA_INT

SequenceLayout ptsLayout
= MemoryLayout.sequenceLayout(10,
MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("x"),
ValueLayout.JAVA_INT.withName("y")));

从序列布局中我们可以获得一个变量句柄,该变量句柄可以在具有相同布局的任何内存段中获取和设置数据元素。我们希望设置的一种元素是x在结构序列中的任意结构中调用的成员。因此,我们通过提供导航到结构体然后导航到其成员的_布局路径_来获取此类元素的变量句柄x

VarHandle xHandle = ptsLayout.varHandle(PathElement.sequenceElement(),
PathElement.groupElement("x"));

相应地,对于会员y

VarHandle yHandle = ptsLayout.varHandle(PathElement.sequenceElement(),
PathElement.groupElement("y"));

Point现在,我们可以通过分配具有结构序列布局的本机段,然后通过两个变量句柄在每个连续结构中设置两个成员来分配和初始化包含十个结构的数组。每个句柄接受MemorySegment要操作的、段内结构序列的基地址以及指示结构序列中的哪个结构将具有其成员集的索引。

MemorySegment segment = Arena.ofAuto().allocate(ptsLayout);
for (int i = 0; i < ptsLayout.elementCount(); i++) {
xHandle.set(segment,
/* base */ 0L,
/* index */ (long) i,
/* value to write */ i); // x
yHandle.set(segment,
/* base */ 0L,
/* index */ (long) i,
/* value to write */ i); // y
}

段分配器

当客户端使用堆外内存时,内存分配通常是一个瓶颈。因此,FFM API 包含一个SegmentAllocator抽象来定义分配和初始化内存段的操作。为了方便起见,该类Arena实现了该SegmentAllocator接口,以便可以使用 arenas 从各种现有源分配本机段。换句话说,Arena是一个“一站式商店”,用于灵活分配和及时释放堆外内存:

try (Arena offHeap = Arena.ofConfined()) {
MemorySegment nativeInt = offHeap.allocateFrom(ValueLayout.JAVA_INT, 42);
MemorySegment nativeIntArray = offHeap.allocateFrom(ValueLayout.JAVA_INT,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9);
MemorySegment nativeString = offHeap.allocateFrom("Hello!");
...
} // memory released here

段分配器也可以通过接口中的工厂获得SegmentAllocator。例如,一个工厂创建了一个切片分配器,它通过返回属于先前分配的段的一部分的内存段来响应分配请求;因此,无需物理分配更多内存即可满足许多请求。以下代码在现有段上获取切片分配器,然后使用它来分配从 Java 数组初始化的段:

MemorySegment segment = ...
SegmentAllocator allocator = SegmentAllocator.slicingAllocator(segment);
for (int i = 0 ; i < 10 ; i++) {
MemorySegment s = allocator.allocateFrom(ValueLayout.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.allocateFrom(ValueLayout.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 操作,例如涉及类加载的对象分配或方法访问。因此,当 JVM 加载此类库时,它们必须与类加载器相关联。然后,为了保持类加载器的完整性,不能从不同类加载器中定义的类加载相同的 JNI 使用库。

相比之下,FFM API 不提供本机代码访问 Java 环境的功能,并且不假设本机库被设计为与 FFM API 一起使用。通过加载的本机库SymbolLookup::libraryLookup(String, Arena)不一定是为了从 Java 代码访问而编写的,并且不会尝试执行 Java 操作。因此,它们不依赖于特定的类加载器,并且可以由不同加载器中的 FFM API 客户端根据需要多次(重新)加载。

将 Java 代码链接到外部函数

接口Linker是 Java 代码与本机代码交互操作的核心。虽然在本文档中我们经常提到 Java 代码和 C 库之间的互操作,但该接口中的概念足够通用,足以支持将来的其他非 Java 语言。该Linker接口支持_向下调用_(从 Java 代码调用本机代码)和_向上调用_(从本机代码调用回 Java 代码)。

interface Linker {
MethodHandle downcallHandle(MemorySegment address,
FunctionDescriptor function);
MemorySegment upcallStub(MethodHandle target,
FunctionDescriptor function,
Arena arena);
}

对于向下调用,该downcallHandle方法获取外部函数的地址(通常是MemorySegment从库查找中获得的地址)并将外部函数公开为_向下调用方法句柄_。随后,Java 代码通过调用其invoke(或) 方法来调用 downcall 方法句柄invokeExact,并且外部函数将运行。传递给方法句柄的invoke方法的任何参数都将传递给外部函数。

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

客户端使用本机链接器_链接到 C 函数,它们通过Linker::nativeLinker().本机链接器是Linker符合运行 JVM 的本机平台的应用程序二进制接口 (ABI) 的接口的实现。 ABI 指定了_调用约定,使用一种语言编写的代码能够将参数传递给用另一种语言编写的代码并接收结果。 ABI 还指定标量 C 类型的大小、对齐方式和字节顺序、应如何处理可变参数调用以及其他详细信息。虽然Linker接口在调用约定方面是中立的,但本机链接器针对许多平台的调用约定进行了优化:

  • Linux/x64
  • Linux/AArch64
  • Linux/RISC-V
  • Linux/PPC64
  • Linux/s390
  • macOS/x64
  • macOS/AArch64
  • Windows/x64
  • Windows/AArch64
  • AIX/ppc64

本机链接器通过委托给libffi.

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

size_t strlen(const char *s);

strlen如下获取公开的向下调用方法句柄(FunctionDescriptor稍后将详细描述):

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

调用 downcall 方法句柄运行strlen并使其结果可供 Java 代码使用:

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

对于参数 ,我们使用辅助方法strlen之一将 Java 字符串转换为堆外内存段。传递此内存段会导致内存段的基地址作为参数传递给函数。allocateFrom``Arena``strlen.invoke``strlen``char *

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

在 Java 代码中描述 C 类型

要创建向下调用方法句柄,本机链接器要求客户端提供FunctionDescriptor描述目标 C 函数的 C 参数类型和 C 返回类型的 。 C 类型由MemoryLayout对象来描述,ValueLayout对于标量 C 类型,主要是int和,对于 C 结构类型,主要是float和。StructLayout与 C 结构类型关联的内存布局必须是复合布局,它定义 C 结构中所有字段的子布局,包括本机编译器可能插入的任何与平台相关的填充。

本机链接器使用 来FunctionDescriptor派生向下调用方法句柄的类型invokeExact。每个方法句柄都是强类型的,这意味着它对运行时可以传递给其方法的参数的数量和类型很严格。例如,为获取一个MemorySegment参数而创建的方法句柄不能通过 调用invokeExact(<MemorySegment>, <MemorySegment>),即使invokeExact它是一种 varargs 方法。向下调用方法句柄的类型描述了开发人员在调用向下调用方法句柄时必须使用的 Java 签名。实际上,它是 C 函数的 Java 级视图。

long如果开发人员以使用标量类型(例如、intsize_t)的 C 函数为目标,则必须了解当前的本机平台。这是因为标量 C 类型与预定义值布局的关联因平台而异。当前平台标量 C 类型和JAVA_*值布局之间的关联由Linker::canonicalLayouts().

例如,假设向下调用方法句柄应公开一个接受 Cint并返回 C 的C 函数long

  • 在 Linux/x64 和 macOS/x64 上,C 类型long和分别与预定义布局和int关联,因此可以通过.然后,本机链接器将向下调用方法句柄的类型安排为的Java 签名。JAVA_LONG``JAVA_INT``FunctionDescriptor``FunctionDescriptor.of(JAVA_LONG, JAVA_INT)``int``long

  • 在 Windows/x64 上,C 类型long与预定义布局 关联,因此必须使用 获取JAVA_INT所需的布局。然后,本机链接器将向下调用方法句柄的类型安排为的Java 签名。FunctionDescriptor``FunctionDescriptor.of(JAVA_INT, JAVA_INT)``int``int

开发人员可以定位使用指针的 C 函数,而无需了解当前本机平台或当前平台上指针的大小。在所有平台上,C 指针类型与预定义的布局相关联ADDRESS,其大小在运行时确定。开发人员不需要区分 C 指针类型,例如int*char**

例如,假设向下调用方法句柄应公开一个void采用指针的 C 函数。由于每个 C 指针类型都与布局相关联ADDRESS,因此FunctionDescriptor可以使用 获得所需的指针类型FunctionDescriptor.ofVoid(ADDRESS)。然后,本机链接器将向下调用方法句柄的类型安排为MemorySegment的Java 签名void。当aMemorySegment被传递给downcall方法句柄时,该段的基地址将被传递给目标C函数。

最后,与 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)。本机链接器会将向下调用方法句柄的类型安排为MemorySegment的Java 签名void

给定本机平台的调用约定,本机链接器使用 来FunctionDescriptor确定当使用参数调用向下调用方法句柄时应如何将结构体的字段传递给 C 函数MemorySegment。对于一种调用约定,本机链接器可以安排分解传入的内存段,使用通用 CPU 寄存器传递前四个字段,并传递 C 堆栈上的其余字段。对于另一种调用约定,本机链接器可以通过分配内存区域、将传入内存段的内容批量复制到该区域并将指向该区域的指针传递给 C 函数来安排间接传递结构。这种低级参数打包发生在幕后,不受客户端代码的任何监督。

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

如前所述,虽然本机链接器专注于提供 Java 代码和 C 库之间的互操作,但该Linker接口是语言中立的:它没有指定如何定义任何本机数据类型,因此开发人员负责获取适合 C 的布局定义类型。这种选择是经过深思熟虑的,因为 C 类型的布局定义(无论是简单标量还是复杂结构)最终都是依赖于平台的。我们预计,在实践中,此类布局将由特定于目标本机平台的工具自动生成。

零长度内存段

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

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

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

客户端可以通过方法将零长度内存段转换为特定大小的本机段MemorySegment::reinterpret。此方法将新的空间和时间边界附加到零长度内存段,以允许取消引用操作。此方法返回的内存段是不安全的:零长度内存段可能由 10 字节长的内存区域支持,但客户端可能会高估该区域的大小并使用它MemorySegment::reinterpret来获取 100 字节长的段长的。稍后,这可能会导致尝试取消引用该区域边界之外的内存,这可能会导致 JVM 崩溃,或者更糟糕的是,导致无提示内存损坏。

由于覆盖零长度内存段的空间和时间界限是不安全的,因此该MemorySegment::reinterpret方法受到_限制_。在程序中使用它会导致 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方法来比较两个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.withTargetLayout(JAVA_INT),
ADDRESS.withTargetLayout(JAVA_INT)),
Arena.ofAuto());

我们终于有了一个内存段,comparFunc它指向一个可用于调用 Java 比较器函数的存根,因此我们现在拥有了调用qsort向下调用句柄所需的一切:

try (Arena arena = Arena.ofConfined()) {
MemorySegment array
= arena.allocateFrom(ValueLayout.JAVA_INT,
0, 9, 3, 4, 6, 5, 1, 8, 2, 7);
qsort.invoke(array, 10L, ValueLayout.JAVA_INT.byteSize(), comparFunc);
int[] sorted = array.toArray(JAVA_INT); // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
}

此代码创建一个堆外数组,将 Java 数组的内容复制到其中,然后将该数组qsort与我们从本机链接器获得的比较器函数一起传递给句柄。调用后,堆外数组的内容将根据我们的比较器函数进行排序,编写为Java代码。然后,我们从段中提取一个新的 Java 数组,其中包含已排序的元素。

内存段和字节缓冲区

APIjava.nio.channels提供了在文件和套接字上执行 I/O 的广泛功能。在此 API 中,I/O 操作以ByteBuffer对象而不是简单的字节数组来表示。客户端向通道写入数据必须首先将其放入字节缓冲区;从通道读取数据后,客户端必须从字节缓冲区中提取数据。例如,以下代码使用 aFileChannel将文件内容读入堆外字节缓冲区,一次 1024 个字节:

try (FileChannel channel = FileChannel.open(... a file path ...)) {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
int bytesRead;
while ((bytesRead = channel.read(buffer)) != -1) {
... extract and process buffer contents ...
buffer.clear();
}
}

由于字节缓冲区可能小于文件,因此代码必须重复从通道读取,然后清除字节缓冲区,以便为下一次读取操作做好准备。

考虑到低级缓冲区分配和处理的背景,开发人员常常会惊讶地发现他们无法控制堆外字节缓冲区的重新分配;相反,它们必须等待垃圾收集器回收它们。如果绝对需要立即释放,那么他们只能诉诸非标准、非确定性技术,例如调用sun.misc.Unsafe::invokeCleaner.

FFM API 使开发人员能够将通道的功能与内存段和竞技场提供的标准、确定性释放相结合。

FFM API 包括一种MemorySegment::asByteBuffer允许将任何内存段用作字节缓冲区的方法。生成的字节缓冲区的生命周期由内存段的时间范围决定,而内存段的时间范围又由用于分配内存段的区域设置。客户端继续使用字节缓冲区读取和写入通道,但现在可以控制何时释放字节缓冲区的内存。这是前面的示例,修改为使用字节缓冲区,当try-with-resources 块关闭 arena 时,该缓冲区的内存将被释放:

// try-with-resources manages two resources: a channel and an arena
try (FileChannel channel = FileChannel.open(... a file path ...);
Arena offHeap = Arena.ofConfined()) {
ByteBuffer buffer = offHeap.allocate(1024).asByteBuffer();
int readBytes;
while ((readBytes = channel.read(buffer)) != -1) {
... unpack and process the contents of buffer ...
buffer.clear();
}
} // buffer’s memory is deallocated here

FFM API 还包括一种MemorySegment::ofBuffer方法,通过创建由同一内存区域支持的内存段,允许将任何字节缓冲区用作内存段。例如,以下方法调用本机strlen函数,该函数采用char *, 以及从堆外字节缓冲区生成的内存段:

void readString(ByteBuffer offheapString) {
MethodHandle strlen = ...
long len = strlen.invokeExact(MemorySegment.ofBuffer(offheapString));
...
}

许多 Java 程序中都存在字节缓冲区,因为它们长期以来都是将堆外数据传递到本机代码的唯一受支持的方式。然而,本机代码访问字节缓冲区中的数据很麻烦,因为本机代码必须首先调用JNI 函数来获取指向支持字节缓冲区的内存区域的指针。相比之下,内存段中的数据很容易被本机代码访问,因为当 Java 代码将对象传递MemorySegment给本机代码时,FFM API 传递的是内存段的基地址(指向其数据的指针),而不是地址物体MemorySegment本身。

安全

大多数 FFM API 在设计上都是安全的。过去需要使用 JNI 和本机代码的许多场景现在可以通过调用 FFM API 中的方法来解决,而不会损害Java 平台的完整性。例如,JNI 的一个重要用例——灵活的内存分配和释放——现在在 Java 代码中由内存段和竞技场支持,并且不需要本机代码。

然而,FFM API 的一部分本质上是不安全的。例如,Java 代码可以请求向下调用方法句柄,Linker但指定与底层外部函数的参数类型不兼容的参数类型。调用生成的方法句柄将产生与native在 JNI 中调用方法时可能发生的相同类型的故障(VM 崩溃或本机代码未定义的行为) 。 Java 运行时无法阻止此类故障,Java 代码也无法捕获此类故障。 FFM API 还可用于生成不安全段,即空间和时间边界由用户提供且无法由 Java 运行时验证的内存段(请参阅 参考资料MemorySegment::reinterpret)。

换句话说,Java 代码和本机代码之间的任何交互都可能损害 Java 平台的完整性。相应地,FFM API中的不安全方法受到_限制_。这意味着它们的使用是允许的,但默认情况下会导致在运行时发出警告。例如:

WARNING: A restricted method in java.lang.foreign.Linker has been called
WARNING: Linker::downcallHandle has been called by com.foo.Server in an unnamed module
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

此类警告会写入标准错误流,对于其代码调用受限方法的每个模块最多发出一次。

要允许模块中的代码M使用不安全方法而不发出警告,请在启动器命令行--enable-native-access=M上指定该选项java。用逗号分隔列表指定多个模块;指定ALL-UNNAMED以启用类路径上所有代码的无警告使用。此外,JAR 文件清单属性Enable-Native-Access: ALL-UNNAMED可以在可执行 JAR 中使用,以允许类路径上的所有代码无警告地使用;不能将其他模块名称指定为该属性的值。

当存在该--enable-native-access选项时,对指定模块列表之外的任何不安全方法的使用都会导致IllegalCallerException抛出异常,而不是发出警告。在未来的版本中,可能需要此选项才能使用不安全的方法;也就是说,如果该选项不存在,那么使用不安全的方法将不会导致警告,而是会导致IllegalCallerException.

为了确保 Java 代码与本机代码交互的方式保持一致,相关的 JEP建议以类似的方式限制 JNI 的使用。仍然可以native从 Java 代码调用方法,以及本机代码调用不安全的 JNI 函数,但--enable-native-access需要该选项以避免警告和随后的异常。这与使Java 平台开箱即用安全的更广泛路线图相一致,要求最终用户或应用程序开发人员选择参与不安全的活动,例如破坏强封装或链接到未知代码。

风险和假设

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

依赖关系

jextract 工具依赖于 FFM API。它获取本机库的头文件,并自动生成与该库互操作所需的向下调用方法句柄。这减少了从 Java 代码使用本机库的开销。