Java 外部函数接口 FFI 实战——Panama 项目让 Java 调 C 库有多简单
Java 外部函数接口 FFI 实战——Panama 项目让 Java 调 C 库有多简单
适读人群:需要在 Java 项目中调用 C/C++ 原生库的工程师 | 阅读时长:约14分钟 | 核心价值:Panama API 的实际使用方法,与 JNI 的对比
说"Panama"这个名字,很多 Java 工程师可能没什么印象。但如果我说"Java 调 C 库",估计大部分人的第一反应是:JNI,麻烦,难用,不到万不得已不碰。
JNI 的痛苦我深有体会。我在一个项目里用 JNI 调了一个老的 C++ 图像处理库,当时的工作量是:写 Java native 方法 → 写 C++ 的 JNI 包装代码 → 处理内存管理 → 调试崩溃(C++ 崩溃比 Java NPE 难排查多了)。
Panama(JEP 454,Java 22 正式,Java 21 preview)提供了一套全新的 API,叫做 Foreign Function & Memory API,目标是让 Java 调用原生函数变得"正常一点"。
一、Panama 和 JNI 的本质区别
JNI 的工作流程:
- 在 Java 里声明
native方法 - 写 C/C++ 代码,实现对应的 JNI 函数(函数签名非常丑)
- 编译成动态库
- Java 加载动态库,调用
这有两个大问题:中间要写一层 C 包装代码,而且内存管理全靠手动,稍有不慎就 crash。
Panama 的方式:直接从 Java 调用 C 函数,不需要写任何 C 代码。通过 Java 自己的 API 管理内存(MemorySegment),可以精确控制内存的生命周期。
二、依赖和配置
Panama API 在 Java 22 里已经是正式特性,Java 21 里是 preview,需要加启动参数:
# Java 21(preview)
java --enable-preview --add-modules jdk.incubator.foreign -jar app.jar
# Java 22+,直接可用,无需额外配置Maven 配置(Java 21 preview):
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.enablePreview>true</maven.compiler.enablePreview>
</properties>三、从一个简单例子开始:调用 C 标准库的函数
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
public class PanamaBasicExample {
public static void main(String[] args) throws Throwable {
// 1. 获取 Linker(连接 Java 和原生世界的桥梁)
Linker linker = Linker.nativeLinker();
// 2. 找到 C 标准库
SymbolLookup stdlib = linker.defaultLookup();
// 3. 查找 strlen 函数
MemorySegment strlenAddr = stdlib.find("strlen").orElseThrow();
// 4. 描述函数签名:接收一个指针(POINTER),返回一个 long(ADDRESS 和 C_LONG_LONG)
FunctionDescriptor strlenDesc = FunctionDescriptor.of(
ValueLayout.JAVA_LONG, // 返回值:long
ValueLayout.ADDRESS // 参数:char*(指针)
);
// 5. 创建方法句柄
MethodHandle strlen = linker.downcallHandle(strlenAddr, strlenDesc);
// 6. 调用
try (Arena arena = Arena.ofConfined()) {
// 把 Java String 转成 C 字符串(\0 结尾)
MemorySegment cStr = arena.allocateFrom("Hello, Panama!");
long len = (long) strlen.invokeExact(cStr);
System.out.println("strlen 结果: " + len); // 14
}
// arena 关闭时,cStr 的内存自动释放
}
}四、内存管理:Arena
Panama 用 Arena 管理堆外内存,这是比 JNI 安全得多的方式:
// confined arena:只能在创建它的线程使用,关闭时释放所有内存
try (Arena arena = Arena.ofConfined()) {
MemorySegment buffer = arena.allocate(1024); // 分配1KB
MemorySegment str = arena.allocateFrom("hello");
// 在这个 try 块内使用 buffer 和 str
} // 自动释放!
// shared arena:可以在多个线程间共享,但关闭时需要等待所有用户完成
Arena sharedArena = Arena.ofShared();
// ... 多线程使用
sharedArena.close();
// global arena:程序生命周期内永不释放
MemorySegment global = Arena.global().allocate(100);五、一个更实际的例子:调用 libz(zlib)压缩库
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.nio.file.Files;
import java.nio.file.Path;
public class ZlibWrapper {
private final MethodHandle compress2;
private final MethodHandle uncompress;
public ZlibWrapper() throws Throwable {
Linker linker = Linker.nativeLinker();
// 加载 libz
SymbolLookup zlib = SymbolLookup.libraryLookup("z", Arena.global());
// compress2(Bytef *dest, uLongf *destLen, const Bytef *source, uLong sourceLen, int level)
this.compress2 = linker.downcallHandle(
zlib.find("compress2").orElseThrow(),
FunctionDescriptor.of(
ValueLayout.JAVA_INT, // 返回 int(错误码)
ValueLayout.ADDRESS, // dest
ValueLayout.ADDRESS, // destLen(指向 long 的指针)
ValueLayout.ADDRESS, // source
ValueLayout.JAVA_LONG, // sourceLen
ValueLayout.JAVA_INT // level
)
);
// uncompress(Bytef *dest, uLongf *destLen, const Bytef *source, uLong sourceLen)
this.uncompress = linker.downcallHandle(
zlib.find("uncompress").orElseThrow(),
FunctionDescriptor.of(
ValueLayout.JAVA_INT,
ValueLayout.ADDRESS,
ValueLayout.ADDRESS,
ValueLayout.ADDRESS,
ValueLayout.JAVA_LONG
)
);
}
public byte[] compress(byte[] data) throws Throwable {
try (Arena arena = Arena.ofConfined()) {
// 分配源数据内存
MemorySegment source = arena.allocate(data.length);
MemorySegment.copy(data, 0, source, ValueLayout.JAVA_BYTE, 0, data.length);
// 分配目标内存(压缩后最大大小)
long destMaxSize = data.length + data.length / 10 + 12;
MemorySegment dest = arena.allocate(destMaxSize);
// destLen 是一个输出参数(指向 long 的指针)
MemorySegment destLen = arena.allocate(ValueLayout.JAVA_LONG);
destLen.set(ValueLayout.JAVA_LONG, 0, destMaxSize);
int result = (int) compress2.invokeExact(dest, destLen, source, (long) data.length, 6);
if (result != 0) {
throw new RuntimeException("压缩失败,错误码: " + result);
}
long compressedSize = destLen.get(ValueLayout.JAVA_LONG, 0);
byte[] compressed = new byte[(int) compressedSize];
MemorySegment.copy(dest, ValueLayout.JAVA_BYTE, 0, compressed, 0, (int) compressedSize);
return compressed;
}
}
// decompress 方法类似,省略
}六、jextract:自动生成绑定代码
手写上面的 FunctionDescriptor 还是比较繁琐的,JDK 工具 jextract 可以自动从 C 头文件生成 Java 绑定代码:
# 安装 jextract(单独下载,不随 JDK 附带)
# 从 C 头文件生成绑定
jextract --output src/main/java \
--target-package com.example.zlib \
/usr/include/zlib.h运行后,你的 Java 源目录里会生成一堆类,对应 zlib.h 里声明的所有函数和结构体,你直接调用即可,不需要手写 FunctionDescriptor。
七、和 JNI 的对比
| 方面 | JNI | Panama |
|---|---|---|
| 需要写 C/C++ 代码 | 是(包装层) | 否 |
| 内存安全 | 手动管理,容易 crash | Arena 管理,scope 内自动释放 |
| 调试 | C crash 难排查 | Java 异常,更直观 |
| 性能 | 极高 | 接近(可能略有开销) |
| 自动生成绑定 | 不支持 | jextract 支持 |
| Java 版本要求 | 很早就有 | Java 22 正式 |
Panama 不是大多数 Java 开发者日常会用到的特性,但如果你有调原生库的需求(OpenCV、librdkafka、特定硬件 SDK 等),它让这件事从"痛苦"变成了"还行"。
下一篇写结构化并发,Java 21 的 StructuredTaskScope,这个和虚拟线程一起,真的改变了我对 Java 并发编程的一些看法。
