本节目标:在决定「用 FFM 调 C」之前,先看清三件事:native 崩溃能不能被隔离、native 资源由谁清理、跨平台分发和 JNI 迁移要付多少成本。
适用版本:JDK 25(Temurin 25.0.5)。FFM API 正式;restricted methods 默认告警,未授权调用在未来版本可能被拒绝。
承接的问题
8.2 的 libisbn 工作正常:isbn13_check_digit 对合法输入返回校验位,对非法输入返回 -1。这个例子太干净,容易让人以为「FFM 就是把 JNI 换了个写法」。
真实世界里的 C 库会做这些事:
- 解引用空指针、越界写、释放后继续使用,直接触发
SIGSEGV或SIGBUS。 - 在内部线程里崩溃,堆栈和 Java 线程没有对应关系。
- 分配了文件句柄、数据库连接、GPU 缓冲区,需要显式
destroy/close,不会因为 Java 对象被 GC 就释放。 - 只提供 macOS arm64 的
.dylib,Linux x86_64 和 Windows 要另外编译。 - 依赖特定版本的
libc、OpenSSL 或系统框架,换一台机器就加载失败。
这些问题不是 FFM 引入的,JNI 一样有。FFM 改变的是「写胶水的成本」和「内存生命周期的可检查性」,没有改变「native 代码和 JVM 共享同一个进程地址空间」这个事实。本节先把最坏情况跑出来,再讨论什么时候值得承担这个风险。
核心机制一:native 崩溃不能被 try/catch 接住
写一个必定崩溃的 C 函数:
int crash_now(void) {
volatile int *p = (volatile int *)0;
return *p;
}
编译成 libcrash.dylib,Java 侧用 FFM 调用:
System.load("/tmp/vol3-c/ffm/libcrash.dylib");
Linker linker = Linker.nativeLinker();
SymbolLookup lookup = SymbolLookup.loaderLookup();
MemorySegment symbol = lookup.find("crash_now").orElseThrow();
MethodHandle crash = linker.downcallHandle(
symbol, FunctionDescriptor.of(JAVA_INT));
try (Arena arena = Arena.ofConfined()) {
System.out.println("before native call");
int value = (int) crash.invokeExact();
System.out.println("after native call: " + value);
}
本机运行,进程输出:
before native call
#
# A fatal error has been detected by the Java Runtime Environment:
#
# SIGSEGV (0xb) at pc=0x00000001028aff9c, pid=16678, tid=9987
#
# JRE version: OpenJDK Runtime Environment Temurin-25.0.5+8 (25.0.5+8) (build 25.0.5+8-LTS)
# Java VM: OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (25.0.5+8-LTS, mixed mode, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, bsd-aarch64)
# Problematic frame:
# C [libcrash.dylib+0x3f9c] crash_now+0xc
#
# CreateCoredumpOnCrash turned off, no core file dumped
#
# An error report file with more information is saved as:
# /tmp/vol3-c/ffm/hs_err_16678.log
[0.731s][warning][os] Loading hsdis library failed
#
# If you would like to submit a bug report, please visit:
# https://github.com/adoptium/adoptium-support/issues
# The crash happened outside the Java Virtual Machine in native code.
# See problematic frame for where to report the bug.
几个细节:
before native call打印了,after native call没有。崩溃发生在invokeExact内部,进程直接终止。- 退出码是 1,不是 Java 异常的退出码。
try/catch (Throwable)一行都不会执行,因为信号处理器接管后调用abort/exit,JVM 没有机会展开 Java 栈。 Problematic frame指向libcrash.dylib的crash_now+0xc,这是定位 native 问题的关键行。没有它,只能看到SIGSEGV。- JVM 生成了
hs_err_pid.log,里面有寄存器、线程栈、加载的库、JVM 参数。生产环境要保留这个文件,它是 native 崩溃的唯一现场。
这条边界决定了架构:如果 C 库的崩溃概率不可接受,FFM 调用必须放到独立进程里,通过 IPC 或 HTTP 把结果传回来。JVM 内的 try/catch、CompletableFuture.exceptionally、Thread.UncaughtExceptionHandler 都接不住 SIGSEGV。-Xcheck:jni 只检查 JNI 调用的参数和引用,不检查 FFM,也不检查 C 库内部的内存错误。
核心机制二:Arena 清理的是内存,不是 native 资源
8.2 已经看到 Arena.ofConfined() 的 close 会让 segment 失效。这里要把「清理」拆成三层:
第一层,Java 分配的 native 内存。Arena 负责。ofConfined/ofShared 在 close 时释放 arena 分配的所有块;ofAuto 由 GC 在合适时机释放;global 永不释放。这一层 FFM 管得很好。
第二层,C 库自己分配的资源。Arena 完全不管。例如:
typedef struct isbn_db isbn_db;
isbn_db *isbn_db_open(const char *path);
void isbn_db_close(isbn_db *db);
int isbn_db_check(isbn_db *db, const char *digits12);
isbn_db_open 返回的句柄里有文件描述符、内存映射、可能的锁。Java 侧必须再绑定一个 isbn_db_close 的 downcall,并在 finally 或 AutoCloseable 里调用。只 close Arena 不会释放这些资源,C 库那边会泄漏。
第三层,动态库本身。System.load 加载的库在进程生命周期内不会卸载。即使所有 Arena 都关闭,libisbn.dylib 的代码段、全局变量、静态初始化仍然驻留。JDK 没有提供「卸载单个 native 库」的受支持 API;要彻底卸载,只能重启进程或使用独立的类加载器加 native 库隔离,但后者在 macOS 和 Linux 上的行为并不一致。
因此,libraryd 的 native 封装应该长这样:
public final class IsbnNativeLibrary implements AutoCloseable {
private final Arena arena;
private final MethodHandle checkDigit;
public IsbnNativeLibrary(Path libraryPath) {
System.load(libraryPath.toAbsolutePath().toString());
Linker linker = Linker.nativeLinker();
MemorySegment symbol = SymbolLookup.loaderLookup()
.find("isbn13_check_digit")
.orElseThrow(() -> new IllegalStateException("symbol not found"));
this.checkDigit = linker.downcallHandle(
symbol, FunctionDescriptor.of(JAVA_INT, ADDRESS));
this.arena = Arena.ofShared();
}
public int checkDigit(String digits12) {
MemorySegment input = arena.allocateFrom(digits12);
try {
return (int) checkDigit.invokeExact(input);
} catch (Throwable failure) {
throw new RuntimeException(failure);
}
}
@Override
public void close() {
arena.close();
}
}
这里用 Arena.ofShared(),因为 IsbnNativeLibrary 可能被多个请求线程共享;Arena.ofConfined() 只能由创建它的线程访问。close() 只释放 arena 分配的内存,libisbn 本身仍然加载着。如果 C 库有 isbn_global_init/isbn_global_shutdown,还要在 close() 里调用 shutdown,并且保证没有线程还在调用。
有一个容易忽略的点:System.load 同一个路径两次不会报错,但同一个库被不同类加载器加载会得到不同的副本,全局状态可能重复初始化。封装类应该只创建一次,通过依赖注入传递,不要在每个请求里 new IsbnNativeLibrary(...)。
核心机制三:什么时候不该用 FFM
FFM 适合「C 库稳定、调用频繁、数据量小、平台可控」的场景。下面四类场景要慎重,甚至应该明确不用。
跨平台分发。 libisbn.dylib 是 macOS arm64 产物。Linux 需要 .so,Windows 需要 .dll;Linux 还要区分 glibc 和 musl,x86_64 和 aarch64。每个平台都要编译、测试、打包,CI 要覆盖矩阵。FFM 只负责调用,不负责找库、解压、校验哈希。libraryd 如果要做成「一个 jar 到处跑」,纯 Java 实现的 ISBN 校验更合适;native 版本只能作为可选加速或兼容层。
崩溃隔离。 前面已经看到,native 崩溃会带走整个 JVM。如果 C 库来自第三方、代码质量不可控、处理的是不可信输入(比如用户上传的文件),把调用放进独立进程更安全。Java 侧用 ProcessBuilder 启动一个小的 native CLI,通过 stdin/stdout 或本地 socket 通信;进程崩了只影响这一次请求,主服务还能返回 503。代价是进程启动和 IPC 开销,适合低频、重负载的调用,不适合每个借阅请求都走一遍。
JNI 迁移成本。 如果现有 JNI 代码已经稳定运行多年,胶水层有测试覆盖,迁移到 FFM 的收益主要是「少写 C 代码」和「内存生命周期可检查」。但如果 JNI 层封装了大量回调、全局引用、线程附着逻辑,迁移会牵动整个模块。不要为了「用新 API」重写;先在新增的小函数上试 FFM,验证工具链和部署,再决定是否扩大。
纯 Java 能做的事。 ISBN 校验、字符串处理、简单加解密、JSON 解析,这些都有成熟的纯 Java 实现。纯 Java 没有 native 加载失败、没有 ABI 不匹配、没有崩溃隔离问题,JIT 优化后性能通常足够。只有当 profiling 证明 native 是瓶颈,或者必须复用某个只有 C 实现的算法时,才引入 FFM。
一个实用的决策表:
| 条件 | 倾向 |
|---|---|
| 纯 Java 实现可用,性能满足 | 纯 Java |
| 已有稳定 JNI,迁移无明确收益 | 保留 JNI |
| C 库稳定、输入可信、平台固定 | FFM |
| C 库可能崩溃、输入不可信 | 独立进程 + IPC |
| 需要一套 jar 跨 macOS/Linux/Windows | 纯 Java,或按平台分发 native |
| 调用频率极低、单次耗时很长 | 独立进程,避免 pin 载体线程 |
这张表不是绝对规则,但它把「语言偏好」换成了「故障模式和维护成本」。
在 libraryd 里落地:native 优先,纯 Java 兜底
libraryd 的 ISBN 校验最终做成双实现。接口已经在 8.2 定义:
public interface IsbnValidator {
boolean isValid(String isbn);
String name();
}
纯 Java 实现是默认值,也是 native 不可用时的回退:
package com.plume.library.domain;
public final class PureJavaIsbnValidator implements IsbnValidator {
@Override
public boolean isValid(String isbn) {
String digits = isbn.replace("-", "");
if (digits.length() != 13) {
return false;
}
int sum = 0;
for (int i = 0; i < 12; i++) {
char c = digits.charAt(i);
if (c < '0' || c > '9') {
return false;
}
int digit = c - '0';
sum += (i % 2 == 0) ? digit : digit * 3;
}
int expected = (10 - (sum % 10)) % 10;
return digits.charAt(12) - '0' == expected;
}
@Override
public String name() {
return "pure-java";
}
}
工厂负责选择实现。它只在启动时尝试加载 native 库,加载失败或自检失败就回退,不把异常抛到请求路径:
package com.plume.library.ffm;
import com.plume.library.domain.IsbnValidator;
import com.plume.library.domain.PureJavaIsbnValidator;
import java.nio.file.Files;
import java.nio.file.Path;
public final class IsbnValidatorFactory {
private IsbnValidatorFactory() {
}
public static IsbnValidator create(String mode, Path libraryPath) {
if (!"native".equals(mode)) {
return new PureJavaIsbnValidator();
}
if (!Files.isRegularFile(libraryPath)) {
System.err.println("isbn: native library missing, falling back to java");
return new PureJavaIsbnValidator();
}
try {
IsbnNativeLibrary library = new IsbnNativeLibrary(libraryPath);
int probe = library.checkDigit("978013468599");
if (probe != 1) {
System.err.println("isbn: native self-check returned " + probe
+ ", falling back to java");
library.close();
return new PureJavaIsbnValidator();
}
return new NativeIsbnValidator(library);
} catch (RuntimeException | LinkageError failure) {
System.err.println("isbn: native load failed: " + failure);
return new PureJavaIsbnValidator();
}
}
}
8.2 里的静态封装只用于最小演示;生产版本让 NativeIsbnValidator 持有 IsbnNativeLibrary,把 checkDigit 包装成 isValid:
package com.plume.library.ffm;
import com.plume.library.domain.IsbnValidator;
public final class NativeIsbnValidator implements IsbnValidator {
private final IsbnNativeLibrary library;
public NativeIsbnValidator(IsbnNativeLibrary library) {
this.library = library;
}
@Override
public boolean isValid(String isbn) {
String digits = isbn.replace("-", "");
if (digits.length() != 13) {
return false;
}
int expected = library.checkDigit(digits.substring(0, 12));
return expected >= 0 && digits.charAt(12) - '0' == expected;
}
@Override
public String name() {
return "native-libisbn";
}
}
启动日志会打印 isbn validator: pure-java 或 isbn validator: native-libisbn,/health 也把这个名字放进 checks.isbn。这样运维能一眼看出当前实例用的是哪条路径,而不是等用户反馈「校验结果不一致」才发现加载失败。
本机用 FactoryDemo 分别指向存在的库和不存在的库,输出:
native-libisbn true
isbn: native library missing, falling back to java
pure-java true
第一行走了 native 路径,第二行打印回退原因,第三行用纯 Java 算出同样的结果。回退路径不是「理论上可用」,而是每次启动都会真实走一遍的代码。
常见错误与边界
错误一:用 try/catch 包 native 调用,以为能兜住崩溃。 本机的 CrashDemo 证明不会:崩溃发生后进程直接终止,catch (Throwable) 没有机会执行。能捕获的只有 Java 异常和 LinkageError;SIGSEGV、SIGBUS、SIGILL 不在其中。需要隔离就用独立进程。
错误二:只关闭 Arena,忘记关闭 C 库句柄。 Arena 只释放它自己分配的内存。isbn_db_open 返回的句柄、文件描述符、线程池都要显式调用对应的 close/destroy。封装类实现 AutoCloseable,在 close() 里按「先停调用、再关句柄、最后关 Arena」的顺序清理。
错误三:每个请求创建一个 Arena.global。 Arena.global() 分配的内存永不释放,放在请求路径上就是稳定的 native 内存泄漏。请求级临时数据用 Arena.ofConfined(),跨请求共享的只读数据才考虑 global,并且要在启动时一次性分配。
错误四:假设 FFM 一定比 JNI 快。 FFM 的 downcall 有方法句柄调用、参数封送、安全检查;JNI 有 JNIEnv 调用和引用管理。两者谁快取决于调用形态、参数数量和 JIT 内联情况。没有基准测试就不要在文档里写「FFM 更快」。ISBN 校验这种纯计算,纯 Java 往往已经足够快,native 的收益可能抵不过一次跨语言调用。
错误五:忽略库的 ABI 和架构。 macOS arm64 的 .dylib 不能在 x86_64 上加载;Linux glibc 编译的 .so 在 Alpine musl 上可能缺符号。加载失败的错误可能是 UnsatisfiedLinkError,也可能是更底层的 dlopen 错误。启动自检要打印库路径、架构和错误原文,不要只写「native 不可用」。
边界一:虚拟线程与阻塞 native。 FFM downcall 执行期间,虚拟线程无法卸载载体线程。短小的纯计算没问题;会阻塞的 native 调用(读文件、等网络、等锁)应该放到平台线程池,或者改成异步接口。第 6 章讲过虚拟线程的 pin 场景,native 调用是 JDK 25 里仍然存在的一类。
边界二:崩溃日志的保留。 hs_err_pid.log 默认写在进程工作目录,容器里可能随 Pod 一起消失。生产环境用 -XX:ErrorFile=/var/log/libraryd/hs_err_%p.log 固定路径,并把目录挂到持久卷。没有这份日志,native 崩溃只能看到「进程退出码 1」,无法定位是哪个库。
小结
- native 崩溃(
SIGSEGV等)会终止整个 JVM,try/catch接不住;本机崩溃输出里的Problematic frame是定位关键。 - Arena 只清理 Java 分配的 native 内存;C 库句柄、文件描述符、线程池要显式关闭,动态库本身在进程内不会卸载。
- 跨平台分发、崩溃隔离、JNI 迁移成本、纯 Java 可替代性,是决定「不用 FFM」的四个主要理由。
libraryd用IsbnValidator接口加工厂做 native 优先、纯 Java 兜底,启动自检失败就回退,不把加载异常带进请求路径。- 不要在请求路径上使用
Arena.global();不要把「FFM 一定更快」当成事实,性能和稳定性都要有测量。 - 生产环境固定
-XX:ErrorFile,保留 native 崩溃现场。
平台层这一章到此结束。下一章进入性能工程:先用 JMH 把「一次调用多快」测准,再用 JFR 找出「时间花在哪」,最后把测量结果折算成分配、锁和批处理的优化决策。
阅读导航:上一节:8.2 FFM API:MemorySegment 与调用 C 库 · 下一节:9.1 基准测试:JMH 的正确用法与常见陷阱 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。