JEP 442:外部函数和内存 API(第三次预览)
总结
引入一个 API,通过它 Java 程序可以与 Java 运行时之外的代码和数据进行互操作。通过高效调用外部函数(即 JVM 之外的代码),以及安全访问外部内存(即不由 JVM 管理的内存),该 API 使 Java 程序能够调用原生库并处理原生数据,而无需担心 JNI 的脆弱性和危险性。这是一个 预览版 API。
历史
目标
-
易用性 — 使用更优越的纯 Java 开发模型替代 Java 原生接口(JNI)。
-
性能 — 提供与现有 API(如 JNI 和
sun.misc.Unsafe
)相当甚至更好的性能。 -
通用性 — 提供操作不同种类外部内存(例如,原生内存、持久内存和托管堆内存)的方式,并随着时间推移,适应其他平台(例如,32 位 x86)以及使用非 C 语言(例如,C++、Fortran)编写的外部函数。
-
安全性 — 允许程序对外部内存执行不安全的操作,但默认情况下会向用户发出此类操作的警告。
非目标
它不是一个目标,旨在
- 在此 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 运行时之外的代码和数据。
外部内存
使用 new
关键字创建的对象存储在 JVM 的 堆 中,当这些对象不再被需要时,它们会被垃圾回收机制回收。然而,对于像 Tensorflow、Ignite、Lucene 和 Netty 这样对性能要求极高的库来说,垃圾回收的成本和不可预测性是不可接受的。它们需要将数据存储在堆外的 非堆内存 中,并自行分配和释放这些内存。通过访问堆外内存,还可以通过例如 mmap
等方式将文件直接映射到内存中,从而实现数据的序列化和反序列化。
Java 平台历史上提供了两个用于访问堆外内存的 API:
-
ByteBuffer
API 提供了直接字节缓冲区,它们是通过堆外固定大小的内存区域支持的 Java 对象。然而,一个区域的最大大小被限制为 2 GB,并且用于读写内存的方法非常基础且容易出错,提供的功能几乎只是对原始值的索引访问。更严重的是,支持直接字节缓冲区的内存在缓冲区对象被垃圾回收之前不会被释放,而开发者无法控制垃圾回收的时机。由于缺乏对及时释放的支持,使得ByteBuffer
API 不适合用于 Java 的系统编程。 -
sun.misc.Unsafe
API 提供了对堆内存的低级访问,同时也适用于堆外内存。使用Unsafe
很快(因为其内存访问操作会被 JVM 内联优化),可以分配超大的堆外内存区域(理论上最大可达 16 EB),并提供了对释放内存的细粒度控制(因为可以随时调用Unsafe::freeMemory
)。然而,这种编程模型存在缺陷,因为它赋予了开发者过多的控制权。在长时间运行的服务器应用程序中,库会随着时间推移分配并与多个堆外内存区域交互;一个区域中的数据可能指向另一个区域中的数据,这些区域必须按照正确的顺序释放,否则悬空指针会导致释放后使用(use-after-free)的错误。由于缺乏对安全释放的支持,Unsafe
API 也不适合用于 Java 的系统编程。(同样的批评也适用于 JDK 外部的一些 API,这些 API 通过包装调用
malloc
和free
的原生代码来提供细粒度的分配和释放功能。)
总之,成熟的客户端应该配有一个 API,该 API 能够像分配、操作和共享堆内存一样流畅且安全地处理堆外内存。这样的 API 应该在可预测的释放需求与防止可能导致 JVM 崩溃或更糟糕的静默内存损坏的过早释放之间取得平衡。
外部函数
自 Java 1.1 起,JNI 就支持调用原生代码(即外部函数),但出于许多原因,它并不完善。
-
JNI 涉及到几个繁琐的构件:Java API(
native
方法)、从 Java API 派生的 C 头文件,以及调用相关本地库的 C 实现。Java 开发人员必须在多个工具链中工作,以保持与平台相关的构件同步,当本地库快速演进时,这尤其繁重。 -
JNI 只能与使用 JVM 构建所针对的操作系统和 CPU 调用约定的语言(通常是 C 和 C++)编写的库进行互操作。
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 代码变得极其不安全!
最终,Java 开发者应该拥有一个受支持的 API,使他们能够直接使用任何被认为对特定任务有用的原生库,而无需 JNI 的繁琐粘连和笨拙。一个极佳的抽象构建基础是 method handles(方法句柄),它在 Java 7 中被引入以支持 JVM 上的快速动态语言。通过方法句柄暴露原生代码将极大地简化编写、构建和分发依赖于原生库的 Java 库的任务。此外,一个能够建模外来函数(即,原生代码)和外来内存(即,堆外数据)的 API 将为第三方原生互操作框架提供坚实的基础。
描述
外部函数和内存 API(FFM API)定义了类和接口,以便库和应用程序中的客户端代码可以使用它们。
- 控制外部内存的分配与释放
(MemorySegment
、Arena
和SegmentAllocator
), - 操作和访问结构化外部内存
(MemoryLayout
和VarHandle
),以及 - 调用外部函数 (
Linker
、FunctionDescriptor
和SymbolLookup
)。
FFM API 位于 java.base
模块的 java.lang.foreign
包中。
示例
作为使用 FFM API 的一个简单示例,以下是获取 C 库函数 radixsort
的方法句柄并使用它对四个字符串进行排序的 Java 代码,这些字符串最初位于 Java 数组中(省略了一些细节)。
由于 FFM API 是一个预览版 API,因此您必须在启用预览功能的情况下编译和运行代码,即 javac --release 21 --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.ofConfined()) {
// 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
一样), - 一个映射段,包裹着一块映射的堆外内存区域(就像通过
mmap
一样),或者 - 一个数组或缓冲区段,分别包裹着与现有 Java 数组或字节缓冲区相关联的一块堆内内存区域。
所有内存段都提供了空间和时间界限,确保内存访问操作的安全性。简而言之,这些界限保证了不会使用未分配的内存,也不会在释放后继续使用。
段的空间边界决定了与该段关联的内存地址范围。例如,下面的代码分配了一个 100 字节的原生段,因此关联的地址范围是从某个基址 b
到 b + 99
(含)。
MemorySegment data = Arena.global().allocate(100);
一个段的 时间界限 决定了其生命周期,即支持该段的内存区域被释放之前的时段。FFM API 保证在支持段的内存区域被释放后,无法再访问该内存段。
段的临时界限由用于分配该段的arena 决定。在相同 arena 中分配的多个段具有相同的临时界限,因此可以安全地包含相互引用:段 A
可以持有指向段 B
中地址的指针,段 B
也可以持有指向段 A
中地址的指针,并且两个段会同时被释放,从而避免了任一段出现悬空指针的情况。
最简单的区域是全局区域,它提供了一个无限制的生命周期:它始终存在。在全局区域中分配的段,就像上面的代码一样,始终可访问,并且支持该段的内存区域永远不会被释放。
然而,大多数程序需要在运行时释放堆外内存,因此需要具有有限生命周期的内存段。
一个自动区域(arena)提供了一个有界的生命周期:由自动区域分配的段(segment)可以一直访问,直到 JVM 的垃圾回收器检测到该内存段不可达,此时支持该段的内存区域将被释放。例如,以下方法在自动区域中分配了一个段:
// 示例代码不翻译
void processData() {
MemorySegment data = Arena.ofAuto().allocateNative(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.openConfined();
MemorySegment segment1 = arena.allocate(100, 1);
MemorySegment segment2 = arena.allocate(50, 1);
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 分配的所有 segment 会被自动失效,并且支持这些 segment 的内存区域也会被释放。
受限区域的确定性生命周期是有代价的:只有一个线程可以访问在受限区域中分配的内存段。如果多个线程需要访问某个段,则可以使用共享区域。在共享区域中分配的内存段可以被多个线程访问,并且任何线程——无论是否访问该区域——都可以关闭该区域以释放这些段。关闭区域会原子性地使这些段失效,但由于需要昂贵的同步操作来检测并取消针对这些段的待处理并发访问操作,因此支持这些段的内存区域的释放可能不会立即发生。
总之,arena 控制哪些线程可以访问内存段,以及何时访问,从而提供强大的时间安全性和可预测的性能模型。FFM API 提供了多种 arena 选择,以便客户端可以在访问广度和释放及时性之间进行权衡。
解引用段
要解引用内存段中的某些数据,我们需要考虑几个因素:
- 要解引用的字节数,
- 发生解引用的地址的对齐约束,
- 字节在内存段中存储的字节序,以及
- 解引用操作中使用的 Java 类型(例如,
int
与float
)。
所有这些特性都包含在 ValueLayout
抽象中。例如,预定义的 JAVA_INT
值布局宽 4 字节,在 4 字节边界上对齐,使用本地平台的字节序(例如,在 Linux/x64 上为小端序),并关联到 Java 类型 int
。
内存段具有简单的解引用方法,用于从内存段读取和写入值。这些方法接受一个值布局,该布局唯一地指定了解引用操作的属性。例如,我们可以在内存段的连续偏移位置写入 25 个 int
值:
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
结构体有两个成员,分别是 Point.x
和 Point.y
:
struct Point {
int x;
int y;
} pts[10];
使用上一节中展示的解引用方法,要初始化这样的原生数组,我们必须编写以下代码(我们假设 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
),可以使用 MemoryLayout
以更声明式的方式描述内存段的内容。例如,上述示例中本地内存段的所需布局可以通过以下方式描述:
SequenceLayout ptsLayout
= MemoryLayout.sequenceLayout(10,
MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("x"),
ValueLayout.JAVA_INT.withName("y")));
这将创建一个 sequence memory layout,其中包含十个重复的 struct layout,其元素分别是名为 x
和 y
的两个 JAVA_INT
布局。有了这个布局,我们可以通过创建两个 memory-access var handles 来避免在代码中计算偏移量,这些特殊的 var handles 接受一个 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 = Arena.ofAuto().allocate(ptsLayout);
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 类型 int
相关联,因此生成的变量句柄 xHandle
和 yHandle
的类型也将是 int
。此外,由于所选的值布局定义在一个序列布局内,这些变量句柄会获得一个额外的 long
类型坐标,即要读取或写入的 Point
结构体的索引。ptsLayout
对象还驱动了本地内存段的分配,这是基于从布局中获取的大小和对齐信息。由于使用了不同的变量句柄来初始化 Point.x
和 Point.y
元素,循环内部不再需要进行偏移量计算。
段分配器
当客户端使用堆外内存时,内存分配通常会成为一个瓶颈。因此,FFM API 包含了一个 SegmentAllocator
抽象,用于定义分配和初始化内存段的操作。为了方便起见,Arena
类实现了 SegmentAllocator
接口,这样就可以使用 Arena 来分配本地内存段。换句话说,Arena
是一个灵活分配和及时释放堆外内存的“一站式解决方案”:
try (Arena offHeap = Arena.ofConfined()) {
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);
...
} // memory released here
段分配器也可以通过 SegmentAllocator
接口中的工厂方法获取。例如,其中一个工厂方法创建了一个 切片分配器,它通过返回先前已分配段的一部分的内存段来响应分配请求;因此,许多请求可以在不实际分配更多内存的情况下得到满足。以下代码从现有段中获取一个切片分配器,然后使用它分配一个从 Java 数组初始化的段:
// 示例代码
MemorySegment segment = ...
SegmentAllocator allocator = SegmentAllocator.slicingAllocator(segment);
for (int i = 0 ; i < 10 ; i++) {
MemorySegment s = allocator.allocateArray(JAVA_INT,
1, 2, 3, 4, 5);
...
}
段分配器可以用作构建块来创建支持自定义分配策略的区域。例如,如果有大量原生段共享相同的有限生命周期,则自定义区域可以使用切片分配器来高效地分配这些段。这使得客户端既能享受可扩展的分配(得益于切片),又能享受确定性的释放(得益于区域)。
例如,以下代码定义了一个切片分配区,其行为类似于受限分配区,但内部使用切片分配器来响应分配请求。当切片分配区关闭时,底层的受限分配区也会关闭,从而使在切片分配区中分配的所有段无效。(部分细节已省略。)
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.allocateArray(JAVA_INT,
1, 2, 3, 4, 5);
...
}
} // all memory allocated is released here
查找外部函数
任何对外部函数支持的第一个要素都是一个在已加载的原生库中查找给定符号地址的机制。这种能力由 SymbolLookup
对象表示,对于将 Java 代码链接到外部函数至关重要(见下文)。FFM API 支持三种不同类型的符号查找对象:
-
SymbolLookup::libraryLookup(String, Arena)
创建一个库查找,它定位用户指定的原生库中的所有符号。创建查找对象会导致库被加载(例如,使用dlopen()
),并关联到一个Arena
对象。当提供的 arena 关闭时,库会被卸载(例如,使用dlclose()
)。 -
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
相比之下,FFM API 并未提供用于本地代码访问 Java 环境的函数,也不假设本地库是为与 FFM API 配合使用而设计的。通过 SymbolLookup::libraryLookup(String, Arena)
加载的本地库并不知道它们正被运行在 JVM 中的代码访问,也并不会尝试执行 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
(或 invokeExact
)方法来调用这个向下调用方法句柄,外部函数便会运行。传递给方法句柄 invoke
方法的任何参数都会被传递给外部函数。
对于上行调用,upcallStub
方法接受一个方法句柄 —— 通常是指向 Java 方法的句柄,而不是下行调用方法句柄 —— 并将其转换为 MemorySegment
实例。随后,当 Java 代码调用下行调用方法句柄时,该内存段作为参数传递。实际上,内存段充当了函数指针的作用。(有关上行调用的更多信息,请参见下文。)
假设我们希望从 Java 向标准 C 库中定义的 strlen
函数进行下层调用:
size_t strlen(const char *s);
客户端可以使用 native linker(原生链接器,参见 Linker::nativeLinker
)来链接 C 函数,这是一种符合由运行 JVM 的操作系统和 CPU 所决定的 ABI 的 Linker
实现。可以通过以下方式获取一个暴露 strlen
的 downcall 方法句柄(FunctionDescriptor
的细节将在稍后描述):
Linker linker = Linker.nativeLinker();
MethodHandle strlen = linker.downcallHandle(
linker.defaultLookup().find("strlen").get(),
FunctionDescriptor.of(JAVA_LONG, ADDRESS)
);
调用向下调用方法句柄将运行 strlen
并使其结果在 Java 中可用。对于传递给 strlen
的参数,我们使用一个辅助方法,通过使用受限的竞技场(confined arena)将 Java 字符串转换为堆外内存段(off-heap memory segment),然后通过引用传递:
try (Arena arena = Arena.ofConfined()) {
MemorySegment str = arena.allocateUtf8String("Hello");
long len = (long) strlen.invoke(str); // 5
}
方法句柄在暴露外部函数时表现良好,因为 JVM 已经优化了方法句柄的调用,直至将其优化为本地代码。当一个方法句柄引用 class
文件中的某个方法时,调用该方法句柄通常会导致目标方法被 JIT 编译;随后,JVM 通过将控制权转移给为目标方法生成的汇编代码,来解释调用 MethodHandle::invokeExact
的 Java 字节码。因此,Java 中的传统方法句柄在后台会指向非 Java 代码;而下向调用方法句柄是一种自然的扩展,它允许开发者显式地指向非 Java 代码。方法句柄还具备一种称为 签名多态性 的特性,这使得使用原始类型参数调用时无需装箱操作。总之,方法句柄使 Linker
能够以一种自然、高效且可扩展的方式暴露外部函数。
在 Java 中描述 C 类型
要创建一个 downcall 方法句柄,FFM API 要求客户端提供一个 FunctionDescriptor
,用以描述目标 C 函数的 C 参数类型和 C 返回类型。在 FFM API 中,C 类型通过 MemoryLayout
对象来描述,例如用于标量 C 类型的 ValueLayout
和用于 C 结构体类型的 GroupLayout
。客户端通常会手头持有 MemoryLayout
对象以解引用外来内存中的数据,并可以重用它们来获取 FunctionDescriptor
。
FFM API 还使用 FunctionDescriptor
来推导下向调用方法句柄的类型。每个方法句柄都是强类型的,这意味着它对可以传递给其 invokeExact
方法的参数数量和类型要求非常严格。例如,为接受一个 MemorySegment
参数而创建的方法句柄不能通过 invokeExact(<MemorySegment>, <MemorySegment>)
调用,即使 invokeExact
是一个可变参数方法。下向调用方法句柄的类型描述了客户端在调用该方法句柄时必须使用的 Java 签名。实际上,这是对 C 函数的 Java 视图。
例如,假设一个 downcall 方法句柄应该暴露一个接受 C 语言 int
类型并返回 C 语言 long
类型的 C 函数。在 Linux/x64 和 macOS/x64 上,C 语言类型 long
和 int
分别与预定义布局 JAVA_LONG
和 JAVA_INT
相关联,因此所需的 FunctionDescriptor
可以通过 FunctionDescriptor.of(JAVA_LONG, JAVA_INT)
获得。然后,本地链接器会将 downcall 方法句柄的类型安排为从 Java 签名 int
到 long
。
如果客户端针对使用诸如 long
、int
和 size_t
等标量类型的 C 函数,则必须了解当前平台。这是因为标量 C 类型与布局常量的关联因平台而异。在 Windows/x64 上,C 语言中的 long
与 JAVA_INT
布局相关联,因此所需的 FunctionDescriptor
将是 FunctionDescriptor.of(JAVA_INT, JAVA_INT)
,而下调方法句柄的类型将是 Java 签名 int
到 int
。
再举一个例子,假设一个下调方法句柄应该公开一个接受指针的 void C 函数。在所有平台上,C 指针类型都与预定义布局 ADDRESS
相关联,因此可以通过 FunctionDescriptor.ofVoid(ADDRESS)
获取所需的 FunctionDescriptor
。然后,本地链接器会将下调方法句柄的类型安排为 Java 签名 MemorySegment
到 void
。也就是说,根据相应函数描述符中指定的布局,MemorySegment
参数可以按引用传递,也可以按值传递。
客户端可以在不了解当前平台的情况下使用 C 语言指针。客户端不需要知道当前平台上指针的大小,因为 ADDRESS
布局的大小是从当前平台推断出来的,客户端也不需要区分 C 指针类型,例如 int*
和 char**
。
最后,与 JNI 不同,本地链接器支持将结构化数据传递给外部函数。假设一个 downcall 方法句柄应该暴露一个接受结构体的 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
会将 downcall 方法句柄的类型安排为 Java 签名 MemorySegment
到 void
。
与 C 结构类型关联的内存布局必须是复合布局,该布局定义了 C 结构中所有字段的子布局,包括任何平台相关的填充(原生编译器可能会插入的)。
如果一个 C 函数返回一个按值传递的结构体(此处未展示),那么必须在堆外分配一段新的内存,并将其返回给 Java 客户端。为了实现这一点,downcallHandle
返回的方法句柄需要一个额外的 SegmentAllocator
参数,FFM API 使用该参数来分配一个内存段以保存 C 函数返回的结构体。
如前所述,虽然原生链接器实现专注于提供 Java 和 C 库之间的互操作性,但 Linker
接口是与语言无关的:它对 C 语言类型的定义没有特定的知识,因此客户端需要负责获取适用于 C 类型的布局定义。这一选择是经过深思熟虑的,因为 C 类型的布局定义(无论是简单的标量还是复杂的结构体)最终都依赖于平台,因此可以由一个对给定目标平台有深入了解的工具自动生成。
为 C 函数打包 Java 参数
调用约定 通过指定一种语言中的代码如何调用另一种语言中的函数、传递参数以及接收结果,从而实现不同语言之间的互操作。Linker
API 在调用约定方面是中立的,但原生链接器实现开箱即用地支持多种调用约定:Linux/x64、Linux/AArch64、Linux/RISC-V、macOS/x64、macOS/AArch64、Windows/x64 和 Windows/AArch64。其他平台 通过 备用链接器 提供支持,该链接器是一种基于 libffi
的原生链接器实现。由于 Linker
API 是用 Java 编写的,因此与 JNI 相比,它更易于维护和扩展,因为 JNI 的调用约定是硬编码到 HotSpot 的 C++ 代码中的。
考虑上面获得的 SYSTEMTIME
结构/布局的 FunctionDescriptor
。根据运行 JVM 的操作系统和 CPU 的调用约定,本地链接器使用 FunctionDescriptor
来推断当使用 MemorySegment
参数调用 downcall 方法句柄时,应该如何将结构的字段传递给 C 函数。对于一种调用约定,本地链接器实现可以安排分解传入的内存段,使用通用 CPU 寄存器传递前四个字段,并在 C 栈上传递剩余的字段。对于另一种调用约定,本地链接器实现可以安排通过分配一块内存区域来间接传递该结构,将传入内存段的内容批量复制到该区域中,并将指向该区域的指针传递给 C 函数。这种最低级别的参数打包是在幕后进行的,不需要客户端代码的任何监督。
零长度内存段
外部函数通常会分配一块内存区域,并返回指向该区域的指针。使用内存段来建模这样的区域是具有挑战性的,因为该区域的大小对 Java 运行时不可用。例如,返回类型为 char*
的 C 函数可能会返回指向包含单个 char
值的区域的指针,或者返回指向以 '\0'
结尾的 char
值序列的区域的指针。调用外部函数的代码无法直接获知该区域的大小。
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
,我们首先需要创建一个 downcall 方法句柄:
Linker linker = Linker.nativeLinker();
MethodHandle qsort = linker.downcallHandle(
linker.defaultLookup().find("qsort").get(),
FunctionDescriptor.ofVoid(ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS)
);
和之前一样,我们使用 JAVA_LONG
布局来映射 C 语言中的 size_t
类型,并且对第一个指针参数(数组指针)和最后一个参数(函数指针)都使用 ADDRESS
布局。
qsort
使用作为函数指针传入的自定义比较函数 compar
对数组的内容进行排序。因此,要调用 downcall 方法句柄,我们需要一个函数指针作为最后一个参数传递给方法句柄的 invokeExact
方法。Linker::upcallStub
帮助我们通过使用现有的方法句柄来创建函数指针,如下所示。
首先,我们在 Java 中编写一个 static
方法,比较两个由 MemorySegment
对象间接表示的 int
值:
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.allocateArray(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.toIntArray(); // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
}
此代码创建一个堆外数组,将 Java 数组的内容复制到其中,然后将该数组连同我们从本地链接器获得的比较函数一起传递给 qsort
句柄。调用完成后,堆外数组的内容将根据我们用 Java 编写的比较函数进行排序。然后我们从段中提取一个新的 Java 数组,其中包含已排序的元素。
安全性
从根本上讲,Java 代码与本地代码之间的任何交互都可能破坏 Java 平台的完整性。链接到预编译库中的 C 函数本质上是不可靠的,因为 Java 运行时无法保证该函数的签名与 Java 代码的预期相匹配,甚至无法保证 C 库中的符号确实是一个函数。此外,即使链接了一个合适的函数,实际调用该函数也可能导致低级故障(例如段错误),从而导致虚拟机崩溃。这些故障无法由 Java 运行时防止,也无法被 Java 代码捕获。
使用 JNI 函数的原生代码尤其危险。此类代码可以通过使用诸如 getStaticField
和 callVirtualMethod
等函数来访问 JDK 内部,而无需命令行标志(例如 --add-opens
)。它还可以在 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 代码可以通过指定与底层外部函数不兼容的参数类型来请求一个 downcall 方法句柄。在 Java 中调用该 downcall 方法句柄将导致与在 JNI 中调用 native
方法时可能出现的相同结果 —— 虚拟机崩溃或未定义行为。FFM API 还可以生成不安全的段,即空间和时间界限由用户提供且无法被 Java 运行时验证的内存段(参见 MemorySegment::reinterpret
)。
FFM API 中的不安全方法并不像 JNI 函数那样存在相同的风险;例如,它们无法更改 Java 对象中 final
字段的值。另一方面,FFM API 中的不安全方法很容易从 Java 代码调用。出于这个原因,FFM API 中不安全方法的使用是 受限制的:允许使用这些方法,但默认情况下,每次使用都会在运行时发出警告。要允许模块 M
中的代码在不发出警告的情况下使用不安全方法,请在 java
命令行中指定 --enable-native-access=M
选项。(通过逗号分隔列表指定多个模块;指定 ALL-UNNAMED
以允许类路径上的所有代码无警告使用。)当此选项存在时,任何来自指定模块列表之外的不安全方法的使用都将导致抛出 IllegalCallerException
,而不是发出警告。在未来的版本中,可能需要此选项才能使用不安全方法。
我们在此并不打算限制 JNI 的任何方面。在 Java 中仍然可以调用 native
方法,并且原生代码也可以调用不安全的 JNI 函数。然而,在未来的版本中,我们可能会以某种方式限制 JNI。例如,像 newDirectByteBuffer
这样的不安全 JNI 函数可能会默认被禁用,就像 FFM API 中的不安全方法一样。更广泛地说,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 中使用本地库的开销。