JEP 389: Foreign Linker API (Incubator) | 外部链接器 API(孵化器)
摘要
引入一个 API,该 API 提供静态类型、纯 Java 访问本地代码的功能。此 API 与 Foreign-Memory API(JEP 393)一起,将大大简化原本容易出错的绑定到本地库的过程。
历史
Foreign-Memory Access API 为此 JEP 提供了基础,最初由 JEP 370 提出,并作为 孵化 API 计划在 2019 年底的 Java 14 中推出,随后由 JEP 383 和 JEP 393 更新,分别针对 Java 15 和 16。Foreign-Memory Access API 和 Foreign Linker API 共同构成了 Project Panama 的关键成果。
目标
易用性:用更高级的纯 Java 开发模型替代 JNI。
C 语言支持:本工作的初步范围旨在在 x64 和 AArch64 平台上提供高质量、完全优化的与 C 库的互操作性。
通用性:Foreign Linker API 及其实现应足够灵活,以便随着时间的推移,能够支持其他平台(如 32 位 x86)和非 C 语言(如 C++、Fortran)编写的外部函数。
性能:Foreign Linker API 应提供与 JNI 相当或更优的性能。
非目标
以下不是本项目的目标:
- 废弃、重新实现或改进 JNI,
- 提供从本地代码头文件机械生成 Java 代码的工具,或
- 更改或改进 Java 应用程序与本地库交互时的打包和部署方式(例如,多平台 JAR 文件)。
动机
自 Java 1.1 以来,Java 就通过 Java 本地接口(JNI) 支持本地方法调用,但这条路径一直都很艰难且易碎。使用 JNI 封装一个本地函数需要开发多个工件:一个 Java API、一个 C 头文件和一个 C 实现。即使在工具帮助下,Java 开发人员也必须跨多个工具链工作,以保持多个与平台相关的工件的同步。对于稳定的 API 来说,这已经足够困难了,但在尝试跟踪进行中的 API 时,每次 API 演变都需要更新所有这些工件,这成为了一个沉重的维护负担。最后,JNI 主要关注的是代码,但代码总是涉及数据交换,而 JNI 在访问本地数据方面提供的帮助很少。因此,开发人员经常采用变通方法(如直接缓冲区或 sun.misc.Unsafe
),这些方法使得应用程序代码更难维护,甚至更不安全。
多年来,为了填补 JNI 留下的空白,涌现出了许多框架,包括 JNA、JNR 和 JavaCPP。JNA 和 JNR 根据用户定义的接口声明动态生成包装器;JavaCPP 则根据 JNI 方法声明上的注解静态生成包装器。尽管这些框架通常比 JNI 体验有了显著改善,但情况仍然不够理想,尤其是与提供一流本地互操作性的语言相比。例如,Python 的 ctypes 包可以无需任何胶水代码就动态地包装本地函数。其他语言,如 Rust,提供了可以从 C/C++ 头文件机械地派生本地包装器的工具。
最终,Java 开发人员应该能够(主要)直接使用任何被认为对特定任务有用的本地库——我们已经看到现状如何阻碍了这一目标的实现。本 JEP 通过引入一个高效且受支持的 API——Foreign Linker API——来纠正这种不平衡,该 API 提供对外部函数的支持,而无需任何介入的 JNI 胶水代码。它通过将外部函数暴露为方法句柄来实现这一点,这些句柄可以在纯 Java 代码中声明和调用。这极大地简化了编写、构建和分发依赖于外部库的 Java 库和应用程序的任务。此外,Foreign Linker API 与 Foreign-Memory Access API 一起,为当前和未来的第三方本地互操作框架提供了一个坚实且高效的基础,这些框架可以可靠地构建于其上。
描述
在本节中,我们将深入探讨如何使用 Foreign Linker API 实现本地互操作。本节中描述的各种抽象将作为名为 jdk.incubator.foreign
的 孵化器模块 提供,该模块与现有的 Foreign Memory Access API 位于同名的包中。
符号查找
任何外部函数支持的首要组成部分是一种在本地库中查找符号的机制。在传统的 Java/JNI 场景中,这是通过 System::loadLibrary
和 System::load
方法完成的,这些方法在内部映射为对 dlopen
的调用。Foreign Linker API 通过 LibraryLookup
类(类似于方法句柄查找)提供了一个简单的库查找抽象,该类提供了在给定本地库中查找命名符号的功能。我们可以通过三种不同的方式获得库查找:
LibraryLookup::ofDefault
— 返回可以“看到”所有已随 VM 加载的符号的库查找。LibraryLookup::ofPath
— 创建一个与在给定绝对路径中找到的库相关联的库查找。LibraryLookup::ofLibrary
— 创建一个与具有给定名称的库相关联的库查找(这可能需要适当设置java.library.path
变量)。
一旦获得查找,客户端就可以使用它来检索库符号(全局变量或函数)的句柄,方法是使用 lookup(String)
方法。此方法返回一个新的 LibraryLookup.Symbol
,它仅作为内存地址和名称的代理。
例如,以下代码查找 clang
库提供的 clang_getClangVersion
函数:
LibraryLookup libclang = LibraryLookup.ofLibrary("clang");
LibraryLookup.Symbol clangVersion = libclang.lookup("clang_getClangVersion");
Foreign Linker API 与 JNI 的库加载机制之间有一个关键的区别,即加载的 JNI 库与类加载器相关联。此外,为了保持 类加载器完整性,相同的 JNI 库不能加载到多个类加载器中。而此处描述的外部函数机制更为原始:Foreign Linker API 允许客户端直接定位本地库,无需任何 JNI 代码介入。至关重要的是,Foreign Linker API 从不将 Java 对象传递给或从本地代码接收 Java 对象。因此,通过 LibraryLookup
加载的库不绑定到任何类加载器,并且可以根据需要多次(重新)加载。
C 链接器
CLinker
接口是 API 中外部函数支持的基础。
interface CLinker {
MethodHandle downcallHandle(LibraryLookup.Symbol func,
MethodType type,
FunctionDescriptor function);
MemorySegment upcallStub(MethodHandle target,
FunctionDescriptor function);
}
这个抽象扮演着双重角色。首先,对于 下行调用(例如从 Java 调用到本地代码),可以使用 downcallHandle
方法将本地函数建模为普通的 MethodHandle
对象。其次,对于 上行调用(例如从本地代码回调 Java 代码),可以使用 upcallStub
方法将现有的 MethodHandle
(可能指向某个 Java 方法)转换为 MemorySegment
,然后将其作为函数指针传递给本地函数。请注意,尽管 CLinker
抽象主要关注为 C 语言提供互操作性支持,但该抽象中的概念足够通用,未来可应用于其他外部语言。
downcallHandle
和 upcallStub
都接受一个 FunctionDescriptor
实例,它是一个内存布局的组合,用于完全描述一个外部函数的签名。CLinker
接口定义了许多布局常量,每个主要的 C 原始类型对应一个。这些布局可以使用 FunctionDescriptor
来组合,以描述 C 函数的签名。例如,我们可以使用以下描述符来模拟一个接受 char*
并返回 long
的 C 函数:
FunctionDescriptor func
= FunctionDescriptor.of(CLinker.C_LONG, CLinker.C_POINTER);
此示例中的布局映射到与底层平台相适应的布局,因此这些布局是平台依赖的:例如,C_LONG
在 Windows 上将是 32 位值布局,但在 Linux 上将是 64 位值。为了针对特定平台,提供了特定于平台的一组布局常量(例如,CLinker.Win64.C_LONG
)。
CLinker
类中定义的布局非常方便,因为它们模拟了我们想要与之工作的 C 类型。此外,它们还通过布局 属性 包含了一些隐藏的信息,这些信息由外部链接器用来计算与给定函数描述符相关联的调用序列。例如,C 类型 int
和 float
可能具有相似的内存布局(它们都是 32 位值),但通常使用不同的处理器寄存器进行传递。CLinker
类中附加到特定于 C 的布局的布局属性确保参数和返回值以正确的方式处理。
downcallHandle
和 upcallStub
同样接受(直接或间接)一个 MethodType
实例。方法类型描述了当客户端与生成的 downcall 句柄或 upcall 桩进行交互时将使用的 Java 签名。MethodType
实例中的参数和返回类型将与相应的布局进行验证。例如,链接器运行时将检查与给定参数 / 返回值相关联的 Java 载体的大小是否与相应布局的大小相等。原始布局到 Java 载体的映射可能因平台而异(例如,C_LONG
在 Linux/x64 上映射为 long
,但在 Windows 上映射为 int
),但指针布局(C_POINTER
)始终与 MemoryAddress
载体相关联,而结构体(其布局由 GroupLayout
定义)则始终与 MemorySegment
载体相关联。
Downcalls
假设我们想要调用标准 C 库中定义的以下函数:
size_t strlen(const char *s);
为了做到这一点,我们需要:
- 查找
strlen
符号, - 使用
CLinker
类中的布局描述 C 函数的签名, - 选择要覆盖到本地函数上的 Java 签名(这将是本地方法句柄的客户端将与之交互的签名),
- 使用
CLinker::downcallHandle
和上述信息创建一个 downcall 本地方法句柄。
下面是一个如何实现这一点的示例:
MethodHandle strlen = CLinker.getInstance().downcallHandle(
LibraryLookup.ofDefault().lookup("strlen"),
MethodType.methodType(long.class, MemoryAddress.class),
FunctionDescriptor.of(CLinker.C_LONG, CLinker.C_POINTER)
);
strlen
函数是标准 C 库的一部分,该库与虚拟机一起加载,因此我们可以直接使用默认查找来查找它。其余部分相当直接。唯一棘手的细节是我们如何建模 size_t
——通常这种类型具有指针的大小,所以在 Linux 上我们可以使用 C_LONG
,但在 Windows 上我们必须使用 C_LONG_LONG
。在 Java 方面,我们使用 long
来建模 size_t
,并使用 MemoryAddress
参数来建模指针。
一旦我们获得了 downcall 本地方法句柄,就可以像使用其他任何方法句柄一样使用它:
try (MemorySegment str = CLinker.toCString("Hello")) {
long len = strlen.invokeExact(str.address()); // 5
}
在这里,我们使用 CLinker
中的一个辅助方法来将 Java 字符串转换为包含以 NULL
结尾的 C 字符串的堆外内存段。然后,我们将该段传递给方法句柄,并将结果存储在 Java 的 long
中。
请注意,所有这一切都是在没有任何中间本地代码的情况下完成的——所有互操作代码都可以用(低级)Java 表示。
Upcalls
有时,将 Java 代码作为函数指针传递给某些本地函数是很有用的。我们可以使用对外链接器对 upcalls 的支持来实现这一点。为了演示这一点,请考虑在标准 C 库中定义的以下函数:
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
这是一个函数,它可以使用自定义的比较器函数 compar
(作为函数指针传递)来对数组的内容进行排序。为了能够从 Java 调用 qsort
函数,我们首先需要为它创建一个 downcall 本地方法句柄:
MethodHandle qsort = CLinker.getInstance().downcallHandle(
LibraryLookup.ofDefault().lookup("qsort"),
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
的 downcall 句柄,我们需要一个 函数指针 作为最后一个参数。这正是外部链接器抽象的上调(upcall)支持发挥作用的地方,因为它允许我们从现有的方法句柄创建函数指针。首先,我们编写一个静态方法,该方法可以比较作为指针传递的两个整数元素:
class Qsort {
static int qsortCompare(MemoryAddress addr1, MemoryAddress addr2) {
return MemoryAccess.getIntAtOffset(MemorySegment.ofNativeRestricted(),
addr1.toRawLongValue()) -
MemoryAccess.getIntAtOffset(MemorySegment.ofNativeRestricted(),
addr2.toRawLongValue());
}
}
然后,我们创建一个方法句柄,指向上面的比较器函数:
MethodHandle comparHandle
= MethodHandles.lookup()
.findStatic(Qsort.class, "qsortCompare",
MethodType.methodType(int.class,
MemoryAddress.class,
MemoryAddress.class));
既然我们有了 Java 比较器的方法句柄,我们就可以创建一个函数指针。就像对下调(downcalls)一样,我们使用 CLinker
类中的布局来描述外部函数指针的签名:
MemorySegment comparFunc
= CLinker.getInstance().upcallStub(comparHandle,
FunctionDescriptor.of(C_INT,
C_POINTER,
C_POINTER));
// 注意:原始代码中的最后一个分号是不必要的,并已在此处移除
最后,我们得到了一个内存段 comparFunc
,其基地址指向一个存根(stub),该存根可用于调用我们的 Java 比较器函数。因此,我们现在拥有调用 qsort
下调句柄所需的一切:
try (MemorySegment array = MemorySegment.allocateNative(4 * 10)) {
array.copyFrom(MemorySegment.ofArray(new int[] { 0, 9, 3, 4, 6, 5, 1, 8, 2, 7 }));
qsort.invokeExact(array.address(), 10L, 4L, comparFunc.address());
int[] sorted = array.toIntArray(); // 结果为 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
}
此代码创建了一个堆外数组,将 Java 数组的内容复制到此数组中,然后将该数组与从外部链接器获得的比较器函数一起传递给 qsort
句柄。作为副作用,在调用之后,堆外数组的内容将根据我们用 Java 编写的比较器函数进行排序。然后,我们从包含已排序元素的段中提取一个新的 Java 数组。
此高级示例展示了外部链接器抽象的全部功能,实现了 Java/ 原生边界上的代码和数据的完全双向互操作性。
替代方案
继续使用 JNI 或其他第三方原生互操作框架。
风险和假设
JIT 实现需要一些工作来确保从 API 获取的原生方法句柄的使用效率至少与现有 JNI 原生方法的使用效率相当,并且可优化。
允许外部函数调用总是意味着要放宽一些通常与 Java 平台相关联的安全要求(尽管在调用 JNI 原生方法时已经是这种情况,但开发人员可能并未意识到这一点)。例如,外部链接器 API 无法验证函数描述符中的参数数量是否与要链接的符号的参数数量匹配。为了帮助解决一些最常见的故障原因,可能会提供额外的调试功能,类似于现有的
-Xcheck:jni
选项。由于外部链接器 API 本质上是不安全的,因此获取外部链接器实例是一个特权受限的操作,需要
-Dforeign.restricted=permit
标志。
依赖项
本 JEP 中描述的 API 是朝着 Project Panama 项目所追求的本地互操作性支持目标迈出的重要里程碑,它大量依赖于 JEP 370 和 JEP 383 中描述的外部内存访问 API。
本 JEP 中描述的工作可能会为后续工作提供工具
jextract
,该工具从给定本地库的头文件开始,机械地生成与该库进行互操作所需的本地方法句柄。这将进一步减少从 Java 使用本地库的开销。