Java 18 是否终于有了比 JNI 更好的替代方案?
由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!
Java 18 于上个月(2022 年 3 月)发布,随之而来的是外部函数和内存 API 的第二个孵化器,因此让我们来看看 Java 中的外部函数接口 (FFI) 的现状。
如果您更喜欢通过观看视频来了解详情,这里是我在 FOSDEM'22 上关于此主题的演讲录像,来自OktaDev YouTube 频道。
什么是外接口?
外部函数接口(FFI)是指从一种编程语言调用另一种编程语言编写的函数或例程的能力。它通常用于访问宿主操作系统上用C等底层语言编写的本地函数或程序。大多数编程语言都默认提供某种形式的FFI功能。该术语起源于通用LISP,但在不同的编程语言中有不同的名称。大多数编程语言使用C/C++的FFI调用约定,并原生支持调用C/C++函数。
为什么需要外联功能接口?
FFI 的大多数应用场景都围绕着与传统应用程序交互以及访问宿主操作系统功能或原生库。但近年来机器学习和高级运算的蓬勃发展使得 FFI 变得更加必要。如今,我们使用外部函数来实现各种各样的应用场景,其中包括:
- 与旧版应用程序交互
- 访问该语言中不可用的功能
- 使用本地库
- 访问主机操作系统上的功能或程序
- 多精度运算,矩阵乘法
- GPU 和 CPU 卸载(Cuda、OpenCL、OpenGL、Vulkan、DirectX 等)
- 深度学习(Tensorflow、cuDNN、Blas 等)
- OpenSSL、V8 等等
Java 中 FFI 的简史
在深入探讨 Java 中 FFI 的现状之前,让我们先简要回顾一下 Java 中 FFI 的发展历程。
Java 本地接口 (JNI)
长期以来,Java 中外传接口 (FFI) 的标准一直是 Java 本地接口 (JNI),但它却因速度慢、安全性低而臭名昭著。如果您习惯了 Rust、Go 或 Python 等其他语言,您可能知道在这些语言中使用 FFI 是多么简单直观,而 Java 在这方面则相形见绌。即使是使用 JNI 进行一个简单的本地调用,也需要做大量的工作,而且仍然可能出错,最终导致应用程序的安全问题。
JNI 的主要问题在于其使用复杂,且需要手动编写 C 桥接代码。这些问题可能导致代码不安全,并带来安全风险。在某些情况下,这还会造成性能开销。JNI
代码的性能和内存安全性取决于开发人员,因此其可靠性也会有所不同。
优点
- C/C++/汇编语言的本地接口访问
- Java 中最快的解决方案
缺点
- 使用复杂且易碎
- 安全性不高,可能导致内存安全问题。
- 可能出现开销和性能损失。
- 难以调试
- 依赖于Java开发人员手动编写安全的C绑定代码。
- 你需要针对每个目标平台编译并发布 C 代码
Java Native Access (JNA)
JNI 的复杂性催生了一些社区驱动的库,旨在简化 Java 中的 FFI 操作。Java Native Access (JNA)就是其中之一。它基于 JNI 构建,至少让 FFI 的使用更加便捷,尤其因为它无需手动编写任何 C 绑定代码,并降低了内存安全问题的发生概率。然而,它仍然保留了一些基于 JNI 的缺点,并且在许多情况下速度略慢于 JNI。尽管如此,JNA 已被广泛使用并经过实战检验,因此无疑比直接使用 JNI 更胜一筹。
优点
- C/C++/汇编语言的本地接口访问
- 与 JNI 相比,使用起来更简单
- 动态绑定,无需手动编写任何 C 绑定代码。
- 广泛使用且成熟的库
- 更好的跨平台支持
缺点
- 利用反射
- 基于 JNI 构建
- 性能开销较大,可能比 JNI 慢。
- 难以调试
Java 本地运行时 (JNR)
另一个流行的选择是Java 本地运行时 (JNR)。虽然它不如 JNA 那样普及和成熟,但它更加现代化,并且在大多数情况下性能优于 JNA。然而,在某些情况下,JNA 的性能可能更胜一筹。
优点
- C/C++/汇编语言的本地接口访问
- 便于使用
- 动态绑定,无需手动编写任何 C 绑定代码。
- 现代 API
- 与 JNI 性能相当
- 更好的跨平台支持
缺点
- 基于 JNI 构建
- 难以调试
进入巴拿马计划
Project Panama 是最新的 Java 项目,旨在简化和改进 Java 中的外键接口 (FFI)。目前,该项目正在孵化许多提案。让我们来看看一些正在进行的提案及其工作原理,并看看我们最终能否在 Java 中获得真正的原生 FFI。
外部内存访问 API
第一个关键要素是外部内存访问 API。它最初在 JDK 14 中孵化,经过三次孵化后,一个新的JEP将其合并到外部函数和内存 API 中。
- 用于安全高效地访问 Java 堆之外的外部内存的 API
- 针对不同类型内存的统一 API
- 不会影响 JVM 内存安全
- 显式内存释放
- 与不同的内存资源交互,包括堆外内存或本地内存
- JEP-370 - JDK 14 中的第一个孵化器
- JEP-383 - JDK 15 中的第二个孵化器
- JEP-393 - JDK 16 中的第三个孵化器
外国链接器 API
FFI 的另一关键组成部分是外部链接器 API。该 API 最初在 JDK 16 中提出,并在后续版本中合并到外部函数和内存 API 中。
- 用于以静态类型、纯 Java 方式访问本地代码的 API
- 注重易用性、灵活性和性能
- 对 C 互操作的初步支持
- 在 a
.dll、.so或 中调用本地代码.dylib - 创建指向 Java 方法的本地函数指针,该指针可以传递给本地库中的代码。
- JEP-389 - JDK 16 中的第一个孵化器
矢量 API
接下来是向量 API,它对于 FFI 至关重要,尤其是在机器学习和高级计算方面。
- 用于可靠且高性能向量计算的 API
- 平台无关
- 清晰简洁的API
- 可靠的运行时编译和性能
- 优雅降级
- JEP-338 - JDK 16 中的第一个孵化器
- JEP-414 - JDK 17 中的第二个孵化器
- JEP-417 - JDK 18 中的第三个孵化器
外部函数和内存 API
最终,外部链接器 API 和外部内存访问 API 共同演化为外部函数和内存 API。它最初在 JDK 17 中孵化。
- 外部内存访问 API 和外部链接器 API 的演进
- 与前两款产品相同的目标和特性(易用性、安全性、性能、通用性)
- JEP-412 - JDK 17 中的第一个孵化器
- JEP-419 - JDK 18 中的第二个孵化器
- JEP-424 - 预计在 JDK 19 中首次预览
jextract
最后,还有非常棒的 jextract 工具。虽然它不是 API 或 JDK 的一部分,但它是 Project Panama 必不可少的工具。
- 一个简单的命令行工具
- 从一个或多个本地 C 头文件生成 Java API。
- 目前搭载 OpenJDK Panama 的船舶,未来将成为 JDK 的一部分。
- 让处理大型 C 型接头变得轻而易举
例如,要在类 Unix 操作系统上使用 jextract 生成 OpenGL 的 Java API,只需运行以下命令:
jextract --source -t org.opengl -I /usr/include /usr/include/GL/glut.h
JNI 诉巴拿马
由于 JNI 是当前标准,而 Panama 的目标是取代它,因此比较两者很有意义。我们来看一个简单的例子,getpid即从标准 Cunistd头文件中调用函数。
JNI
正如您在这里看到的,使用 JNI 进行这个简单的本地调用只需六个步骤。首先,您需要编写一个 Java 类来声明本地方法。然后,您需要javac生成一个头文件和一个 C 类。这些就是本地绑定。接下来,您需要实现这个 C 类。请记住,编写 C 代码的是 Java 开发人员。这意味着您必须编写内存安全的 C 代码,并且该代码可以通过JNIEnv传递给 C 类的变量访问整个 JVM。在很多情况下,开发人员可能没有太多 C 语言经验。所以,这将会很有趣……或者更像是一场安全噩梦。接下来,您需要将 C 代码编译成一个平台特定的动态库,并确定该二进制文件的存放位置。如果必须祈祷,希望这一切不会使应用程序面临安全漏洞。然后,您需要将这个库加载到 Java 类中,编译并运行该类,希望一切顺利。
哎呀!这只是一个简单的getpid调用;想象一下用 JNI 编写类似 OpenGL 接口或 GPU 卸载程序之类的东西。
巴拿马
使用新的 Panama API,您可以通过两种不同的方式执行相同的操作,要么手动查找和加载原生函数,要么使用 jextract 工具。
第一种方法是直接使用 CLinker API 编写 Java 代码。查找并调用本地方法,就这么简单。你还可以执行更复杂的操作,例如操作本地内存等等。这种方法直接使用 Foreign Linker API 和 Foreign Memory API 来进行本地调用和管理本地内存。但这并非最高效的方式,因为它需要编写大量样板代码,并且在处理大型 C 头文件时扩展性较差。
第二个选择是使用 jextract。有了 jextract,上述所有步骤都可以简化为一行代码。jextract 提供纯 Java API 来处理本地程序,无需编写任何本地代码或修改任何头文件。jextract 使用外部链接器 (FFI) 和外部内存 (FFI) API 生成所有内容。是不是很棒!这种 FFI 体验类似于 Go 和 Rust 等语言。
对于简单的本地调用,可以使用第一种方法,但对于复杂的调用,第二种方法要好得多,也更具可扩展性。
基准
让我们运行一些Java 微基准测试工具 (JMH)基准测试,以比较 JNI 和 Panama API 的性能。我们将使用getpid标准 Cunistd头文件中的函数进行比较。我们将分别使用 JNI 和 Panama API 调用 API 并比较性能。我是在 Linux 系统上运行基准测试的,使用的是 OpenJDK 19 Panama 早期访问版本openjdk 19-panama 2022-09-20。
以下是 JNI 的代码,它使用预编译的JavaCPP库来调用getpid函数。我们无需编写所有手动 C 绑定代码和相关步骤,因为 JavaCPP 库已经完成了这些工作。
@Benchmark
public int JNILinux() {
return org.bytedeco.javacpp.linux.getpid();
}
@Benchmark
public int JNIMac() {
return org.bytedeco.javacpp.macosx.getpid();
}
以下是巴拿马的代码,它使用 Foreign Linker APIgetpid手动调用该函数。
// get System linker
private static final CLinker linker = CLinker.systemCLinker();
// predefine symbols and method handle info
private static final NativeSymbol nativeSymbol = linker.lookup("getpid").get();
private static final MethodHandle getPidMH = linker.downcallHandle(
nativeSymbol,
FunctionDescriptor.of(ValueLayout.OfInt.JAVA_INT));
@Benchmark
public int panamaDowncall() throws Throwable {
return (int) getPidMH.invokeExact();
}
使用 jextract,您可以通过以下命令为头文件生成 Java API,从而进一步简化操作:
# Linux
export C_INCLUDE=/usr/include
# macOS
export C_INCLUDE=/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include
jextract --source -d generated/src/main/java -t org.unix -I $C_INCLUDE $C_INCLUDE/unistd.h
以下是 jextract 生成的调用 API 的代码:
@Benchmark
public int panamaJExtract() {
return org.unix.unistd_h.getpid();
}
这是示例结果。
Benchmark Mode Cnt Score Error Units
FFIBenchmark.JNI avgt 40 50.221 ± 0.512 ns/op
FFIBenchmark.panamaDowncall avgt 40 49.382 ± 0.701 ns/op
FFIBenchmark.panamaJExtract avgt 40 49.946 ± 0.721 ns/op
使用 Panama API 似乎比 JNI 略快一些,而 JNI 目前还处于孵化阶段,所以我预计稳定后情况会更好。
如果您想自行运行基准测试,请按照源代码库中 readme 文件中的说明进行操作。
那么,我们到达了吗?
截至 JDK 18,Project Panama 的当前状态如下。
- 仍在孵化中
- 已经能够使用与 C 语言互操作的语言,例如 C/C++、Fortran、Rust 等。
- 性能与JNI持平或更优。希望未来能进一步提升。
- jextract 让使用原生库变得非常容易。
- 内存安全性高,且比 JNI 更稳定
- 本地/堆外内存访问
- 文档方面亟待改进。这只是一个试点项目,所以请期待它未来会有所改善。
了解更多关于Java和FFI的信息
如果您想了解更多关于 Java 和 FFI 的知识,请查看以下资源。
- 三种在本地使用 HTTPS 运行 Java 的方法
- Java 中五种带有秘密的反模式
- 隆重推出适用于 JHipster 的 Spring Native:轻松构建无服务器全栈应用
- Java 记录:WebFlux 和 Spring Data 示例
- 巴拿马项目新手指南
如果您喜欢这篇教程,您很可能也会喜欢我们发布的其他教程。请在 Twitter 上关注 @oktadev并订阅我们的 YouTube 频道,以便在发布新的开发者教程时收到通知。
文章来源:https://dev.to/oktadev/does-java-18-finally-have-a-better-alternative-to-jni-1ka9

