JEP 389:外部链接器 API(孵化器)
概括
引入一个 API,该 API 提供对本机代码的静态类型、纯 Java 访问。该 API 与外部内存 API ( JEP 393 ) 一起将大大简化绑定到本机库的容易出错的过程。
历史
为该 JEP 提供基础的外部内存访问 API 最初由JEP 370提出,并于 2019 年底作为孵化 API面向 Java 14 ,随后由面向 Java 的JEP 383和JEP 393进行刷新分别为15和16。外部内存访问 API 和外部链接器 API 共同构成了巴拿马项目的关键交付成果。
目标
-
_易于使用:_用卓越的纯 Java 开发模型替代 JNI。
-
_C 支持:_这项工作的初始范围旨在在 x64 和 AArch64 平台上提供高质量、完全优化的与 C 库的互操作性。
-
_通用性:_外部链接器 API 和实现应该足够灵活,随着时间的推移,可以支持其他平台(例如 32 位 x86)和用 C 以外的语言(例如 C++、Fortran)编写的外部函数。
-
_性能:_外部链接器 API 应该提供与 JNI 相当或更好的性能。
非目标
它的目标不是:
- 删除、重新实现或改进 JNI,
- 提供从本机代码头文件机械生成 Java 代码的工具,或者
- 更改或改进与本机库交互的 Java 应用程序的打包和部署方式(例如,多平台 JAR 文件)。
动机
Java 从 Java 1.1 开始就支持通过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(外部链接器 API)来纠正这种不平衡,该 API 提供外部函数支持,而不需要任何介入的 JNI 粘合代码。它通过将外部函数公开为可以在纯 Java 代码中声明和调用的方法句柄来实现此目的。这极大地简化了编写、构建和分发依赖于外部库的 Java 库和应用程序的任务。此外,外部链接器 API 与外部内存访问 API 一起提供了坚实而高效的基础,第三方本机互操作框架(无论是现在还是将来)都可以可靠地构建在该基础上。
描述
在本节中,我们将深入探讨如何使用外部链接器 API 实现本机互操作。本节中描述的各种抽象将作为名为 的孵化器模块提供jdk.incubator.foreign
,位于同名的包中,与现有的外部内存访问 API 并排。
符号查找
任何外部函数支持的第一个要素是在本机库中查找符号的机制。在传统的 Java/JNI 场景中,这是通过System::loadLibrary
和System::load
方法完成的,这些方法在内部映射 到对dlopen
.外部链接器 API 通过LibraryLookup
类提供简单的库查找抽象(类似于方法句柄查找),它提供了在给定本机库中查找命名符号的功能。我们可以通过三种不同的方式获取库查找:
-
LibraryLookup::ofDefault
— 返回库查找,可以_查看_已随 VM 加载的所有符号。 -
LibraryLookup::ofPath
— 创建与在给定绝对路径找到的库关联的库查找。 -
LibraryLookup::ofLibrary
— 创建与给定名称的库关联的库查找(这可能需要java.library.path
适当设置变量)。
获得查找后,客户端可以使用该lookup(String)
方法使用它来检索库符号(全局变量或函数)的句柄。此方法返回一个 fresh LibraryLookup.Symbol
,它只是内存地址和名称的代理。
例如,以下代码查找库clang_getClangVersion
提供的函数clang
:
LibraryLookup libclang = LibraryLookup.ofLibrary("clang");
LibraryLookup.Symbol clangVersion = libclang.lookup("clang_getClangVersion");
外部链接器 API 的库加载机制与 JNI 的库加载机制之间的一个重要区别是加载的 JNI 库与类加载器相关联。此外,为了保持类加载器的完整性,同一个 JNI 库不能加载到多个类加载器中。这里描述的外部函数机制更为原始:外部链接器 API 允许客户端直接定位本机库,无需任何中间 JNI 代码。至关重要的是,Java 对象永远不会通过外部链接器 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 语言提供互操作支持,但该抽象中的概念足够通用,将来可以适用于其他外语。
两者都downcallHandle
采用upcallStub
一个FunctionDescriptor
实例,它是内存布局的聚合,用于完整描述外部函数的签名。该CLinker
接口定义了许多布局常 量,每个常量对应一种主要的 C 基元类型。这些布局可以使用 来组合,FunctionDescriptor
以描述 C 函数的签名。例如,我们可以使用以下描述符对接受 achar*
并返回 a 的C 函数进行建模:long
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 位值),但通常使用不同的处理器寄存器进行传递。附加到类中特定于 C 的布局的布局属性CLinker
可确保以正确的方式处理参数和返回值。
两者downcallHandle
都upcallStub
接受(直接或间接)一个MethodType
实例。方法类型描述了客户端在与生成的下行调用句柄或上行调用存根交互时将使用的 Java 签名。实例中的参数和返回类型MethodType
根据相应的布局进行验证。例如,链接器运行时检查与给定参数/返回值关联的 Java 载体的大小是否等于相应布局的大小。原始布局到 Java 载体的映射可能因平台而异(例如,C_LONG
映射到long
Linux/x64,但映射到int
Windows),但指针布局 ( C_POINTER
) 始终与载体和结构相关联MemoryAddress
(其布局由a GroupLayout
) 始终与运营商相关联MemorySegment
。
下线
假设我们要调用标准 C 库中定义的以下函数:
size_t strlen(const char *s);
为此,我们必须:
- 查找
strlen
符号, - 使用类中的布局描述 C 函数的签名
CLinker
, - _选择要覆盖_在本机函数上的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库的一部分,随VM一起加载,因此我们可以使用默认查找来查找它。剩下的事情就非常简单了。唯一棘手的细节是我们如何建模size_t
——通常这种类型具有指针的大小,因此我们可以C_LONG
在 Linux 上使用,但我们必须C_LONG_LONG
在 Windows 上使用。在 Java 方面,我们size_t
使用 a 进行建模long
,并使用参数对指针进行建模MemoryAddress
。
一旦我们获得了向下调用本机方法句柄,我们就可以将其用作任何其他方法句柄:
try (MemorySegment str = CLinker.toCString("Hello")) {
long len = strlen.invokeExact(str.address()); // 5
}
在这里,我们使用其中一个辅助方法将Java 字符串转换为包含终止 C 字符串的CLinker
堆外内存段。NULL
然后,我们将该段传递给方法句柄并将结果存储在 Java 中long
。
请注意,这一切都是在没有任何干预本机代码的情况下实现的——所有互操作代码都可以用(低级)Java 来表达。
上行呼叫
有时,将 Java 代码作为函数指针传递给某些本机函数很有用。我们可以通过使用外部链接器对上行调用的支持来实现这一点。为了演示这一点,请考虑标准 C 库中定义的以下函数:
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
这是一个可用于对数组内容进行排序的函数,使用自定义比较器函数 ,compar
该函数作为函数指针传递。为了能够qsort
从 Java 调用该函数,我们首先要为其创建一个向下调用的本机方法句柄:
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
来映射 Csize_t
类型,并且我们MemoryAddess.class
同时使用第一个指针参数(数组指针)和最后一个参数(函数指针)。
这次,为了调用qsort
向下调用句柄,我们需要一个_函数指针_作为最后一个参数传递。这就是外部链接器抽象的上行支持派上用场的地方,因为它允许我们从现有方法句柄创建函数指针。首先,我们编写一个静态方法,可以比较两个作为指针传递的 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 比较器的方法句柄,我们可以创建一个函数指针。就像向下调用一样,我们使用类中的布局来描述外部函数指针的签名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 数组,其中包含已排序的元素。
这个高级示例展示了外部链接器抽象的全部功能,以及跨 Java/本机边界的代码和数据的完全双向互操作。
备择方案
继续使用 JNI 或其他第三方本机互操作框架。
风险和假设
-
JIT 实现需要做一些工作来确保从 API 检索的本机方法句柄的使用至少与现有 JNI 本机方法的使用一样高效和可优化。
-
允许外部函数调用总是意味着放宽一些通常与 Java 平台相关的安全要求。 (调用 JNI 本机方法时已经是这种情况, 尽管开发人员可能没有意识到这一点)。例如,外部链接器 API 无法验证函数描述符中的参数数量是否与所链接的符号的数量相匹配。为了帮助解决一些最常见的故障原因,可以提供与现有
-Xcheck:jni
选项类似的附加调试功能。 -
由于外部链接器 API 本质上是不安全的,因此获取外部链接器实例是一项需要该
-Dforeign.restricted=permit
标志的特权、受限操作。
依赖关系
-
此 JEP 中描述的 API 代表了本机互操作支持的一个重要里程碑,这是巴拿马项目的目标,并且在很大程度上建立在JEP 370和JEP 383中描述的外部内存访问 API 的基础上。
-
此 JEP 中描述的工作可能会使后续工作能够提供一个工具,
jextract
该工具从给定本机库的头文件开始,机械地生成与该库互操作所需的本机方法句柄。这将进一步减少使用 Java 本地库的开销。