Java 外部函数与内存 API 实战:不写 C 代码直接调用系统库

2026-10-04 0 639

过去十几年,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 以上,把这块重写一遍。门槛比想象中低,收益比想象中实在。

Java 外部函数与内存 API 实战:不写 C 代码直接调用系统库
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 java Java 外部函数与内存 API 实战:不写 C 代码直接调用系统库 https://www.taomawang.com/server/java/2858.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务