《Java 高级编程》8.2 FFM API:MemorySegment 与调用 C 库

JNI 需要写头文件、编译胶水代码、处理引用计数。本节用 JDK 25 正式的外函数与内存 API 编译 libisbn,通过 Linker、SymbolLookup 和 MemorySegment 真实调用 isbn13_check_digit,演示 Arena 生命周期与结构体布局,并记录不加 --enable-native-access 时的告警原文。

本节目标:不写一行 JNI 胶水代码,用 java.lang.foreign 调用本机 C 库的 isbn13_check_digit,理解 Linker、SymbolLookup、MethodHandle 和 Arena 的分工,并知道不加 --enable-native-access 时 JDK 25 会输出什么。
适用版本:JDK 25(Temurin 25.0.5)。FFM API 在 JDK 22 正式;restricted methods 在 JDK 25 默认告警。

承接的问题

libraryd 的 ISBN 校验一开始是纯 Java 实现:去掉连字符、检查长度、按 1-3-1-3 加权求和、算校验位。后来业务方给了一个 C 库 libisbn,说它和印刷厂、图书馆系统的校验规则完全一致,要求 Java 侧复用它。

传统做法是 JNI:

  1. 写 native int isbn13CheckDigit(String digits12);。
  2. 用 javac -h 生成 com_plume_library_native_Isbn.h。
  3. 写 C 实现,处理 jstring 到 const char* 的转换、GetStringUTFChars/ReleaseStringUTFChars、异常检查。
  4. 编译成 .dylib/.so,通过 System.loadLibrary 加载。
  5. 每次改函数签名,头文件、C 代码、Java 声明三处一起改。

胶水代码不难,但容易出错:忘记 ReleaseStringUTFChars 会泄漏;在 C 里调用 Java 方法忘了 ExceptionCheck 会把异常吞掉;jstring 的编码在不同平台不完全一致。FFM 把这层胶水收进 JDK:Java 代码直接用 MethodHandle 描述 C 函数签名,用 MemorySegment 管理 native 内存,不需要生成头文件,也不需要写 C 侧的 JNI 包装。

代价是权限模型:调用 native 代码是 restricted operation,JDK 25 会告警,未来版本可能直接拒绝。

核心机制一:Linker、SymbolLookup 与 MethodHandle

C 库只有一个函数:

int isbn13_check_digit(const char *digits12);

它接收 12 位数字字符串,返回 0 到 9 的校验位;输入非法时返回 -1。编译成动态库:

cc -shared -fPIC -o libisbn.dylib isbn.c

本机产物:

-rwxr-xr-x  1 user  staff  16864 libisbn.dylib
libisbn.dylib: Mach-O 64-bit dynamically linked shared library arm64

Java 侧先加载库,再找符号,再把符号绑定成 MethodHandle:

package com.plume.library.ffm;

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.invoke.MethodHandle;

import static java.lang.foreign.ValueLayout.ADDRESS;
import static java.lang.foreign.ValueLayout.JAVA_INT;

public final class IsbnNative {

    private static final MethodHandle CHECK_DIGIT;

    static {
        System.load(System.getProperty("libisbn.path", "libisbn.dylib"));
        Linker linker = Linker.nativeLinker();
        SymbolLookup lookup = SymbolLookup.loaderLookup();
        MemorySegment symbol = lookup.find("isbn13_check_digit")
                .orElseThrow(() -> new IllegalStateException("isbn13_check_digit not found"));
        CHECK_DIGIT = linker.downcallHandle(
                symbol, FunctionDescriptor.of(JAVA_INT, ADDRESS));
    }

    public static int checkDigit(String digits12) {
        try (Arena arena = Arena.ofConfined()) {
            MemorySegment cString = arena.allocateFrom(digits12);
            return (int) CHECK_DIGIT.invokeExact(cString);
        } catch (Throwable failure) {
            throw new RuntimeException("native call failed", failure);
        }
    }
}

四步逐个解释:

  • System.load 把动态库加载进当前进程。参数是绝对路径或 java.library.path 下的库名。这里用系统属性 libisbn.path 传入路径,避免依赖 java.library.path 的平台差异。
  • Linker.nativeLinker() 返回当前平台的链接器,负责把 C ABI 映射成 Java 调用约定。它是 restricted method 的入口之一。
  • SymbolLookup.loaderLookup() 在「已经被 System.load 加载的库」里找符号。find 返回 Optional<MemorySegment>,找不到时给显式异常,不要等到调用时才发现。
  • downcallHandle 把符号和 FunctionDescriptor 绑定成 MethodHandle。FunctionDescriptor.of(JAVA_INT, ADDRESS) 表示「返回 int,接收一个指针」。参数和返回值的 layout 必须与 C 签名严格对应。

invokeExact 要求调用点的静态类型与 MethodHandle 的签名完全一致。checkDigit 返回 int,所以这里强转 (int);如果 C 函数返回 long,descriptor 要改成 JAVA_LONG,Java 侧也要用 long 接收。类型不匹配不会自动转换,只会抛 WrongMethodTypeException。

本机运行:

978013468599 -> 1
978711121382 -> 6
97801346859X -> -1
97801346859 -> -1

前两行是合法 ISBN-12 前缀,返回校验位;第三行含非数字,第四行只有 11 位,C 函数读到第 12 个字符时遇到 '\0',返回 -1。这个行为是 C 库的契约,Java 侧不需要额外判断,但要在文档里写清楚「-1 表示输入非法」。

核心机制二:Arena 管理 native 内存的生命周期

MemorySegment 是一段 native 内存的视图。它本身不拥有内存,拥有者是 Arena。Arena.ofConfined() 创建一个线程受限的 arena:

try (Arena arena = Arena.ofConfined()) {
    MemorySegment cString = arena.allocateFrom(digits12);
    return (int) CHECK_DIGIT.invokeExact(cString);
}

allocateFrom(String) 做三件事:按 UTF-8 编码字符串、追加一个 '\0' 终止符、返回指向起始地址的 MemorySegment。C 函数按 const char* 读取,遇到 '\0' 停止,所以不需要额外传长度。

try-with-resources 结束时调用 Arena.close(),arena 分配的所有 segment 立刻失效。之后再用这个 segment,会抛 IllegalStateException,而不是读写已释放的内存。本机用一个逃逸的 segment 验证:

MemorySegment escaped;
try (Arena arena = Arena.ofConfined()) {
    escaped = arena.allocateFrom("libraryd");
}
// arena 已关闭,escaped 不能再使用

本机输出:

java.lang.IllegalStateException: Already closed

这是 FFM 相对 JNI 的一个实际优势:JNI 里 GetStringUTFChars 拿到的指针在 ReleaseStringUTFChars 之后继续使用是未定义行为,可能读到垃圾,也可能崩溃;FFM 在 Java 层做了生命周期检查,至少能把「用已释放内存」变成可捕获的异常。

三种 arena 的分工:

Arena生命周期典型用途
Arena.ofConfined()当前线程,close 后失效一次调用的临时字符串、输出缓冲区
Arena.ofShared()多线程可见,close 后失效跨线程共享的 native 缓冲区
Arena.global()进程生命周期,不释放与进程同寿的常量、全局句柄
Arena.ofAuto()GC 决定,不可手动 close不确定生命周期、量很小的场景

绝大多数调用用 ofConfined()。它的分配和释放都在当前线程,开销低,也不会把 native 内存泄漏到 GC 之后。Arena.global() 要慎用:分配了就不回收,适合启动时加载一次的常量表,不适合每个请求都分配。

核心机制三:结构体布局与字段读取

C 库还提供了一个填充结构体的函数:

typedef struct {
    char digits[12];
    int check;
} isbn_result;

void isbn13_fill(const char *digits12, isbn_result *out);

Java 侧用 MemoryLayout 描述这个结构体:

static final MemoryLayout ISBN_RESULT = MemoryLayout.structLayout(
        MemoryLayout.sequenceLayout(12, JAVA_BYTE).withName("digits"),
        JAVA_INT.withName("check")
);

private static final MethodHandle FILL = linker.downcallHandle(
        lookup.find("isbn13_fill").orElseThrow(),
        FunctionDescriptor.ofVoid(ADDRESS, ADDRESS));

structLayout 按声明顺序排列字段,sequenceLayout(12, JAVA_BYTE) 表示 12 个字节的数组,withName 给字段起名,后面用 PathElement.groupElement("check") 定位。FunctionDescriptor.ofVoid(ADDRESS, ADDRESS) 表示返回 void,接收两个指针:输入字符串和输出结构体。

调用和读取:

try (Arena arena = Arena.ofConfined()) {
    MemorySegment input = arena.allocateFrom(digits12);
    MemorySegment result = arena.allocate(ISBN_RESULT);
    FILL.invokeExact(input, result);

    VarHandle checkHandle = ISBN_RESULT.varHandle(groupElement("check"));
    int check = (int) checkHandle.get(result, 0L);

    long offset = ISBN_RESULT.byteOffset(groupElement("digits"));
    byte[] digits = result.asSlice(offset, 12).toArray(JAVA_BYTE);
    return new String(digits) + " -> " + check;
}

arena.allocate(ISBN_RESULT) 按 layout 的大小和对齐分配内存。varHandle(groupElement("check")) 返回一个绑定到字段的 VarHandle,get(result, 0L) 的第二个参数是基址偏移,这里传 0。byteOffset 给出字段在结构体里的字节偏移,asSlice 切出 12 字节的视图,toArray 复制成 Java 数组。

本机输出:

16 bytes
978013468599 -> 1

结构体大小是 16 字节:12 字节 digits 加 4 字节 int,正好对齐,没有填充。如果字段顺序改成 int check; char digits[12];,大小会变成 16 字节,但 digits 的偏移会变成 4,末尾还会有 0 字节填充。布局必须和 C 头文件完全一致,不能靠猜。

核心机制四:native access 授权与真实告警

FFM 的 Linker::downcallHandle、SymbolLookup::libraryLookup、System::load 等都是 restricted methods。JDK 25 默认允许调用,但会打印告警。不加 --enable-native-access 运行 IsbnNative,本机输出:

WARNING: A restricted method in java.lang.System has been called
WARNING: java.lang.System::load has been called by com.plume.library.ffm.IsbnNative in an unnamed module (file:/private/tmp/vol3-c/ffm/out/)
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

978013468599 -> 1
978711121382 -> 6
97801346859X -> -1
97801346859 -> -1

告警有四个要点:

  • 它点名了调用方 com.plume.library.ffm.IsbnNative 和它所在的 unnamed module。
  • 它给出了消除告警的开关:--enable-native-access=ALL-UNNAMED。
  • 它明确说「未来版本会在未授权时阻止调用」。JDK 26 起,未授权可能从告警变成拒绝,这是迁移计划必须考虑的时间点。
  • 告警只出现一次(按模块),不会每次调用都刷屏。不要因为「只看到一次」就以为没有调用 native。

加上开关后,本机输出干净:

java --enable-native-access=ALL-UNNAMED \
  -Dlibisbn.path=/tmp/vol3-c/ffm/libisbn.dylib \
  -cp out com.plume.library.ffm.IsbnNative
978013468599 -> 1
978711121382 -> 6
97801346859X -> -1
97801346859 -> -1

如果应用已经是命名模块,开关要写模块名,而不是 ALL-UNNAMED:

java --enable-native-access=com.plume.library.app \
  --module-path ... --module com.plume.library.app/com.plume.library.app.AppMain

ALL-UNNAMED 只覆盖 classpath 上的代码。模块化之后继续用 ALL-UNNAMED,命名模块里的调用仍然会被告警。第 8.1 节把 app 做成模块后,这两件事要一起改。

在 libraryd 里落地:IsbnValidator 的双实现

FFM 调用不应该散落在业务代码里。libraryd 定义一个 IsbnValidator 接口,把 native 实现和纯 Java 实现放在后面:

package com.plume.library.domain;

public interface IsbnValidator {
    boolean isValid(String isbn);
    String name();
}

native 实现负责格式归一化和调用:

package com.plume.library.ffm;

import com.plume.library.domain.IsbnValidator;

public final class NativeIsbnValidator implements IsbnValidator {

    @Override
    public boolean isValid(String isbn) {
        String digits = isbn.replace("-", "");
        if (digits.length() != 13) {
            return false;
        }
        String first12 = digits.substring(0, 12);
        int expected = IsbnNative.checkDigit(first12);
        if (expected < 0) {
            return false;
        }
        return digits.charAt(12) - '0' == expected;
    }

    @Override
    public String name() {
        return "native-libisbn";
    }
}

纯 Java 实现 PureJavaIsbnValidator 保持同样的接口,作为 native 库不可用时的回退,具体代码在 8.3 展开。两个实现都返回 IsbnValidator,业务层只看到 isValid 和 name。

装配点根据配置选择实现:

IsbnValidator validator = switch (config.isbnValidator()) {
    case "native" -> new NativeIsbnValidator();
    case "java" -> new PureJavaIsbnValidator();
    default -> throw new IllegalArgumentException(
            "unknown isbn validator: " + config.isbnValidator());
};

这个 switch 的 selector 是字符串,所以需要 default。选择 native 时,启动阶段应该做一次自检:用 978013468599 调一遍,期望返回 1;失败就拒绝启动,而不是等到第一个借阅请求才暴露。自检通过后,LibraryService.borrow 只依赖 IsbnValidator 接口,不关心背后是 C 还是 Java。

常见错误与边界

错误一:FunctionDescriptor 与 C 签名不一致。 C 返回 int,Java 写成 JAVA_LONG,invokeExact 会抛 WrongMethodTypeException,或者读到错误的高位。size_t 在 64 位平台是 8 字节,对应 JAVA_LONG;int 是 4 字节,对应 JAVA_INT;指针统一用 ADDRESS。不要用 JAVA_INT 接指针,也不要用 JAVA_LONG 接 int。

错误二:字符串没有 NUL 终止符。 arena.allocateFrom(String) 会自动追加 '\0';如果用 MemorySegment.copy 手动写字节,必须自己补 0。C 函数按 const char* 读,没有终止符会一直读到相邻内存,结果不可预测,甚至越界崩溃。

错误三:segment 在 arena 关闭后继续使用。 本机会抛 IllegalStateException: Already closed,这是最好的情况。如果 segment 被传到另一个线程,而 arena 是 ofConfined,还会遇到线程限制错误。规则是:segment 不逃出 arena 的作用域;需要跨线程就把 arena 换成 ofShared,并保证 close 发生在所有使用之后。

错误四:用 loaderLookup 找系统 libc 的符号。 SymbolLookup.loaderLookup() 只查已经加载的库。strlen、memcmp 这些 libc 符号要用 Linker.nativeLinker().defaultLookup()。反过来,自己 System.load 的 libisbn 用 defaultLookup 找不到,因为它不在默认查找范围。两个 lookup 的分工要分清。

错误五:在虚拟线程里做长时间阻塞的 native 调用。 FFM downcall 执行期间,虚拟线程无法卸载载体线程;C 函数如果阻塞几秒,载体线程就被占住。JDK 25 已经解决了 synchronized 的 pinning,但本地调用仍是剩余 pin 场景之一。短小的纯计算(比如 ISBN 校验)没问题;会阻塞的 native 调用应该放到平台线程池,或者改成异步 native 接口。

边界一:结构体对齐。 MemoryLayout.structLayout 会按字段对齐插入填充,字段顺序改变会改变偏移和总大小。Java 侧的 layout 必须和 C 头文件逐字段对应;最好在 C 侧加 _Static_assert(sizeof(isbn_result) == 16, ...),启动时再用 ISBN_RESULT.byteSize() 对一次。

边界二:native 库的分发。 libisbn.dylib 是 macOS arm64 产物,Linux 需要 libisbn.so,Windows 需要 libisbn.dll。FFM 不解决跨平台打包,System.load 的路径、库名、依赖库都要按平台准备。第 8.3 节会讨论什么时候干脆不要用 native。

小结

  • FFM 用 Linker、SymbolLookup、FunctionDescriptor 把 C 函数绑定成 MethodHandle,不需要 JNI 头文件和胶水代码。
  • Arena.ofConfined() 管理一次调用的 native 内存,close 后 segment 立即失效;allocateFrom(String) 自动追加 NUL。
  • 结构体用 MemoryLayout.structLayout 描述,字段偏移和总大小必须与 C 头文件一致;本机 isbn_result 是 16 字节。
  • JDK 25 对 restricted methods 默认告警,--enable-native-access=ALL-UNNAMED 只覆盖 classpath;命名模块要写模块名。
  • 本机不加开关时的告警原文点名了 System::load 和 unnamed module,并明确说未来版本会阻止未授权调用。
  • libraryd 用 IsbnValidator 接口隔离 native 与纯 Java 实现,启动自检失败就回退,不让业务代码直接依赖 IsbnNative。

能调用 C 只是第一步。下一节讨论边界:native 崩溃会带走整个 JVM,Arena 的生命周期、跨平台分发、JNI 迁移成本都会影响「该不该用 FFM」这个决定。

阅读导航:上一节:8.1 JPMS 模块化:module-info、requires 与 jlink · 下一节:8.3 原生互操作边界:稳定性、清理与替代方案 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发