跳到主要内容

JEP 389: 外部链接器 API(孵化中)

QWen Max 中英对照 JEP 389: Foreign Linker API (Incubator)

总结

引入一个 API,提供对原生代码的静态类型、纯 Java 访问。这个 API 与 Foreign-Memory API(JEP 393)相结合,将大大简化原本容易出错的绑定到原生库的过程。

历史

为本 JEP 提供基础的 Foreign-Memory Access API 最初由 JEP 370 提出,并于 2019 年底作为 孵化 API 被纳入 Java 14,随后由 JEP 383JEP 393 进行更新,分别针对 Java 15 和 Java 16。Foreign-Memory Access API 和 Foreign Linker API 共同构成了 Project Panama 的关键交付成果。

目标

  • 易用性: 使用更优越的纯 Java 开发模型替换 JNI。

  • C 支持: 此工作的初始范围旨在提供高质量、完全优化的与 C 库的互操作性,支持 x64 和 AArch64 平台。

  • 通用性: Foreign Linker API 及其实施应足够灵活,以便随着时间的推移,能够适应对其他平台(例如,32 位 x86)和使用除 C 以外的语言(例如 C++、Fortran)编写的外部函数的支持。

  • 性能: Foreign Linker API 应提供与 JNI 相当或更好的性能。

非目标

目标不包括:

  • 删除、重新实现或改进 JNI,
  • 提供一个工具,从本地代码头文件自动生成 Java 代码,或者
  • 更改或改进 Java 应用程序与本地库交互的打包和部署方式(例如,多平台 JAR 文件)。

动机

自 Java 1.1 起,Java 就通过 Java Native Interface (JNI) 支持对本地方法的调用,但这种方式一直以来既复杂又脆弱。使用 JNI 包装一个本地函数需要开发多个构件:一个 Java API、一个 C 头文件和一个 C 实现。即使有工具的帮助,Java 开发人员也必须跨多个工具链工作,以保持多个依赖于平台的构件同步。对于稳定的 API 来说这已经够难了,而在尝试跟踪正在开发中的 API 时,每次 API 演进时更新所有这些构件会成为一个重大的维护负担。最后,JNI 主要关注代码,但代码总是需要交换数据,而 JNI 在访问本地数据方面提供的帮助很少。因此,开发人员常常采用变通方法(例如直接缓冲区或 sun.misc.Unsafe),这使得应用程序代码更难以维护,甚至更加不安全。

多年来,出现了许多框架来填补 JNI 留下的空白,包括 JNAJNRJavaCPP。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::loadLibrarySystem::load 方法完成的,这些方法在内部映射为对 dlopen 的调用。外来链接器 API 通过 LibraryLookup 类(类似于方法句柄查找)提供了一个简单的库查找抽象,该类提供了在给定的本地库中查找命名符号的功能。我们可以通过三种不同的方式获取库查找:

  • LibraryLookup::ofDefault — 返回可以看到随虚拟机加载的所有符号的库查找。

  • 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 代码。重要的是,Java 对象永远不会通过 Foreign Linker API 传递给本地代码或从本地代码返回。因此,通过 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 语言提供互操作支持,但此抽象中的概念足够通用,未来也可适用于其他外语种。

downcallHandleupcallStub 都需要一个 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 语言中的两个类型 intfloat 可能共享相似的内存布局(它们都是 32 位值),但通常使用不同的处理器寄存器进行传递。CLinker 类中附加到特定于 C 的布局上的布局属性确保了参数和返回值能够以正确的方式处理。

downcallHandleupcallStub 也接受(直接或间接)一个 MethodType 实例。方法类型描述了客户端在与生成的 downcall 句柄或 upcall 存根交互时将使用的 Java 签名。MethodType 实例中的参数和返回类型会根据相应的布局进行验证。例如,链接器运行时会检查与特定参数/返回值关联的 Java 载体的大小是否等于相应布局的大小。原始布局到 Java 载体的映射可能因平台而异(例如,C_LONG 在 Linux/x64 上映射为 long,但在 Windows 上映射为 int),但指针布局(C_POINTER)始终与 MemoryAddress 载体相关联,结构体(其布局由 GroupLayout 定义)始终与 MemorySegment 载体相关联。

向下调用

假设我们要调用标准 C 库中定义的以下函数:

size_t strlen(const char *s);

要做到这一点,我们必须:

  • 查找 strlen 符号,
  • 使用 CLinker 类中的布局描述 C 函数的签名,
  • 选择 Java 签名以覆盖原生函数(这将是与原生方法句柄的客户端交互时使用的签名),以及
  • 使用上述信息,通过 CLinker::downcallHandle 创建一个下行调用的原生方法句柄。

下面是一个如何实现这一点的示例:

MethodHandle strlen = CLinker.getInstance().downcallHandle(
LibraryLookup.ofDefault().lookup("strlen"),
MethodType.methodType(long.class, MemoryAddress.class),
FunctionDescriptor.of(C_LONG, 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 表达。

上调用

有时,将 Java 代码作为函数指针传递给某些本地函数是很有用的。我们可以通过使用对外调用(upcalls)的外部链接器支持来实现这一点。为了演示这一点,请考虑标准 C 库中定义的以下函数:

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

这是一个可以使用自定义比较函数 compar 对数组内容进行排序的函数,该比较函数通过函数指针传递。为了能够从 Java 中调用 qsort 函数,我们首先需要为它创建一个下调用本地方法句柄:

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_LONGlong.class 来映射 C 语言中的 size_t 类型,并且在第一个指针参数(数组指针)和最后一个参数(函数指针)中都使用了 MemoryAddess.class

这一次,为了调用 qsort 下调句柄,我们需要一个函数指针作为最后一个参数传递。这就是 foreign-linker 抽象的上行调用支持派上用场的地方,因为它允许我们从现有的方法句柄创建一个函数指针。首先,我们编写一个静态方法,该方法可以比较两个作为指针传递的 int 元素:

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,其基地址指向一个存根,该存根可用于调用我们的 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 数组,其中包含已排序的元素。

这个高级示例展示了 foreign-linker 抽象的全部功能,实现了 Java/原生边界上代码和数据的完全双向互操作。

替代方案

继续使用 JNI 或其他第三方原生互操作框架。

风险与假设

  • JIT 实现将需要一些工作,以确保从 API 中检索到的本地方法句柄的使用至少与现有的 JNI 本地方法一样高效且可优化。

  • 允许调用外部函数总是意味着放宽一些通常与 Java 平台相关联的安全要求。(在调用 JNI 本地方法时已经是这种情况了,尽管开发者可能没有意识到这一点)。例如,Foreign Linker API 无法验证函数描述符中的参数数量是否与被链接的符号匹配。为了帮助排查一些最常见的失败原因,可能会提供额外的调试功能,类似于现有的 -Xcheck:jni 选项。

  • 由于 Foreign Linker API 本质上是不安全的,获取一个外部链接器实例是一种特权受限操作,需要使用 -Dforeign.restricted=permit 标志。

依赖

  • 本 JEP 中描述的 API 代表了向 Project Panama 目标迈进的重要里程碑,该目标旨在实现原生互操作支持,并且在很大程度上基于 JEP 370JEP 383 中描述的 Foreign-Memory Access API。

  • 本 JEP 中描述的工作可能会为后续工作提供一个名为 jextract 的工具,该工具从给定的原生库的头文件出发,机械地生成与该库互操作所需的原生方法句柄。这将进一步减少从 Java 使用原生库的开销。