Java 21 FFI 性能测试 —— Panama vs JNI/JNA/JNR
作为全新的 Java FFI 方案,从 Java 16 开始孵化的 Panama Foreign Function API 广受关注, 也有一些人将它与 JNI、JNA、JNR 等现在常用的 FFI 方案进行了对比测试。
但是,Panama 从 Java 16 到 21,几乎每个 Java 版本中都有大更新,现有的测试主要基于 Java 16 时的早期 API,结果可能有些过时, 所以我基于 Java 21 重新设计了一组 JMH 测试,向各位展示 Panama 最新的性能表现。
这组测试有四项:
NoopBenchmark
: 在 Java 中调用一个无操作的空 C 函数。此测试主要目的是展示调用本机函数的基本开销。SysinfoBenchmark
: 在 Java 中调用 Linux 系统函数sysinfo
,并从结果获取mem_unit
的值。此测试主要目的是展现需要操作 C 结构体时各个方案的性能表现。StringConvertBenchmark
: 此测试包含两个子项:将不同长度的 Java 字符串转换为 C 字符串传递给 C 方法,以及接受 C 函数返回的 C 字符串并转换为 Java 字符串。此测试的主要目的是展示字符串转换的性能。QSortBenchmark
: 用一个 Java 方法作为回调函数调用 C 标准库函数qsort
。此测试的主要目的是展示 C 一侧调用 Java 回调方法的性能。
而测试中除了 Panama 和 JNI 以外,也会与 JNA 和 JNR 进行对比。
JNA 的测试结果会包括常规用法和 Direct Mapping 两种模式的对比,Panama 的结果中也会包括对 Java 21 中新提供的 isTrivial
链接选项的测试。
测试结果
NoopBenchmark
方案 | 吞吐量 (ops/ms) | 吞吐量百分比 |
---|---|---|
JNA | 19372.933 | 6.7% |
JNA Direct Mapping | 20595.690 | 7.2% |
JNR | 124115.069 | 43.2% |
JNR (Ignore Error) | 241616.003 | 84.1% |
JNI | 287143.357 | 100.0% |
Panama | 325322.988 | 113.3% |
Panama (Trivial Call) | 459682.023 | 160.1% |
在数据表格中,我以 JNI 的数据为基准列出了百分比化的吞吐量,这样能更直观的感受它们的性能差异。
作为已经被广为使用的传统方案,JNA 表现出了极为惊人的巨大开销,与 JNI 十四倍的性能差距让它比其他几个方案都要慢出一个数量级, 即使使用 Direct Mapping 也只有轻微的改善。
JNR 默认情况下虽然依然比 JNA 好得多,但仍然难以让人满意。JNR 的表现主要是因为 JNR 默认会保存 errno 值,
虽然通常来说在本机函数的开销很低,但相对于本测试的 noop 函数就无法忽略了。
我们使用 @IgnoreError
让 JNR 不报错错误,此时 JNR 的性能损失就只有不到 16% 了。
而默认情况下 Panama 吞吐量已经比 JNI 高出了 13.3%,使用 isTrivial
链接器选项时开销还能进一步降低,相比 JNI 有 60.1% 的吞吐量提升。
SysinfoBenchmark
Linux 的 sysinfo
函数和很多 C 函数一样,接受一个结构体指针,通过该指针传递结果。
JNI 处理这种惯例只需要传递给它一个局部变量指针即可,JNA/JNR 则可以将结构体映射至 Java 类进行处理。
而在 Panama 中,我们需要为这个结构体创建一个 StructLayout
,然后在堆上分配一段内存,再将它传递给 C 函数,测试方法如下:
@Benchmark
public int getMemUnitPanama() throws Throwable {
try (Arena arena = Arena.ofConfined()) {
MemorySegment info = arena.allocate(sysinfoLayout);
getMemUnit.invokeExact(info);
return (int) memUnitHandle.get(info);
}
}
结果:
方案 | 吞吐量 (ops/ms) | 吞吐量百分比 |
---|---|---|
JNA | 152.826 | 2.0% |
JNA Direct Mapping | 152.582 | 2.0% |
JNR | 3531.837 | 47.2% |
JNI | 7482.798 | 100.0% |
Panama | 5569.635 | 74.4% |
Panama (Trivial Call) | 5560.135 | 74.3% |
JNA 再次展现出了它极其夸张的性能,性能仅仅为 JNI 的 2%,而且使用 Direct Mapping 也没有改善。
JNR 性能只有 JNI 的不到一半,这主要是将 C 结构体映射至 Java 类付出的成本。
Panama 在这项测试中表现也不理想,相比 JNI 慢了 25% 左右,原因主要是需要花费时间创建临时的 Arena
和在堆上分配结构体。
尝试在测试方法外分配内存并重新进行测试,得到了以下的结果:
现在 Panama 和 JNI 的性能差距不到 1%,可以佐证上面的观点。
此测试展现了 Panama 的缺点:无法在栈上分配局部变量,所以需要更多的在堆上进行分配。
为了缓解分配带来的影响,可以考虑一次性分配一大块内存,然后在需要的时候通过 SegmentAllocator.slicingAllocator
分割成小块使用, 最后一次性释放。
StringConvertBenchmark
该测试包含两个子项。
将 Java 字符串转换为 C 字符串
JNI 没有直接的转换方式,所以此子项中没有对 JNI 进行测试。
JNA/JNR 内置支持将 Java String
映射至 C const char *
,而 Panama 需要手动调用 Arena::allocateUtf8String
进行转换。
在此子项测试中,Panama 依然远强于 JNA/JNR,但 JNA 和 JNR 的结果就比较微妙了。
虽然对于小字符串 JNR 依然有优势,但对于较大的字符串 JNR 甚至比 JNA 更慢。 合理怀疑 JNR 的转换实现存在性能问题,小字符串主要是 JNA 基本开销较大,所以没有体现出优势,但对于较大的字符串 JNR 的问题就暴露出来了。
将 C 字符串转换为 Java 字符串
折线图后半段比较拥挤,下面是放大后的图像:
JNA/JNR 依然是使用内置的类型映射,JNI 则使用了 JNI 函数 NewStringUTF
,Panama 则是调用了 MemorySegment::getUtf8String
.
令人惊讶的是,JNA 对于较大的字符串性能超过了其他方案。
我查看了源码,Panama/JNA/JNR 使用了类似的方式进行转换:
- 扫描
'\0'
字符,确定字符串长度; - 将 C 字符串拷贝至 Java 字节数组中;
- 解码字符串。
其中 Panama 和 JNR 都是在 Java 中使用简单的循环确定 C 字符串长度,而 JNA 使用了 C 标准库函数 strlen
,
这可能就是性能存在差距的原因。
我已经将此问题反馈给 Panama 开发者,他们正在研究这个问题,希望能在未来的版本中得到改进。
QSortBenchmark
该测试使用 qsort
排序一个 int
数组,而传递给 qsort
的比较器函数指针则是调用 Java 实现的回调函数。
对于 JNA/JNR,我使用它们将特定的函数式接口映射为 C 函数指针的内置支持传递回调函数。
对于 JNI,我将回调函数的 jmethodId
和相关的 jclass
全局引用缓存在 C 的静态变量中,
每次调用回调函数都只需要从 JavaVM
中取得 JNIEnv
,然后调用 CallStaticIntMethod
即可。
该回调函数的 Java 实现使用了 sun.misc.Unsafe
以解引用指针。
而 Panama,我使用了文档中的实现方式,为回调函数生成 upcall stub 并传递。
该测试是 Panama 优势最大的场景。Panama 的性能约为 JNI/JNR 的 3.5~4 倍,JNA 的 20~30 倍。
总结
Panama 不仅让 Java FFI 摆脱需要手动编写 C 代码的束缚,而且有比传统 JNI 更优秀的性能,可以期待它未 Java FFI 带来一场变革。
目前 Panama 仍处于 Preview 阶段,正在积极开发。我非常希望更多人参与对它的试用,并在邮件列表(panama-dev@openjdk.org)中反馈自己的体验和遇到的问题, 这样能够帮助 Panama 在正式定稿前进行更多改进。