跳到主要内容

JEP 412:外部函数和内存 API(孵化器)

概括

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

历史

本 JEP 中提出的 API 是两个孵化 API 的演变:外部内存访问 API 和外部链接器 API。外部内存访问 API 最初由JEP 370提出,并于 2019 年底针对 Java 14 作为孵化 API;它在 Java 15 中由JEP 383和 Java 16 中的JEP 393重新孵化。Foreign Linker API 最初由JEP 389提出,并于 2020 年底针对 Java 16,同样作为孵化 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 库(如TensorflowIgniteLuceneNetty )的性能至关重要,主要是因为它可以让他们避免与垃圾收集相关的成本和不可预测性。它还允许通过例如将文件映射到内存来序列化和反序列化数据结构mmap。然而,Java 平台目前并未提供令人满意的访问堆外数据的解决方案。

  • ByteBufferAPI允许创建在堆外分配的_直接_字节缓冲区,但它们的最大大小为 2 GB,并且不会立即释放。这些和其他限制源于这样一个事实:APIByteBuffer不仅设计用于堆外内存访问,还设计用于字符集编码/解码和部分 I/O 操作等领域的批量数据的生产者/消费者交换。在这种情况下,不可能满足多年来提出的堆外增强的许多请求(例如,4496703655836848375645029431)。

  • sun.misc.UnsafeAPI公开了堆内数据的内存访问操作,这些操作也适用于堆外数据。使用Unsafe是高效的,因为它的内存访问操作被定义为 HotSpot JVM 内在函数并由 JIT 编译器优化。然而,使用Unsafe是危险的,因为它允许访问任何内存位置。这意味着 Java 程序可以通过访问已释放的位置来使 JVM 崩溃;由于这个和其他原因,Unsafe一直强烈反对使用.

  • 使用 JNI 调用本机库,然后访问堆外数据是可能的,但性能开销很少使其适用:从 Java 到本机比访问内存慢几个数量级,因为 JNI 方法调用不能从许多常见的方法中受益JIT 优化,例如内联。

总而言之,当涉及到访问堆外数据时,Java 开发人员面临着一个两难境地:他们应该选择安全但低效的路径(ByteBuffer)还是应该放弃安全而追求性能(Unsafe)?他们需要的是一个受支持的 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 对象都必须由本机代码费力地解包。例如,考虑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) 定义类和接口,以便库和应用程序中的客户端代码可以

  • 分配外部内存
    MemorySegmentMemoryAddress、 和SegmentAllocator),
  • 操作和访问结构化外部内存
    MemoryLayoutMemoryHandles、 和MemoryAccess),
  • 管理外部资源的生命周期 ( ResourceScope),以及
  • 调用外部函数 (SymbolLookupCLinker)。

FFM API 驻留在模块jdk.incubator.foreign的包中jdk.incubator.foreign

例子

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

// 1. Find foreign function on the C library path
MethodHandle radixSort = CLinker.getInstance().downcallHandle(
CLinker.systemLookup().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,
CLinker.C_POINTER), ...);
// 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 = CLinker.toCString(javaStrings[i], newImplicitScope());
MemoryAccess.setAddressAtIndex(offHeap, i, cString.address());
}
// 5. Sort the off-heap data by calling the foreign function
radixSort.invoke(offHeap.address(), 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 = MemoryAccess.getAddressAtIndex(offHeap, i);
javaStrings[i] = CLinker.toJavaStringRestricted(cStringPtr);
}
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作用域,它确保当垃圾收集器认为该对象无法访问时,与该段关联的内存被释放。隐式作用域还确保内存段可从多个线程访问。

换句话说,上面的代码创建了一个段,其行为ByteBufferallocateDirect工厂分配的段非常匹配。 FFM API 还支持确定性内存释放和其他线程限制选项,如下所述

取消引用内存段

取消引用与段关联的内存是通过获取var 句柄(Java 9 中引入的数据访问抽象)来实现的。特别是,使用_内存访问 var 句柄_取消引用段。这种 var 句柄使用一对访问坐标:

  • 类型的坐标MemorySegment——要取消引用其内存的段,以及
  • 类型的坐标long— 距段基地址的偏移量,在该处发生取消引用。

内存访问变量句柄是通过MemoryHandles类中的工厂方法获得的。例如,此代码获取一个内存访问 var 句柄,该句柄可以将int值写入本机内存段,并使用它在连续偏移处写入 25 个四字节值:

MemorySegment segment = MemorySegment.allocateNative(100, newImplicitScope());
VarHandle intHandle = MemoryHandles.varHandle(int.class, ByteOrder.nativeOrder());
for (int i = 0; i < 25; i++) {
intHandle.set(segment, /* offset */ i * 4, /* value to write */ i);
}

可以通过使用类提供的一种或多种组合器方法组合内存访问 var 句柄来表达更高级的访问习惯用法MemoryHandles。通过这些,客户端可以例如重新排序给定内存访问变量句柄的坐标、删除一个或多个坐标以及插入新坐标。这允许创建内存访问 var 句柄,该句柄接受一个或多个逻辑索引到由平坦的堆外内存区域支持的多维数组中。

为了使 FFM API 更易于使用,该类MemoryAccess提供了静态访问器来取消引用内存段,而无需构造内存访问 var 句柄。例如,有一个访问器可以int在给定偏移量的段中设置一个值,从而使上面的代码可以简化为:

MemorySegment segment = MemorySegment.allocateNative(100, newImplicitScope());
for (int i = 0; i < 25; i++) {
MemoryAccess.setIntAtOffset(segment, i * 4, i);
}

内存布局

为了减少有关内存布局的繁琐计算的需要(例如,i * 4在上面的示例中),MemoryLayout可以使用 a 以更具声明性的方式描述内存段的内容。例如,上面示例中本机内存段的所需布局可以用以下方式描述:

SequenceLayout intArrayLayout
= MemoryLayout.sequenceLayout(25,
MemoryLayout.valueLayout(32, ByteOrder.nativeOrder()));

这将创建一个_序列内存布局_,其中 32 位_值布局_(描述单个 32 位值的布局)重复 25 次。给定内存布局,我们可以避免在代码中计算偏移量,并简化内存分配和内存访问 var 句柄的创建:

MemorySegment segment = MemorySegment.allocateNative(intArrayLayout, newImplicitScope());
VarHandle indexedElementHandle =
intArrayLayout.varHandle(int.class, PathElement.sequenceElement());
for (int i = 0; i < intArrayLayout.elementCount().getAsLong(); i++) {
indexedElementHandle.set(segment, (long) i, i);
}

该对象通过创建_布局路径_intArrayLayout来驱动内存访问变量句柄的创建,该布局路径用于从复杂的布局表达式中选择嵌套布局。该对象还驱动本机内存段的分配,该分配基于从布局导出的大小和对齐信息。前面示例中的循环常量已替换为序列布局的元素计数。intArrayLayout``25

资源范围

前面示例中看到的所有内存段都使用非确定性释放:一旦内存段实例变得无法访问,垃圾收集器就会释放与这些段关联的内存。我们说这些段被_隐式释放_。

在某些情况下,客户端可能希望控制何时发生内存释放。例如,假设一个大内存段是使用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资源范围(无论是受限的还是共享的)可以与负责执行隐式释放的对象相关联,以防资源范围对象在close客户端调用该方法之前变得不可访问。

某些资源作用域(称为_隐式_资源作用域)不支持显式释放——调用close将会失败。隐式资源范围始终使用Cleaner.可以使用ResourceScope::newImplicitScope工厂创建隐式作用域,如前面的示例所示。

段分配器

当客户端使用堆外内存时,内存分配通常会成为瓶颈。 FFM API 包含一个SegmentAllocator抽象,它定义了分配和初始化内存段的有用操作。段分配器是通过SegmentAllocator接口中的工厂获得的。例如,以下代码创建一个基于 arena 的分配器,并使用它来分配一个段,该段的内容是从 Javaint数组初始化的:

try (ResourceScope scope = ResourceScope.newConfinedScope()) {
SegmentAllocator allocator = SegmentAllocator.arenaAllocator(scope);
for (int i = 0 ; i < 100 ; i++) {
MemorySegment s = allocator.allocateArray(C_INT, new int[] { 1, 2, 3, 4, 5 });
...
}
...
} // all memory allocated is released here

此代码创建一个受限资源范围,然后创建一个与该范围关联的_无界竞技场分配器_。该分配器将分配特定大小的内存块,并通过返回预分配块的不同切片来响应分配请求。如果slab没有足够的空间来容纳新的分配请求,则分配新的slab。如果与arena分配器关联的资源范围被关闭,则与分配器创建的段(即,在循环体中for)关联的所有内存都被原子地释放。这种习惯将抽象提供的确定性解除分配的优点ResourceScope与更灵活和可扩展的分配方案结合起来。在编写管理大量堆外段的代码时,它非常有用。

不安全的内存段

到目前为止,我们已经了解了内存段、内存地址和内存布局。解引用操作只能在内存段上进行。由于内存段具有空间和时间界限,因此 Java 运行时始终可以确保与给定段关联的内存被安全地取消引用。但是,在某些情况下,客户端可能只有一个MemoryAddress实例,与本机代码交互时经常出现这种情况。由于 Java 运行时无法知道与内存地址关联的空间和时间边界,因此 FFM API 禁止直接取消引用内存地址。

要取消引用内存地址,客户端有两种选择。

  • 如果已知地址属于内存段,则客户端可以通过执行_变基_MemoryAddress::segmentOffset操作。变基操作重新解释地址相对于段基地址的偏移量,以产生一个新的偏移量,该偏移量可以应用于现有的段,然后可以安全地取消引用。

  • 或者,如果不存在这样的段,则客户端可以使用工厂_不安全地_MemoryAddress::asSegment创建一个段。该工厂有效地将新的空间和时间边界附加到原始内存地址,以便允许取消引用操作。此工厂返回的内存段是_不安全的_:原始内存地址可能与 10 字节长的内存区域关联,但客户端可能会意外高估该区域的大小并创建 100 字节长的不安全内存段。这可能会导致稍后尝试取消引用与不安全段关联的内存区域边界之外的内存,这可能会导致 JVM 崩溃,或者更糟糕的是,导致无提示内存损坏。因此,创建不安全段被视为受_限制的操作_,并且默认情况下被禁用(请参阅下面的更多内容)。

查找外部函数

对外部函数的任何支持的第一个要素是加载本机库的机制。对于 JNI,这是通过System::loadLibrarySystem::load方法来完成的,这些方法在内部映射到对dlopen或其等价物的调用。使用这些方法加载的库总是与类加载器(即调用该System方法的类的加载器)相关联。库和类加载器之间的关联至关重要,因为它控制着加载的库的生命周期:只有当类加载器不再可达时,它的所有库才能被安全卸载。

FFM API 不提供加载本机库的新方法。开发人员使用System::loadLibrarySystem::load方法加载将通过 FFM API 调用的本机库。库和类加载器之间的关联被保留,因此库将以与 JNI 相同的可预测方式卸载。

与 JNI 不同,FFM API 提供了在加载的库中查找给定符号的地址的功能。这种由对象表示的功能SymbolLookup对于将 Java 代码链接到外部函数至关重要(见下文)。获取对象有两种方式SymbolLookup

  • SymbolLookup::loaderLookup返回一个符号查找,该查找可以查看当前类加载器加载的所有库中的所有符号。
  • CLinker::systemLookup返回特定于平台的符号查找,该查找可查看标准 C 库中的符号。

给定符号查找,客户端可以使用该SymbolLookup::lookup(String)方法找到外部函数。如果命名函数存在于符号查找所看到的符号中,则该方法返回MemoryAddress指向该函数入口点的 a 。例如,以下代码加载 OpenGL 库(使其与当前类加载器关联)并查找其glGetString函数的地址:

System.loadLibrary("GL");
SymbolLookup loaderLookup = SymbolLookup.loaderLookup();
MemoryAddress clangVersion = loaderLookup.lookup("glGetString").get();

将 Java 代码链接到外部函数

接口CLinker是 Java 代码与本机代码交互操作的核心。虽然它的CLinker重点是提供 Java 和 C 库之间的互操作,但接口中的概念足够通用,足以支持将来的其他非 Java 语言。该接口支持_向下调用_(从 Java 代码调用本机代码)和_向上调用_(从本机代码调用回 Java 代码)。

interface CLinker {
MethodHandle downcallHandle(MemoryAddress func,
MethodType type,
FunctionDescriptor function);
MemoryAddress upcallStub(MethodHandle target,
FunctionDescriptor function,
ResourceScope scope);
}

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

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

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

size_t strlen(const char *s);

strlen可以如下获取公开的向下调用方法句柄(MethodType和的详细信息FunctionDescriptor将很快描述):

MethodHandle strlen = CLinker.getInstance().downcallHandle(
CLinker.systemLookup().lookup("strlen").get(),
MethodType.methodType(long.class, MemoryAddress.class),
FunctionDescriptor.of(C_LONG, C_POINTER)
);

调用 downcall 方法句柄将运行strlen并使其结果在 Java 中可用。对于 的参数strlen,我们使用辅助方法将 Java 字符串转换为堆外内存段并传递该段的地址:

MemorySegment str = CLinker.toCString("Hello", newImplicitScope());
long len = strlen.invokeExact(str.address()); // 5

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

在 Java 中描述 C 类型

_为了创建向下调用方法句柄,FFM API 要求客户端提供目标 C 函数的两侧视图:使用不透明_Java 对象(MemoryAddress、 )的高级签名和使用_透明_Java 对象的MemorySegment低级签名( )。依次获取每个签名:MemoryLayout

  • 高级签名 aMethodType用作向下调用方法句柄的类型。每个方法句柄都是强类型的,这意味着它对可以传递给其invokeExact方法的参数的数量和类型很严格。例如,为获取一个参数而创建的方法句柄不能通过 via或 viaMemoryAddress调用。因此,它描述了客户端在调用向下调用方法句柄时必须使用的 Java 签名。实际上,它是 C 函数的 Java 视图。invokeExact(<MemoryAddress>, <MemoryAddress>)``invokeExact("Hello")``MethodType

  • 低级签名 aFunctionDescriptor由对象组成MemoryLayout。这使我们能够CLinker准确地理解 C 函数的参数,以便能够正确地排列它们,如下所述。客户端通常MemoryLayout手头上有对象,以便取消引用外部内存中的数据,并且这些对象可以在此处重用为外部函数签名。

例如,获取接受int并返回 a的 C 函数的向下调用方法句柄long将需要以下内容MethodTypeFunctionDescriptor参数downcallHandle

MethodType mtype         = MethodType.methodType(long.class, int.class);
FunctionDescriptor fdesc = FunctionDescriptor.of(C_LONG, C_INT);

(此示例针对 Linux/x64 和 macOS/x64,其中 Java 类型long和分别与预定义布局和int关联。Java 类型与内存布局的关联因平台而异;例如,在 Windows/x64 上,Java与与布局。)CLinker``C_LONG``C_INT``long``C_LONG_LONG

作为另一个示例,获取带有指针的 C 函数的向下调用方法句柄void需要以下MethodTypeFunctionDescriptor

MethodType mtype         = MethodType.methodType(void.class, MemoryAddress.class);
FunctionDescriptor fdesc = FunctionDescriptor.ofVoid(C_POINTER);

(C中的所有指针类型在Java中都表示为MemoryAddress对象;相应的布局是 ,其大小取决于当前平台C_POINTER。客户端不区分,例如 ,int*char**,因为传递给共同的Java类型和内存布局CLinker包含足够的将 Java 参数正确传递给 C 函数的信息。)

最后,与 JNI 不同的是,它CLinker支持将结构化数据传递给外部函数。获取采用结构的 C 函数的向下调用方法句柄void需要以下MethodTypeFunctionDescriptor

MethodType mtype         = MethodType.methodType(void.class, MemorySegment.class);
MemoryLayout SYSTEMTIME = MemoryLayout.ofStruct(
C_SHORT.withName("wYear"), C_SHORT.withName("wMonth"),
C_SHORT.withName("wDayOfWeek"), C_SHORT.withName("wDay"),
C_SHORT.withName("wHour"), C_SHORT.withName("wMinute"),
C_SHORT.withName("wSecond"), C_SHORT.withName("wMilliseconds")
);
FunctionDescriptor fdesc = FunctionDescriptor.ofVoid(SYSTEMTIME);

(对于高级MethodType签名,Java 客户端始终使用不透明类型MemorySegment,其中 C 函数需要按值传递结构。对于低级FunctionDescriptor签名,与 C 结构类型关联的内存布局必须是复合布局,它定义C 结构中所有字段的子布局,包括可能由本机编译器插入的填充。)

如果 C 函数返回按值结构(由低级签名表示),则必须在堆外分配新的内存段并将其返回到 Java 客户端。为此,返回的方法句柄downcallHandle需要一个附加SegmentAllocator参数,FFM API 使用该参数分配内存段来保存 C 函数返回的结构。

为 C 函数打包 Java 参数

不同语言之间的互操作需要_调用约定_来指定一种语言中的代码如何调用另一种语言中的函数、如何传递参数以及如何接收任何结果。该CLinker实现了解多种开箱即用的调用约定:Linux/x64、Linux/AArch64、macOS/x64 和 Windows/x64。由于用 Java 编写,它比 JNI 更容易维护和扩展,JNI 的调用约定被硬连线到 HotSpot 的 C++ 代码中。

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

上行呼叫

有时,将 Java 代码作为函数指针传递给某个外部函数很有用。我们可以通过使用CLinker对上行调用的支持来实现这一点。在本节中,我们将逐步构建一个更复杂的示例,该示例展示了 的全部功能CLinker,以及跨 Java/本机边界的代码和数据的完全双向互操作。

考虑标准 C 库中定义的以下函数:

void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));

要从 Java调用qsort,我们首先需要创建一个向下调用方法句柄:

MethodHandle qsort = CLinker.getInstance().downcallHandle(
CLinker.systemLookup().lookup("qsort").get(),
MethodType.methodType(void.class, MemoryAddress.class, long.class,
long.class, MemoryAddress.class),
FunctionDescriptor.ofVoid(C_POINTER, C_LONG, C_LONG, C_POINTER)
);

和以前一样,我们使用C_LONGlong.class来映射 Csize_t类型,并且我们MemoryAddess.class同时使用第一个指针参数(数组指针)和最后一个参数(函数指针)。

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

首先,我们static用 Java 编写一个方法来比较两个long值,间接表示为MemoryAddress对象:

class Qsort {
static int qsortCompare(MemoryAddress addr1, MemoryAddress addr2) {
return MemoryAccess.getIntAtOffset(MemorySegment.globalNativeSegment(),
addr1.toRawLongValue()) -
MemoryAccess.getIntAtOffset(MemorySegment.globalNativeSegment(),
addr2.toRawLongValue());
}
}

其次,我们创建一个指向 Java 比较器方法的方法句柄:

MethodHandle comparHandle
= MethodHandles.lookup()
.findStatic(Qsort.class, "qsortCompare",
MethodType.methodType(int.class,
MemoryAddress.class,
MemoryAddress.class));

第三,现在我们有了 Java 比较器的方法句柄,我们可以使用CLinker::upcallStub.就像向下调用一样,我们使用CLinker类中的布局来描述函数指针的签名:

MemoryAddress comparFunc =
CLinker.getInstance().upcallStub(comparHandle,
FunctionDescriptor.of(C_INT,
C_POINTER,
C_POINTER),
newImplicitScope());
);

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

MemorySegment array = MemorySegment.allocateNative(4 * 10, newImplicitScope());
array.copyFrom(MemorySegment.ofArray(new int[] { 0, 9, 3, 4, 6, 5, 1, 8, 2, 7 }));
qsort.invokeExact(array.address(), 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 运行时验证的内存段(请参阅 参考资料MemoryAddress::asSegment)。

FFM API 中的不安全方法不会造成与 JNI 函数相同的风险;例如,它们不能更改finalJava 对象中字段的值。另一方面,FFM API 中的不安全方法很容易从 Java 代码中调用。因此,在 FFM API 中使用不安全方法受到_限制:_默认情况下禁用对不安全方法的访问,因此调用此类方法会抛出IllegalAccessException.要启用对某些模块 M 中代码的不安全方法的访问,请java --enable-native-access=M在命令行上指定。 (在逗号分隔的列表中指定多个模块;指定ALL-UNNAMED以启用对类路径上所有代码的访问。)FFM API 的大多数方法都是安全的,Java 代码可以使用这些方法,无论是否--enable-native-access给出。

我们不建议在这里限制 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 本地库的开销。