JEP 412: Foreign Function & Memory API (Incubator) | 外部函数和内存 API(孵化器)
摘要
引入一个 API,使 Java 程序能够与 Java 运行时环境之外的代码和数据互操作。通过高效地调用外部函数(即 JVM 之外的代码)以及安全地访问外部内存(即不由 JVM 管理的内存),该 API 使 Java 程序能够调用本地库并处理本地数据,同时避免 JNI 的脆弱性和危险性。
历史
本 JEP 中提出的 API 是两个孵化 API 的演进:外部内存访问 API 和外部链接器 API。JEP 370 首次提出了外部内存访问 API,并计划在 2019 年底的 Java 14 中作为 孵化 API 推出;随后,JEP 383 在 Java 15 中重新孵化了该 API,而 JEP 393 则在 Java 16 中再次进行了孵化。JEP 389 首次提出了外部链接器 API,并计划在 2020 年底的 Java 16 中作为 孵化 API 推出。
目标
易用性 —— 用一个更优越的纯 Java 开发模型替代 Java 本地接口(JNI)。
性能 —— 提供与现有 API(如 JNI 和
sun.misc.Unsafe
)相当甚至更好的性能。通用性 —— 提供操作不同类型外部内存(如本地内存、持久性内存和管理堆内存)的方法,并随着时间的推移,适应其他平台(如 32 位 x86)以及用非 C 语言(如 C++、Fortran)编写的外部函数。
安全性 —— 默认禁用不安全操作,仅允许应用程序开发人员或最终用户在明确选择加入后使用。
非目标
本 API 不旨在:
- 在此 API 基础上重新实现 JNI,或以任何方式更改 JNI;
- 在此 API 基础上重新实现旧的 Java API,如
sun.misc.Unsafe
; - 提供从本地代码头文件自动生成 Java 代码的工具;
- 更改与本地库交互的 Java 应用程序的打包和部署方式(例如,通过多平台 JAR 文件)。
动机
Java 平台始终为希望超越 JVM 并与其他平台交互的库和应用程序开发人员提供了坚实的基础。Java API 以方便可靠的方式公开非 Java 资源,无论是访问远程数据(JDBC)、调用 Web 服务(HTTP 客户端)、为远程客户端提供服务(NIO 通道),还是与本地进程通信(Unix 域套接字)。不幸的是,Java 开发人员在访问一种重要的非 Java 资源时仍然面临重大障碍:即与 JVM 位于同一台机器上但位于 Java 运行时之外的代码和数据。
外部内存
存储在 Java 运行时之外的内存中的数据被称为 堆外 数据。(堆 是 Java 对象存在的地方——堆上 数据——也是垃圾收集器工作的地方。)对于像 Tensorflow、Ignite、Lucene 和 Netty 这样的流行 Java 库来说,访问堆外数据对其性能至关重要,主要是因为这可以让它们避免与垃圾收集相关的成本和不可预测性。此外,通过例如 mmap
将文件映射到内存中,还可以允许数据结构进行序列化和反序列化。然而,目前 Java 平台并没有提供一个令人满意的解决方案来访问堆外数据。
ByteBuffer
API 允许创建分配在堆外的 直接 字节缓冲区,但其最大大小为两吉字节,并且它们不会立即被释放。这些以及其他限制源于ByteBuffer
API 的设计初衷不仅是为了访问堆外内存,还为了在处理字符集编码 / 解码和部分 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
)?他们需要的是一个从底层开始设计就考虑到了安全性和 JIT 优化的、用于访问堆外数据(即外部内存)的受支持 API。
外部函数
自 Java 1.1 以来,JNI 就支持调用本地代码(即外部函数),但由于多种原因,它并不完善。
JNI 涉及多个繁琐的组件:一个 Java API(
native
方法)、一个从 Java API 派生的 C 头文件,以及一个调用目标本地库的 C 实现。Java 开发者必须在多个工具链之间工作,以保持与平台相关的组件同步,这在本地库快速发展时尤其繁重。JNI 只能与用 C 和 C++ 等语言编写的库进行互操作,这些库使用为 JVM 构建的操作系统和 CPU 的调用约定。
native
方法不能用于调用使用不同约定的语言编写的函数。JNI 没有将 Java 类型系统与 C 类型系统协调起来。在 Java 中,聚合数据通过对象表示,而在 C 中,聚合数据通过结构体表示,因此任何传递给
native
方法的 Java 对象都必须由本地代码费力地解包。例如,考虑 Java 中的一个记录类Person
:将Person
对象传递给native
方法将需要本地代码使用 JNI 的 C API 从该对象中提取字段(如firstName
和lastName
)。因此,Java 开发者有时会将其数据扁平化为单个对象(如字节数组或直接字节缓冲区),但更常见的是,由于通过 JNI 传递 Java 对象速度较慢,他们使用Unsafe
API 分配堆外内存,并将其地址作为long
类型传递给native
方法——这会使 Java 代码变得极其不安全!
多年来,出现了许多框架来填补 JNI 留下的空白,包括 JNA、JNR 和 JavaCPP。虽然这些框架通常是对 JNI 的显著改进,但情况仍然不够理想,尤其是与提供一流原生互操作性的语言相比。例如,Python 的 ctypes 包可以无需任何胶水代码就动态地包装本地库中的函数。其他语言,如 Rust,提供了可以从 C/C++ 头文件机械地推导本地包装器的工具。
最终,Java 开发者应该拥有一个受支持的 API,该 API 允许他们直接调用任何被认为对特定任务有用的本地库,而无需处理 JNI 的繁琐和笨重。一个优秀的构建基础是 方法句柄(method handles),它在 Java 7 中被引入,以支持 JVM 上的快速动态语言。通过方法句柄暴露本地代码将极大地简化编写、构建和分发依赖于本地库的 Java 库的任务。此外,一个能够模拟外部函数(即本地代码)和外部内存(即堆外数据)的 API 将为第三方本地互操作框架提供坚实的基础。
描述
外部函数与内存 API(FFM API)定义了类和接口,以便库和应用程序中的客户端代码可以:
- 分配外部内存(
MemorySegment
、MemoryAddress
和SegmentAllocator
), - 操作和访问结构化外部内存(
MemoryLayout
、MemoryHandles
和MemoryAccess
), - 管理外部资源的生命周期(
ResourceScope
),以及 - 调用外部函数(
SymbolLookup
和CLinker
)。
FFM API 位于 jdk.incubator.foreign
模块的 jdk.incubator.foreign
包中。
示例
作为使用 FFM API 的一个简短示例,以下是 Java 代码,它获取一个 C 库函数 radixsort
的方法句柄,然后使用它来排序四个原本位于 Java 数组中的字符串(省略了一些细节):
// 1. 在 C 库路径上查找外部函数
MethodHandle radixSort = CLinker.getInstance().downcallHandle(
CLinker.systemLookup().lookup("radixsort"), ...);
// 2. 在堆上分配内存以存储四个字符串
String[] javaStrings = { "mouse", "cat", "dog", "car" };
// 3. 在堆外分配内存以存储四个指针
MemorySegment offHeap = MemorySegment.allocateNative(
MemoryLayout.ofSequence(javaStrings.length, CLinker.C_POINTER), ...);
// 4. 将字符串从堆上复制到堆外
for (int i = 0; i < javaStrings.length; i++) {
// 在堆外分配一个字符串,然后存储指向它的指针
MemorySegment cString = CLinker.toCString(javaStrings[i], newImplicitScope());
MemoryAccess.setAddressAtIndex(offHeap, i, cString.address());
}
// 5. 调用外部函数对堆外数据进行排序
radixSort.invoke(offHeap.address(), javaStrings.length, MemoryAddress.NULL, '\0');
// 6. 将(重新排序后的)字符串从堆外复制回堆上
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 特性,例如,流(Streams)可以允许多个线程并行地在堆上和堆外内存之间复制数据。
内存段
内存段 是一种抽象,用于模拟位于堆外或堆上的连续内存区域。内存段可以是:
- 原生 段,从原生内存中直接分配(例如,通过
malloc
), - 映射 段,围绕映射的原生内存区域包装(例如,通过
mmap
),或 - 数组 或 缓冲区 段,分别围绕与现有 Java 数组或字节缓冲区相关联的内存包装。
所有内存段都提供空间、时间和线程隔离保证,这些保证被严格执行以确保内存解引用操作的安全性。例如,以下代码在堆外分配了 100 字节:
MemorySegment segment = MemorySegment.allocateNative(100, newImplicitScope());
一个内存段的 空间边界 确定了与该段相关联的内存地址范围。上述代码中段的边界由 基地址b
(表示为 MemoryAddress
实例)和字节大小(100)定义,从而形成一个从 b
到 b
+99(含)的地址范围。
一个内存段的 时间边界 确定了该段的生命周期,即该段何时被释放。段的生命周期和线程隔离状态由 ResourceScope
抽象表示,将在 下文 中讨论。上述代码中的资源范围是一个新的 隐式 范围,它确保当 MemorySegment
对象被垃圾收集器视为不可达时,与该段相关联的内存将被释放。隐式范围还确保内存段可以从多个线程访问。
换句话说,上述代码创建了一个其行为与通过 allocateDirect
工厂方法分配的 ByteBuffer
非常相似的段。FFM API 还支持确定性内存释放和其他线程隔离选项,将在 下文 中讨论。
引用内存段
通过获取 var handle(Java 9 中引入的数据访问抽象)来实现与段相关联的内存的引用解除。特别是,通过 内存访问 var handle 来解除段的引用。这种 var handle 使用一对访问坐标:
- 类型为
MemorySegment
的坐标——要解除引用的内存段, - 类型为
long
的坐标——从段的基地址开始的偏移量,在该偏移量处进行引用解除。
通过 MemoryHandles
类中的工厂方法可以获得内存访问 var handle。例如,以下代码获取一个内存访问 var handle,该 handle 可以将 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, /* 偏移量 */ i * 4, /* 要写入的值 */ i);
}
通过结合使用 MemoryHandles
类提供的一个或多个组合器方法,可以表达更高级别的访问习惯用法。通过这些方法,客户端可以例如重新排序给定内存访问 var handle 的坐标,删除一个或多个坐标,并插入新坐标。这允许创建内存访问 var handle,该 handle 接受一个或多个逻辑索引,这些索引指向由平面堆外内存区域支持的多维数组。
为了使 FFM API 更加易于使用,MemoryAccess
类提供了静态访问器来解除内存段的引用,而无需构造内存访问 var handle。例如,有一个访问器可以在给定偏移量处设置段中的 int
值,从而使上面的代码简化为:
MemorySegment segment = MemorySegment.allocateNative(100, newImplicitScope());
for (int i = 0; i < 25; i++) {
MemoryAccess.setIntAtOffset(segment, i * 4, i);
}
内存布局
为了减少关于内存布局(如上面示例中的 i * 4
)的繁琐计算,可以使用 MemoryLayout
以更声明性的方式描述内存段的内容。例如,上面示例中所需的本机内存段布局可以描述如下:
SequenceLayout intArrayLayout
= MemoryLayout.sequenceLayout(25,
MemoryLayout.valueLayout(32, ByteOrder.nativeOrder()));
这创建了一个 序列内存布局,其中 32 位的 值布局(描述单个 32 位值的布局)重复了 25 次。给定一个内存布局,我们可以避免在代码中计算偏移量,并简化内存分配和内存访问 var handle 的创建:
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
对象通过创建 布局路径 来驱动内存访问 var handle 的创建,该路径用于从复杂的布局表达式中选择嵌套布局。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);
...
} // 在此处释放两个内存段
此代码创建了一个 受限 的资源作用域,并在创建两个内存段时使用它:一个映射段 (s1
) 和一个本地段 (s2
)。这两个内存段的生命周期与资源作用域的生命周期绑定在一起,因此在 try-with-resources 语句完成后访问这些内存段(例如,使用内存访问 var handle 取消引用它们)将导致抛出运行时异常。
除了管理内存段的生命周期外,资源作用域还用作控制哪些线程可以访问该内存段的手段。受限资源作用域将访问权限限制在创建该作用域的线程上,而 共享 资源作用域则允许从任何线程进行访问。
无论是受限还是共享资源作用域,都可以与一个 java.lang.ref.Cleaner
对象相关联,该对象负责在资源作用域对象在客户端调用 close
方法之前变得不可达时执行隐式释放。
一些资源作用域,称为 隐式 资源作用域,不支持显式释放——调用 close
会失败。隐式资源作用域始终使用 Cleaner
来管理其资源。可以使用 ResourceScope::newImplicitScope
工厂方法创建隐式作用域,如前面的示例所示。
段分配器
当客户端使用堆外内存时,内存分配往往成为瓶颈。FFM API 包含了一个 SegmentAllocator
抽象,它定义了用于分配和初始化内存段的实用操作。通过 SegmentAllocator
接口中的工厂方法可以获得段分配器。例如,以下代码创建了一个基于区域(arena)的分配器,并使用它来分配一个其内容从 Java int
数组初始化的段:
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 });
...
}
...
} // 在这里释放所有已分配的内存
此代码创建了一个受限的资源作用域,然后创建了一个与该作用域关联的 无界区域分配器。此分配器将分配特定大小的内存块(slab),并通过返回预分配块的不同切片来响应分配请求。如果某个块没有足够的空间来容纳新的分配请求,则会分配一个新的块。如果与区域分配器关联的资源作用域被关闭,则分配器创建的所有段(即 for
循环体中的段)关联的内存都将被原子性地释放。这种惯用法结合了由 ResourceScope
抽象提供的确定性释放内存的优势,以及更加灵活和可扩展的分配方案。在编写管理大量堆外段的代码时,这非常有用。
不安全的内存段
到目前为止,我们已经了解了内存段、内存地址和内存布局。只有对内存段才能进行解引用操作。由于内存段具有空间和时间界限,Java 运行时环境总能确保与给定段相关联的内存被安全地解引用。然而,在某些情况下,客户端可能只有 MemoryAddress
实例,这通常发生在与本地代码交互时。由于 Java 运行时环境无法知道与内存地址相关联的空间和时间界限,FFM API 禁止直接解引用内存地址。
为了解引用一个内存地址,客户端有两个选择。
如果已知地址位于某个内存段内,客户端可以通过
MemoryAddress::segmentOffset
执行 重新定位 操作。重新定位操作将地址相对于段基地址的偏移量重新解释为新的偏移量,该偏移量可以应用于现有段,然后可以安全地对其进行解引用。或者,如果不存在这样的段,则客户端可以使用
MemoryAddress::asSegment
工厂方法 不安全地 创建一个。此工厂方法实际上为原始内存地址附加了新的空间和时间界限,以便允许解引用操作。该工厂方法返回的内存段是 不安全的:原始内存地址可能与一个长度为 10 字节的内存区域相关联,但客户端可能会错误地高估该区域的大小,并创建一个长度为 100 字节的不安全内存段。这可能会导致后续尝试解引用与不安全段相关联的内存区域界限之外的内存,从而导致 JVM 崩溃,或者更糟糕的是,导致静默内存损坏。因此,创建不安全段被视为 受限操作,并默认禁用(更多信息请参见 下文)。
查找外部函数
支持外部函数的首要条件是具备加载本地库的机制。在 JNI 中,这是通过 System::loadLibrary
和 System::load
方法实现的,这些方法内部映射为对 dlopen
或其等价函数的调用。使用这些方法加载的库始终与类加载器相关联(即调用 System
方法的类的加载器)。库与类加载器之间的关联至关重要,因为它决定了加载库的 生命周期:只有当类加载器不再可达时,才能 安全地 卸载其所有库。
FFM API 没有提供新的方法来加载本地库。开发人员使用 System::loadLibrary
和 System::load
方法来加载将通过 FFM API 调用的本地库。库与类加载器之间的关联得以保留,因此,库的卸载方式与 JNI 相同,具有可预测性。
与 JNI 不同,FFM API 提供了在已加载的库中找到给定符号地址的能力。这种能力由 SymbolLookup
对象表示,对于将 Java 代码链接到外部函数至关重要(见 下文)。有两种方式可以获得 SymbolLookup
对象:
SymbolLookup::loaderLookup
返回一个符号查找器,该查找器可以看到当前类加载器加载的所有库中的所有符号。CLinker::systemLookup
返回一个特定于平台的符号查找器,该查找器可以看到标准 C 库中的符号。
给定一个符号查找器,客户端可以使用 SymbolLookup::lookup(String)
方法来查找外部函数。如果命名的函数存在于符号查找器可见的符号中,则该方法将返回一个 MemoryAddress
,该地址指向函数的入口点。例如,以下代码加载 OpenGL 库(使其与当前类加载器相关联),并找到其 glGetString
函数的地址:
System.loadLibrary("GL");
SymbolLookup loaderLookup = SymbolLookup.loaderLookup();
MemoryAddress glGetStringAddress = 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 代码通过调用该句柄的 invokeExact
方法来执行下行调用,从而运行外部函数。任何传递给 invokeExact
方法的参数都会传递给外部函数。
对于上行调用,upcallStub
方法接受一个方法句柄(通常是指向 Java 方法的方法句柄,而不是下行调用方法句柄),并将其转换为内存地址。之后,当 Java 代码调用下行调用方法句柄时,该内存地址将作为参数传递。实际上,这个内存地址起到了函数指针的作用。(有关上行调用的更多信息,请参阅 下文。)
假设我们希望从 Java 调用标准 C 库中定义的 strlen
函数:
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)
);
调用这个下行调用方法句柄将运行 strlen
函数,并将结果返回给 Java。对于 strlen
的参数,我们使用一个辅助方法将 Java 字符串转换为堆外内存段,并传递该内存段的地址:
MemorySegment str = CLinker.toCString("Hello", newImplicitScope());
long len = strlen.invokeExact(str.address()); // 5
方法句柄在暴露外部函数方面表现良好,因为 JVM 已经对方法句柄的调用进行了优化,直至原生代码。当方法句柄引用 class
文件中的方法时,调用该方法句柄通常会导致目标方法被即时编译(JIT-compiled);随后,JVM 通过将控制权转移到为目标方法生成的汇编代码来解释调用 MethodHandle::invokeExact
的 Java 字节码。因此,调用传统的方法句柄已经是一种准外部调用;一个以 C 库中的函数为目标的向下调用方法句柄只是方法句柄的一种更外部的形式。方法句柄还具有一个称为 签名多态性 的特性,它允许使用原始参数进行无装箱调用。总之,方法句柄使 CLinker
能够以自然、高效和可扩展的方式暴露外部函数。
在 Java 中描述 C 类型
为了创建向下调用方法句柄,FFM API 要求客户端提供目标 C 函数的双面视图:使用 不透明Java 对象(MemoryAddress
、MemorySegment
)的高级签名,以及使用 透明Java 对象(MemoryLayout
)的低级签名。依次来看每个签名:
高级签名,即
MethodType
,作为向下调用方法句柄的类型。每个方法句柄都是强类型的,这意味着它对可以传递给其invokeExact
方法的参数数量和类型有严格要求。例如,为接受一个MemoryAddress
参数而创建的方法句柄不能通过invokeExact(<MemoryAddress>, <MemoryAddress>)
或invokeExact("Hello")
来调用。因此,MethodType
描述了客户端在调用向下调用方法句柄时必须使用的 Java 签名。它实际上是 C 函数的 Java 视图。低级签名,即
FunctionDescriptor
,由MemoryLayout
对象组成。这使得CLinker
能够精确理解 C 函数的参数,从而能够按照下文所述的方式正确排列它们。客户端通常手头上有MemoryLayout
对象,以便取消引用外部内存中的数据,这些对象可以在此作为外部函数签名重复使用。
作为一个示例,要为一个接受 int
类型参数并返回 long
类型的 C 函数获取向下调用方法句柄,需要向 downcallHandle
方法提供以下 MethodType
和 FunctionDescriptor
参数:
MethodType mtype = MethodType.methodType(long.class, int.class);
FunctionDescriptor fdesc = FunctionDescriptor.of(C_LONG, C_INT);
(此示例针对 Linux/x64 和 macOS/x64 平台,其中 Java 类型 long
和 int
分别与预定义的 CLinker
布局 C_LONG
和 C_INT
相关联。Java 类型与内存布局的关联因平台而异;例如,在 Windows/x64 上,Java 的 long
与 C_LONG_LONG
布局相关联。)
再举一个例子,要为一个接受指针的 void
类型 C 函数获取向下调用方法句柄,需要以下 MethodType
和 FunctionDescriptor
:
MethodType mtype = MethodType.methodType(void.class, MemoryAddress.class);
FunctionDescriptor fdesc = FunctionDescriptor.ofVoid(C_POINTER);
(在 Java 中,C 中的所有指针类型都表示为 MemoryAddress
对象;其对应的布局大小取决于当前平台,即为 C_POINTER
。客户端无需区分例如 int*
和 char**
,因为传递给 CLinker
的 Java 类型和内存布局共同包含了足够的信息,以将 Java 参数正确传递给 C 函数。)
最后,与 JNI 不同,CLinker
支持将结构化数据传递给外部函数。若要为接受结构体的 void
类型 C 函数获取向下调用方法句柄,需要以下 MethodType
和 FunctionDescriptor
:
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
签名,当 C 函数期望通过值传递结构体时,Java 客户端总是使用不透明类型 MemorySegment
。对于低级 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
使用函数描述符来推断在通过 MemorySegment
参数调用向下调用方法句柄时,如何将该结构体的字段传递给 C 函数。对于一种调用约定,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_LONG
和 long.class
来映射 C 中的 size_t
类型,并使用 MemoryAddress.class
作为第一个指针参数(数组指针)和最后一个参数(函数指针)。
qsort
使用自定义的比较器函数 compar
对数组内容进行排序,该函数作为函数指针传递。因此,为了调用向下调用方法句柄,我们需要一个函数指针作为最后一个参数传递给方法句柄的 invokeExact
方法。CLinker::upcallStub
通过使用现有的方法句柄来帮助我们创建函数指针,如下所示。
首先,我们在 Java 中编写一个 static
方法,该方法比较两个以 MemoryAddress
对象间接表示的 long
值:
class Qsort {
static int qsortCompare(MemoryAddress addr1, MemoryAddress addr2) {
return MemoryAccess.getIntAtOffset(MemorySegment.globalNativeSegment(),
addr1.toRawLongValue()) -
MemoryAccess.getIntAtOffset(MemorySegment.globalNativeSegment(),
addr2.toRawLongValue());
}
}
注意:这里假设我们是在处理一个整型数组,并且使用 MemoryAccess
和 MemorySegment
来访问这些整数值。在实际情况中,你可能需要根据 qsort
调用中传递的 size
参数和数组的实际类型来调整比较逻辑。此外,MemorySegment.globalNativeSegment()
的使用仅作为示例,实际中你可能需要使用与你的数据结构更匹配的内存段。此外,请注意,这里的 qsortCompare
方法可能需要进一步适配以正确处理不同类型的数据和边界情况。
接下来,我们创建一个指向 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
,它指向一个存根(stub),该存根可用于调用我们的 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 数组的内容复制到这个堆外数组中,然后将数组以及我们从 CLinker
获得的比较器函数一起传递给 qsort
句柄。调用之后,堆外数组的内容将根据我们用 Java 编写的比较器函数进行排序。然后,我们从该段中提取一个新的 Java 数组,其中包含排序后的元素。
安全性
从根本上讲,Java 代码与本地代码之间的任何交互都可能破坏 Java 平台的完整性。链接到预编译库中的 C 函数本质上是不可靠的,因为 Java 运行时无法保证该函数的签名与 Java 代码的期望相匹配,甚至无法保证 C 库中的符号真的是一个函数。此外,如果链接了合适的函数,实际调用该函数可能会导致低级故障,如段错误,最终导致虚拟机崩溃。Java 运行时无法防止此类故障,Java 代码也无法捕获它们。
使用 JNI 函数的本地代码尤其危险。此类代码可以通过使用如 getStaticField
和 callVirtualMethod
等函数,无需命令行标志(如 --add-opens
)即可访问 JDK 内部。它还可以在 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 中调用下调用方法句柄将导致与在 JNI 中调用 native
方法时相同的结果——VM 崩溃或未定义行为。FFM API 还可能产生不安全的段,即其空间和时间边界由用户提供且无法由 Java 运行时验证的内存段(参见 MemoryAddress::asSegment
)。
FFM API 中的不安全方法并不具有与 JNI 函数相同的风险;例如,它们不能更改 Java 对象中 final
字段的值。另一方面,FFM API 中的不安全方法很容易从 Java 代码中调用。因此,FFM API 中不安全方法的使用是 受限的:默认情况下,访问不安全方法是禁用的,因此调用这些方法会抛出 IllegalAccessException
。要为某个模块 M 中的代码启用对不安全方法的访问,请在命令行上指定 java --enable-native-access=M
。(在逗号分隔的列表中指定多个模块;指定 ALL-UNNAMED
以为类路径上的所有代码启用访问。)FFM API 的大多数方法是安全的,Java 代码可以无论是否给出 --enable-native-access
都使用这些方法。
我们并不打算在这里限制 JNI 的任何方面。在 Java 中调用 native
方法以及让本地代码调用不安全的 JNI 函数仍然是可能的。然而,在未来的版本中,我们可能会以某种方式限制 JNI。例如,类似于 FFM API 中的不安全方法,诸如 newDirectByteBuffer
这样的不安全 JNI 函数可能会被默认禁用。更广泛地说,JNI 机制是如此地不可挽回地危险,我们希望库能更偏爱纯 Java 的 FFM API 来执行安全和不安全的操作,以便我们能够逐渐默认禁用所有 JNI。这与 Java 更广泛的路线图相一致,即让平台开箱即用就是安全的,要求最终用户选择参与不安全的活动,如破坏强封装或链接到未知代码。
我们并不打算在这里以任何方式更改 sun.misc.Unsafe
。FFM API 对堆外内存的支持是对 sun.misc.Unsafe
中围绕 malloc
和 free
的包装(即 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 使用本地库的开销。