《Java 高级编程》8.3 原生互操作边界:稳定性、清理与替代方案

FFM 能调用 C,但 native 代码里的空指针会直接带走整个 JVM,try/catch 接不住。本节用真实的 SIGSEGV 崩溃输出说明隔离边界,讨论 Arena 清理、库卸载、跨平台分发与 JNI 迁移成本,并给出 libraryd 在 native 与纯 Java 校验器之间的选择与回退代码。

本节目标:在决定「用 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 的正确用法与常见陷阱 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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