几年前为一个金融系统对接硬件加密机,被迫写了一套 JNI 胶水代码。那个过程到现在想起来都头皮发麻:先写 Java 类声明 native 方法,用 javac 编译再 javah 生成头文件,然后写 C 实现,处理各种类型转换,还得时刻提防内存泄漏和跨平台编译问题。后来那个模块每升级一次 JDK,光 JNI 部分的测试和适配就要耗掉大半个工作日。
Java 22 将外部函数与内存 API(Foreign Function & Memory API,简称 FFM API)从孵化转正,正式定版。它提供了纯 Java 代码调用本地库(如 C 共享库)以及安全操作堆外内存的能力,彻底把开发者从 JNI 的泥潭里拉了出来。最近我用它把公司一个还在跑 JNI 的小模块迁了过来,过程比想象的顺利很多。这篇文章就以一个调用 C 标准库 `qsort` 函数排序 Java 数组的完整案例,把 FFM API 的核心机制和使用流程完整拆解一遍。
一、FFM API 解决了 JNI 的哪些痛点
JNI 最大的问题在于它是一套完全独立的编程模型。开发者需要离开 Java 的舒适区去管理原生内存、处理本地引用、写额外胶水代码。而且 JNI 调用本身有性能开销,错误处理也极其不便——一个不小心就会让整个 JVM 崩溃。FFM API 则把这些能力统一到了 Java 语言层面:找到本地函数、描述函数签名、分配和管理本地内存、调用函数,全部用 Java 代码完成,不再需要离开 IDE 去编辑 C 源文件。
FFM API 主要由两个核心包构成:`java.lang.foreign` 提供内存段(MemorySegment)、内存布局(MemoryLayout)和内存分配器(Arena);`java.lang.foreign` 中的 `Linker` 和 `SymbolLookup` 负责定位本地函数并将其转换为可调用的 Java 方法句柄(MethodHandle)。这些组件协作起来,就形成了一条“纯 Java 调用本地代码”的干净通路。
二、准备工作:找到本地函数并描述签名
我们首先要调用的目标是 C 标准库中的 `qsort` 函数,它的原型是:
void qsort(void *base, size_t nitems, size_t size, int (*compar)(const void *, const void*));
在 FFM API 中,第一步是通过 `Linker` 获取对应平台的链接器实例,然后用 `SymbolLookup` 定位函数地址:
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
MemorySegment qsortAddress = stdlib.find("qsort").orElseThrow();
`SymbolLookup` 会搜索标准库的符号表,返回函数在内存中的地址(封装为 `MemorySegment`)。接着,我们需要用 `FunctionDescriptor` 描述这个函数的参数和返回值类型。FFM API 使用 `ValueLayout` 来表示 Java 与 C 之间的类型映射,比如 `ValueLayout.JAVA_INT` 对应 C 的 `int`,`ValueLayout.ADDRESS` 对应指针。
FunctionDescriptor qsortDesc = FunctionDescriptor.ofVoid(
ValueLayout.ADDRESS, // void *base
ValueLayout.JAVA_LONG, // size_t nitems (用long承载)
ValueLayout.JAVA_LONG, // size_t size
ValueLayout.ADDRESS // compar函数指针
);
这里 `nitems` 和 `size` 的类型用的是 `JAVA_LONG` 而不是 `JAVA_INT`,因为 C 的 `size_t` 在 64 位平台上通常是 8 字节,与 Java 的 `long` 匹配。将这个描述与地址绑定,就得到了一个可调用的方法句柄:
MethodHandle qsort = linker.downcallHandle(qsortAddress, qsortDesc);
三、用 Arena 管理本地内存,告别手动 free
JNI 里最令人头疼的就是手动管理本地内存的分配和释放。FFM API 提供了 Arena(内存竞技场)来自动管理内存生命周期,类似于一个内存作用域,一旦 Arena 关闭,它管辖的所有内存段都会被自动释放。
我们要排序一个 Java 的 `int` 数组,首先需要把它拷贝到本地内存中。使用 `Arena.ofConfined()` 创建一个线程绑定的竞技场:
int[] array = {5, 2, 9, 1, 5, 6};
int length = array.length;
Arena arena = Arena.ofConfined();
MemorySegment nativeArray = arena.allocateFrom(ValueLayout.JAVA_INT, array);
`allocateFrom` 方法将 Java 数组直接拷贝到一段新分配的本地内存里,并返回对应的 `MemorySegment` 对象。该内存由 `arena` 管理,当 `arena.close()` 被调用时自动释放。
四、处理回调函数:用 upcall 将 Java 方法传给 C
`qsort` 需要一个比较函数指针。在 FFM API 中,我们可以用 `Linker.upcallStub` 将一个 Java 方法句柄转化为一个 `MemorySegment`,这个段就代表可在本地代码中调用的函数指针。
先定义 Java 的比较逻辑:
MethodHandle comparHandle = MethodHandles.lookup().findStatic(
FFMExample.class, "compare",
MethodType.methodType(int.class, MemorySegment.class, MemorySegment.class)
);
对应的静态方法:
public static int compare(MemorySegment a, MemorySegment b) {
int ia = a.get(ValueLayout.JAVA_INT, 0);
int ib = b.get(ValueLayout.JAVA_INT, 0);
return Integer.compare(ia, ib);
}
然后创建 upcall stub:
FunctionDescriptor comparDesc = FunctionDescriptor.of(ValueLayout.JAVA_INT,
ValueLayout.ADDRESS, ValueLayout.ADDRESS);
MemorySegment comparStub = linker.upcallStub(comparHandle, comparDesc, arena);
注意 `comparStub` 也绑定到 `arena`,这意味着它的生命周期与整个竞技场一致。
五、调用 qsort 并取回结果
现在万事俱备,用 `qsort` 方法句柄执行排序:
qsort.invokeExact(nativeArray, (long) length,
ValueLayout.JAVA_INT.byteSize(), comparStub);
`invokeExact` 要求参数类型与描述完全匹配。执行完毕后,本地内存中的数组已经被排序。我们把结果读回 Java 数组:
int[] sorted = nativeArray.toArray(ValueLayout.JAVA_INT);
System.out.println(Arrays.toString(sorted));
arena.close(); // 释放所有关联的本地内存
控制台输出 `[1, 2, 5, 5, 6, 9]`。整个流程完全在 Java 代码中完成,没有一行 C 源码、没有头文件生成、没有任何外部编译步骤。
六、深入 Arena 的三种作用域模型
上述例子用了 `Arena.ofConfined()`,这种竞技场只能由创建它的线程访问,适合单线程任务。FFM API 还提供另外两种模式:
Arena.ofShared():允许多线程访问,内部通过同步保证安全,但访问成本略高于受限竞技场。Arena.global():全局竞技场,其生命周期绑定到整个类加载器,内存永远不会被释放,适合存放不变的常量数据或长期存活的本地资源。
对于大多数场景,推荐优先使用 `ofConfined`,确保线程安全同时性能最佳。
七、从 JNI 迁移到 FFM 的实际收益
在迁移加密机调用模块时,我们对比了新旧方案的代码量和可维护性。原来用 JNI 实现的模块包含一个 Java 类、一个 C 头文件、一个 C 实现文件以及对应的编译脚本,总代码量约 300 行。迁移到 FFM 后,纯 Java 代码只需约 100 行,且无需任何平台相关的编译配置。更重要的是,整个调用流程完全在 IDE 的调试器掌控之下,可以像调试普通 Java 代码一样设置断点、检查变量,这在 JNI 时代几乎是不可能的。
性能方面,FFM API 在设计时考虑了调用优化,通过方法句柄的内联和链接器的高效转换,其调用开销通常低于传统的 JNI。加上开发者不再需要编写额外的类型转换和检查代码,开发效率有了明显提升。
八、注意事项与最佳实践
第一,错误处理要小心。本地函数的崩溃可能会直接影响到整个 JVM,不像纯 Java 异常可以被捕获。在用 FFM API 时,仍然需要严格遵守外部函数的语义约束,尤其是内存访问的边界检查。好在 `MemorySegment` 本身提供了边界检查,访问越界时会抛出 `IndexOutOfBoundsException`,这比 JNI 中直接内存踩踏要好得多。
第二,避免在热点路径频繁分配新的 Arena。Arena 的创建和关闭有一定成本,可以复用同一个 Arena 来分配多段内存,最后一次性释放。
第三,`upcallStub` 创建的回调函数指针在竞技场关闭后就会失效,确保它们的生命周期需求与内存管理策略一致。
九、总结
Java 22 FFM API 的正式落地,让 Java 与本地世界的交互终于有了一个安全、高效、纯粹的语言内方案。无论是调用已有的 C 库,还是需要手动管理大块堆外内存,开发者都不必再忍受 JNI 那种撕裂的开发体验。从 `qsort` 这种基础案例出发,同样的模式可以扩展到调用任意第三方动态库、集成硬件驱动、或者实现高性能的内存数据交换。如果你的项目中还有遗留的 JNI 代码在苟延残喘,Java 22 或许就是将它们彻底淘汰的最佳时机。

