过去十几年,Java 想碰一碰操作系统底层,绕不开两个选择:要么写 JNI,要么认命用 Runtime.exec 去解析命令行输出。前者要维护一份 .c 文件和一套本地构建链,后者脆弱得像用正则解析 HTML。
JDK 22 把外部函数与内存 API(FFM API)正式定稿,java.lang.foreign 包脱离了 preview。这意味着从 Java 22 开始,调用 libc 里的任意函数,跟调用一个普通 Java 方法没什么区别——没有 C 代码,没有编译产物,没有 --enable-preview 参数。
这篇文章不讲概念,直接上手三个能跑的例子:读系统负载、解析 uname 结构体、内存映射大文件。代码在 OpenJDK 22 和 25 上都验证过。
FFM API 的四个核心概念
看代码之前先把名词理清楚,不然一路都是问号。
- Linker:连接器,负责把 Java 的调用翻译成目标平台的本地调用约定。用
Linker.nativeLinker()拿到当前平台的实例。 - SymbolLookup:符号查找器,负责把函数名字符串变成内存地址。
linker.defaultLookup()会去当前已加载的公共库(Linux 下就是 libc)里找。 - FunctionDescriptor:函数签名描述,说清楚参数和返回值各是什么类型。这里写错一个字符,运行时就会炸。
- Arena:内存作用域,负责管理本地内存的生死。它实现了 AutoCloseable,用 try-with-resources 包起来,退出时自动释放,不需要 free。
把本地内存的生命周期绑在 Arena 上,是 FFM 相比 Unsafe 和 ByteBuffer 最舒服的地方。你不再需要思考「这块内存什么时候释放」,只需要思考「它属于哪个作用域」。
案例一:读取系统负载
先来个最简单的。getloadavg 的 C 原型是:
int getloadavg(double loadavg[], int nelem);
它会往你给的数组里填 1 到 3 个浮点数,返回实际填充的数量,失败返回 -1。
对应的 Java 代码:
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.SymbolLookup;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;
public final class SysInfo {
private static final Linker LINKER = Linker.nativeLinker();
private static final SymbolLookup LIBC = LINKER.defaultLookup();
private static final MethodHandle GETLOADAVG = LINKER.downcallHandle(
requireSymbol("getloadavg"),
FunctionDescriptor.of(
ValueLayout.JAVA_INT, // 返回值
ValueLayout.ADDRESS, // double[] 数组指针
ValueLayout.JAVA_INT)); // nelem
private static MemorySegment requireSymbol(String name) {
return LIBC.find(name).orElseThrow(
() -> new UnsatisfiedLinkError("找不到符号 " + name + ",类 Unix 系统之外跑不了"));
}
private SysInfo() {
}
public static double[] loadAverage() {
try (Arena arena = Arena.ofConfined()) {
// 分配 3 个 double 的连续内存,等价于 C 里的 malloc(3 * sizeof(double))
MemorySegment buf = arena.allocate(ValueLayout.JAVA_DOUBLE, 3);
int n = (int) GETLOADAVG.invokeExact(buf, 3);
if (n <= 0) {
return new double[0];
}
double[] result = new double[n];
for (int i = 0; i < n; i++) {
result[i] = buf.getAtIndex(ValueLayout.JAVA_DOUBLE, i);
}
return result;
} catch (Throwable t) {
throw new IllegalStateException("调用 getloadavg 失败", t);
}
}
}
几个细节值得停下来看。
ValueLayout.ADDRESS 对应 C 的指针类型。传 MemorySegment 进去就行,虚拟机会自动取出它的基地址。
返回值那一行写的是 (int) GETLOADAVG.invokeExact(buf, 3)。这个强制转换看着多余,其实是必须的——invokeExact 是签名多态方法,编译器要靠调用点的目标类型来推断精确描述符。去掉这个转换,编译直接不通过。
arena.allocate(ValueLayout.JAVA_DOUBLE, 3) 分配出来的内存是零初始化的,这一点跟 malloc 不一样,比它安全。
整个方法体没有任何 free 调用。try 块结束时 Arena 关闭,内存归还,链路清晰。
案例二:解析 uname 结构体
光传数组不够看,真正麻烦的是结构体。C 里定义一个结构体,Java 这边怎么描述它的内存布局?
Linux 的 utsname 长这样:
struct utsname {
char sysname[65];
char nodename[65];
char release[65];
char version[65];
char machine[65];
char domainname[65];
};
六个等长的字节数组。用 MemoryLayout.structLayout 加 sequenceLayout 就能精确还原:
import java.lang.foreign.MemoryLayout;
import java.lang.foreign.StructLayout;
import java.lang.foreign.ValueLayout;
private static final StructLayout UTSNAME = MemoryLayout.structLayout(
MemoryLayout.sequenceLayout(65, ValueLayout.JAVA_BYTE).withName("sysname"),
MemoryLayout.sequenceLayout(65, ValueLayout.JAVA_BYTE).withName("nodename"),
MemoryLayout.sequenceLayout(65, ValueLayout.JAVA_BYTE).withName("release"),
MemoryLayout.sequenceLayout(65, ValueLayout.JAVA_BYTE).withName("version"),
MemoryLayout.sequenceLayout(65, ValueLayout.JAVA_BYTE).withName("machine"),
MemoryLayout.sequenceLayout(65, ValueLayout.JAVA_BYTE).withName("domainname"));
private static final MethodHandle UNAME = LINKER.downcallHandle(
requireSymbol("uname"),
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS));
注意 withName。它不影响内存布局,但后面取字段偏移量的时候要用到这个名字,强烈建议加上。
然后是调用和取值:
public static String[] uname() {
try (Arena arena = Arena.ofConfined()) {
MemorySegment buf = arena.allocate(UTSNAME);
int rc = (int) UNAME.invokeExact(buf);
if (rc != 0) {
throw new IllegalStateException("uname 返回码 " + rc);
}
return new String[]{
readField(buf, "sysname"),
readField(buf, "nodename"),
readField(buf, "release"),
readField(buf, "version"),
readField(buf, "machine")
};
} catch (Throwable t) {
throw new IllegalStateException("调用 uname 失败", t);
}
}
private static String readField(MemorySegment buf, String field) {
long offset = UTSNAME.byteOffset(MemoryLayout.PathElement.groupElement(field));
return buf.getString(offset);
}
这里的关键是 UTSNAME.byteOffset(...)。它会算出字段相对于结构体起始位置的字节偏移量,编译期就能确定,运行时不产生额外计算。有了偏移量,buf.getString(offset) 就能把以 NUL 结尾的字节序列读成 Java 字符串。
因为每个字段都是定长 65 字节的数组,字段之间不会有对齐填充,这个布局在 x86_64 和 ARM64 上都成立。如果换成带 long 或指针的结构体,就必须考虑对齐问题,那时候用 MemoryLayout.structLayout(...) 让 JDK 自己算填充位更稳妥——它会遵循平台的 ABI 规则插入匿名填充。
案例三:内存映射大文件
FFM API 带来的第二个便利是 FileChannel.map 的新重载,它直接返回 MemorySegment,不用再跟 MappedByteBuffer 打交道了。
下面这个方法统计一个文件里有多少个换行符,走的是页缓存,比 BufferedReader 逐行读更快,因为它压根不创建对象:
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
public static long countLines(Path file) throws IOException {
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ);
Arena arena = Arena.ofConfined()) {
long size = ch.size();
if (size == 0) {
return 0;
}
MemorySegment data = ch.map(FileChannel.MapMode.READ_ONLY, 0, size, arena);
long lines = 0;
for (long i = 0; i < size; i++) {
if (data.get(ValueLayout.JAVA_BYTE, i) == 'n') {
lines++;
}
}
return lines;
}
}
这里的内存映射由 Arena 统一保管:Arena 关闭时映射自动解除,不需要显式 unmap。MappedByteBuffer 时代那个「文件删不掉、映射还在」的坑,到这里算是翻篇了。
顺带一提,如果要做更复杂的模式匹配,MemorySegment.mismatch 可以直接比对一个模式段和一大块内存,返回第一个不匹配的位置,写高性能扫描器的时候非常好用。
Arena 该怎么选
四个工厂方法,用途完全不同,选错了要么报错要么漏内存:
| 方法 | 适用场景 | 注意点 |
|---|---|---|
Arena.ofConfined() |
默认选择,单线程内使用 | 跨线程访问抛 WrongThreadException,开销最低 |
Arena.ofShared() |
内存需要被多个线程共享 | 比 confined 慢一点,且不能在共享内存上做 close |
Arena.ofAuto() |
生命周期不确定,优先保命 | 交给 GC 回收,延迟不可控 |
Arena.global() |
进程级常驻内存,比如缓存的函数句柄 | 永不释放,别往里塞大块数据 |
实践里 90% 的情况用 ofConfined 就够了。真正需要跨线程的场景,往往应该重新想想数据是不是该在 Java 堆里共享,而不是在本地内存里。
几个我踩过的坑
第一个坑:MethodHandle 和 FunctionDescriptor 对不上,报 WrongMethodTypeException。
我第一次照着文档写的时候,把 ValueLayout.JAVA_INT 写成了 JAVA_LONG,编译期毫无反应,一运行就抛 WrongMethodTypeException: cannot convert MethodHandle(...)int to (...)long。这个异常信息其实挺明确的,但第一次遇到容易懵。解决办法很土:把 invokeExact 换成 invoke 先跑通,invoke 会做类型适配,能跑通说明描述符基本对,再把 invoke 换回 invokeExact。
第二个坑:Arena 关掉之后还在用 MemorySegment。
类似这样:
MemorySegment escaped;
try (Arena arena = Arena.ofConfined()) {
escaped = arena.allocate(64);
}
// 上面 try 块结束,arena 已关闭
escaped.set(ValueLayout.JAVA_BYTE, 0, (byte) 1); // IllegalStateException
会抛 IllegalStateException: Already closed。这个设计其实是优点——总比访问一块已经被 free 的内存强。但如果你确实需要把内存带出作用域,那就用 Arena.ofAuto(),或者把整个 Arena 的生命周期提到更外层。
第三个坑:defaultLookup 在 macOS 上找不到某些符号。
linker.defaultLookup() 查的是已经加载进进程的公共符号。Linux 下 libc 通常没问题,macOS 偶尔会漏。这时候改用显式加载:
try (Arena arena = Arena.ofShared()) {
SymbolLookup lib = SymbolLookup.libraryLookup("/usr/lib/libSystem.B.dylib", arena);
MemorySegment sym = lib.find("getloadavg").orElseThrow();
// ...
}
注意这里的 Arena 不能提前关闭,符号查找结果的存活期跟着它走。
写在最后
FFM API 定稿之后,Java 和本地代码之间的那道墙基本算是拆了。downcall 的开销在预热之后是纳秒量级,跟 JNI 处在同一个水平线上,真正的区别不在速度,而在于你不再需要一个 .c 文件、一套 CMake 配置、一个随平台变化的编译产物。整个绑定逻辑用纯 Java 描述,跟着 Jar 包走。
如果你的项目里还躺着一堆 JNI 胶水代码,或者为了读个系统指标就得 Runtime.exec 加字符串切割,值得花一个下午把 JDK 升到 22 以上,把这块重写一遍。门槛比想象中低,收益比想象中实在。

