Java 基础体系 · 第 53/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 25 Foreign Function & Memory API:Arena、MemorySegment 和本地调用

Foreign Function & Memory API(简称 FFM)是 Java 访问进程外部内存、调用本地函数以及接收本地回调的标准 API。它在 Java 22 中正式定稿,Java 25 LTS 延续了这一 API。

FFM 解决的是两类问题:

  1. Foreign Memory:在 Java 堆之外分配、访问和释放内存。
  2. Foreign Function:按照本地 ABI(Application Binary Interface,应用程序二进制接口)调用 C 或其他本地语言导出的函数。

它并不是 JNI 的简单替代语法。FFM 将本地内存的地址范围、生命周期、线程访问限制和函数参数布局显式建模为 Java 对象,因此可以在调用本地代码前进行更多检查;但本地函数内部仍然可能发生 C/C++ 意义上的未定义行为,Java 无法自动消除这种风险。


一、先建立整体模型

一次典型的 FFM 本地调用包含以下对象:

Java 代码
   │
   ├── Arena
   │     └── 管理内存生命周期
   │
   ├── MemorySegment
   │     └── 描述一段内存的地址、范围、权限和生命周期
   │
   ├── SymbolLookup
   │     └── 查找本地函数地址
   │
   ├── FunctionDescriptor
   │     └── 描述本地函数的参数和返回值布局
   │
   ├── Linker.downcallHandle(...)
   │     └── 创建 Java MethodHandle
   │
   └── MethodHandle.invokeExact(...)
         │
         ▼
      本地函数

其中最容易混淆的是:

  • Arena 管理的是生命周期和分配策略
  • MemorySegment 表示的是一段可访问的内存范围
  • MemoryLayout 描述的是数据在内存中的布局
  • FunctionDescriptor 描述的是函数调用约定中的参数和返回值布局
  • Linker 将 Java 侧描述转换为实际的本地调用句柄。

可以把一个 MemorySegment 抽象为:

S=(a,n,σ,p)S = (a, n, \sigma, p)

其中:

  • aa:内存起始地址;
  • nn:内存大小;
  • σ\sigma:生命周期作用域;
  • pp:访问权限和线程访问规则。

Java 访问偏移量为 oo、长度为 ll 的区域时,至少必须满足:

0o,0l,o+ln0 \le o,\quad 0 \le l,\quad o+l \le n

同时还必须满足:

  1. MemorySegment 仍然存活;
  2. 当前线程有权访问它;
  3. 访问方式与其权限兼容;
  4. 使用的 ValueLayout 与实际数据类型、对齐和字节序相匹配。

这就是 FFM 的核心安全边界。它不能保证本地函数实现正确,但可以在 Java 访问阶段检查很多越界、越权和生命周期错误。


二、Arena:本地内存的生命周期管理器

2.1 为什么需要 Arena

Java 堆内对象的生命周期由垃圾回收器管理,而本地内存不属于 Java 堆。若每次调用 malloc 后都手工调用 free,容易出现:

  • 忘记释放导致泄漏;
  • 重复释放;
  • 释放后继续访问;
  • 多个对象释放顺序不一致;
  • 异常路径没有执行清理逻辑。

Arena 将一组本地内存分配绑定到同一个生命周期。关闭 Arena 时,它负责释放由该 Arena 管理的内存。

最常见的用法是词法作用域管理:

try (Arena arena = Arena.ofConfined()) {
    MemorySegment buffer = arena.allocate(1024);

    // 在这里使用 buffer
} // 离开作用域时,buffer 对应的本地内存被释放

Arena 实现了 AutoCloseable,因此可以配合 try-with-resources 使用。这里的关键因果关系是:

  1. arena.allocate(1024) 分配本地内存;
  2. 返回的 MemorySegment 记录该内存属于这个 Arena
  3. arena.close() 使相关 segment 失效并释放资源;
  4. 之后继续访问该 segment,会触发 Java 侧的生命周期检查,而不是继续访问已释放内存。

2.2 三种常见 Arena

Arena.ofConfined()

ofConfined() 创建线程受限的 Arena。

它具有两个重要特征:

  • 只有创建它的线程可以访问其中的 segment;
  • 通常也只有创建线程可以关闭它。

这适合具有明确所有权的同步代码:

try (Arena arena = Arena.ofConfined()) {
    MemorySegment segment = arena.allocate(64);
    // 创建线程分配、访问并关闭
}

如果把这个 segment 传给另一个线程访问,可能抛出 WrongThreadException。这种限制不是缺陷,而是帮助程序员明确本地内存的所有权,避免无意间构造跨线程共享的裸指针。

Arena.ofShared()

ofShared() 创建可被多个线程访问的 Arena。

它适合以下场景:

  • 本地代码会在其他线程中回调 Java;
  • 一个本地缓冲区需要在线程之间共享;
  • 生命周期由多个线程协作管理。

但是,ofShared() 只放宽了线程访问限制,并不自动提供并发同步。两个线程同时修改同一片内存,仍然可能发生数据竞争。

换句话说:

ofShared() 解决“能否跨线程访问”
同步机制     解决“并发访问是否正确”

Arena.global()

Arena.global() 表示全局生命周期的 Arena。其分配内容通常存活到 Java 进程结束,不需要显式关闭。

它适合真正的进程级资源,例如:

  • 本地库要求永久保存的静态数据;
  • 整个进程生命周期内都有效的回调函数指针;
  • 不方便绑定到某个短期请求的资源。

但全局 Arena 也意味着资源不能按请求及时回收。如果把所有临时数据都放入全局 Arena,就会形成近似永久泄漏。

2.3 Arena 状态变化

Arena 和其分配的 segment 可以抽象为以下状态:

stateDiagram-v2
    [*] --> Open
    Open --> Open: allocate / access
    Open --> Closing: close()
    Closing --> Closed
    Closed --> [*]

    Open --> Error: 非所有者线程访问 confined arena
    Open --> Error: 越界访问
    Closed --> Error: 继续访问 segment

close() 不是“通知垃圾回收器以后再释放”,而是立即改变相关 segment 的有效状态。一个 segment 的 Java 引用仍然可能存在,但它已经不再是可访问的本地内存视图。


三、MemorySegment:受生命周期约束的内存视图

3.1 MemorySegment 不等于裸地址

MemorySegment 可以表示一段本地内存,也可以表示 Java 堆数组或 ByteBuffer 背后的内存区域。

它不是简单的 long address。除了地址,它还携带:

  • 可访问范围;
  • 生命周期;
  • 读写权限;
  • 线程访问限制;
  • 是否允许作为本地地址传递。

因此,FFM 中传递内存时,通常传递的是 MemorySegment,而不是手工维护一个 long 地址。

例如:

try (Arena arena = Arena.ofConfined()) {
    MemorySegment segment = arena.allocate(16);

    segment.set(ValueLayout.JAVA_INT, 0, 123);
    int value = segment.get(ValueLayout.JAVA_INT, 0);

    System.out.println(value);
}

这里:

  • segment 表示 16 字节区域;
  • ValueLayout.JAVA_INT 表示以 Java int 的布局读写 4 字节;
  • 偏移量 0 表示从 segment 起始处访问;
  • set 写入 123;
  • get 读取同一个位置。

如果把偏移量改为 13,则访问范围是 [13, 17),超过 16 字节边界,Java 侧会拒绝该访问。

3.2 空间安全和时间安全

FFM 的边界检查可以分为两类。

空间安全

空间安全保证访问不会超出 segment 描述的范围:

o+lsegment.byteSize()o + l \leq \text{segment.byteSize()}

例如:

try (Arena arena = Arena.ofConfined()) {
    MemorySegment segment = arena.allocate(8);

    segment.set(ValueLayout.JAVA_LONG, 0, 42L); // 合法,访问 [0, 8)
    segment.set(ValueLayout.JAVA_INT, 6, 1);    // 非法,访问 [6, 10)
}

第二次写入虽然地址计算本身是整数运算,但对应区域超过了 segment 的边界,因此会失败。

时间安全

时间安全保证 segment 所属的 Arena 尚未关闭:

MemorySegment segment;

try (Arena arena = Arena.ofConfined()) {
    segment = arena.allocate(8);
}

segment.get(ValueLayout.JAVA_LONG, 0); // Arena 已关闭,访问失败

这与传统 JNI 或手工 malloc/free 的区别很重要。传统裸指针在释放后通常仍然只是一个数值,错误访问可能直接造成进程崩溃或数据破坏;FFM 会保留生命周期关联并在 Java 访问路径上进行检查。

不过,这种保护主要覆盖 Java 侧的 segment 操作。若本地函数保存了一个指针,并在 Arena 关闭后异步使用它,Java 无法替本地代码恢复安全性。

3.3 只读 segment 与本地调用

MemorySegment 可以具有只读限制。只读 segment 可以被本地函数读取,但不能被 Java 或本地函数安全地当作可写内存使用。

因此,调用类似 const char* 的本地函数时,可以传递只读字符串缓冲区;调用需要填充输出缓冲区的函数时,则必须传递可写 segment。

需要注意,C 语言的 const 本身并不总能在二进制 ABI 中体现。FFM 的 ADDRESS 参数只描述“这是一个地址”,不会自动验证本地函数是否真的只读。只读约束更多是 Java 侧的访问约束,不能替代正确的 C 函数声明和实现。


四、MemoryLayout:解释内存中的数据

本地 API 不使用 Java 的对象布局。一个 C int、指针、结构体或数组,都必须被描述为对应的内存布局。

常用的值布局包括:

ValueLayout.JAVA_BYTE
ValueLayout.JAVA_SHORT
ValueLayout.JAVA_INT
ValueLayout.JAVA_LONG
ValueLayout.JAVA_FLOAT
ValueLayout.JAVA_DOUBLE
ValueLayout.ADDRESS

例如,一个包含两个字段的结构体可以表示为:

MemoryLayout pointLayout = MemoryLayout.structLayout(
    ValueLayout.JAVA_INT.withName("x"),
    ValueLayout.JAVA_INT.withName("y")
);

这个布局表达的是:

struct Point {
    int x;
    int y;
};

对于这个简单结构体,两个 int 连续排列通常得到 8 字节布局。但涉及 C 结构体时不能只看字段大小,还必须考虑:

  • 字段对齐;
  • 编译器插入的 padding;
  • 结构体整体对齐;
  • 平台 ABI;
  • long、指针和 size_t 的平台宽度。

例如:

struct Example {
    char  tag;
    int   value;
};

不能简单认为它只占 1 + 4 = 5 字节。为了满足 int 的对齐要求,C 编译器通常会在 tag 后面插入 padding,结构体大小也可能向整体对齐边界补齐。

4.1 字节序

ValueLayout 还可以携带字节序信息。网络协议或文件格式通常要求明确使用大端或小端,而不是依赖当前机器:

ValueLayout.OfInt littleEndianInt =
    ValueLayout.JAVA_INT.withOrder(ByteOrder.LITTLE_ENDIAN);

本地 ABI 数据通常使用平台本地字节序,因此不应把网络协议的布局直接当成本地结构体布局使用。

4.2 通过布局访问字段

对于命名结构体字段,可以使用路径元素取得字段访问句柄。概念上,访问 pointLayout 中的 "x" 字段需要:

  1. 从结构体布局中定位名为 x 的成员;
  2. 根据字段的布局和偏移量生成访问句柄;
  3. 使用 MemorySegment 和元素索引访问该字段。

这种方式比手工写固定偏移更可靠,因为字段偏移由布局计算。但布局本身仍必须与本地编译器实际生成的 ABI 布局一致。


五、从本地符号到 Java MethodHandle

本地调用不是直接把一个 Java 方法名映射到 C 函数。它需要经过三个步骤:

5.1 查找符号地址

SymbolLookup 用于查找本地符号。标准 C 库中的函数可以通过本地 linker 的默认查找器寻找:

Linker linker = Linker.nativeLinker();

MemorySegment strlenAddress = linker.defaultLookup()
    .find("strlen")
    .orElseThrow(() -> new UnsatisfiedLinkError("strlen not found"));

这里的 strlenAddress 是一个表示函数地址的 MemorySegment

如果符号来自自定义动态库,也可以使用 SymbolLookup.libraryLookup 打开库文件。此时动态库本身的生命周期也需要管理,通常应把它绑定到一个显式 Arena,而不是让它在调用仍可能发生时被关闭。

5.2 描述函数签名

C 函数:

size_t strlen(const char *s);

在 64 位常见平台上,可以近似描述为:

FunctionDescriptor descriptor =
    FunctionDescriptor.of(
        ValueLayout.JAVA_LONG, // 64 位平台上的 size_t
        ValueLayout.ADDRESS    // const char*
    );

FunctionDescriptor 中的第一个布局是返回值,后面的布局是参数。

但这里有一个平台边界:size_t 并不是 Java 固定宽度类型。32 位平台上的 size_t 可能是 32 位,因此不能无条件把所有 C 的 size_t 都写成 JAVA_LONG。生产代码应按照目标平台 ABI 选择布局,或使用 linker 提供的 canonical C layouts。

5.3 创建 downcall handle

downcall 表示 Java 调用本地函数:

MethodHandle strlen = linker.downcallHandle(
    strlenAddress,
    descriptor
);

返回值是 MethodHandle。它的 Java 侧调用类型由 FunctionDescriptor 决定:

MemorySegment -> long

如果实际调用时的 Java 静态类型与 MethodHandle 类型不完全一致,使用 invokeExact 会失败。因此必须留意返回值和参数的精确类型。


六、端到端示例:调用 C 标准库的 strlen

下面的程序调用本地 C 标准库中的 strlen,将 Java 字符串编码为以零字节结尾的 UTF-8 字符串,再把地址传给 C 函数。

import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;

import static java.nio.charset.StandardCharsets.UTF_8;

public class FfmStrlen {
    public static void main(String[] args) throws Throwable {
        Linker linker = Linker.nativeLinker();

        MemorySegment strlenAddress = linker.defaultLookup()
                .find("strlen")
                .orElseThrow(() ->
                        new UnsatisfiedLinkError("strlen was not found"));

        MethodHandle strlen = linker.downcallHandle(
                strlenAddress,
                FunctionDescriptor.of(
                        ValueLayout.JAVA_LONG,
                        ValueLayout.ADDRESS
                )
        );

        String text = "Foreign Memory";

        try (Arena arena = Arena.ofConfined()) {
            MemorySegment cString = arena.allocateFrom(text, UTF_8);

            long length = (long) strlen.invokeExact(cString);

            System.out.println("text   = " + text);
            System.out.println("length = " + length);
        }
    }
}

在支持 Java 25 的 64 位操作系统上,可以这样编译和运行:

javac --release 25 FfmStrlen.java
java --enable-native-access=ALL-UNNAMED FfmStrlen

预期输出:

text   = Foreign Memory
length = 14

6.1 每一步为什么成立

第一步:取得本地 linker

Linker linker = Linker.nativeLinker();

本地 linker 知道当前平台的调用约定,例如:

  • 参数如何放入寄存器或栈;
  • 返回值如何传递;
  • 指针大小;
  • 对齐规则;
  • 本地函数调用所需的 ABI 细节。

这也是为什么不能把某个平台生成的函数地址和另一套调用约定随意混用。

第二步:查找 strlen

linker.defaultLookup().find("strlen")

默认查找器用于定位当前进程或标准本地库中可见的符号。找不到符号时,Optional 为空,示例将其转换为 UnsatisfiedLinkError

找不到符号不一定说明函数不存在,也可能是:

  • 动态库没有加载;
  • 符号名经过修饰;
  • 当前平台的标准库导出方式不同;
  • 使用了错误的库版本;
  • 目标函数不是公开符号。

第三步:创建以零字节结尾的字符串

MemorySegment cString = arena.allocateFrom(text, UTF_8);

C 的 strlen 不接收 Java 字符串,也不知道 Java 的长度字段。它从传入地址开始逐字节读取,直到遇到 '\0'

因此,传给 strlen 的内存必须满足:

UTF-8 字节序列 + 一个 0 字节

Arena.allocateFrom(String, Charset) 为字符串建立本地内存表示,并包含适合 C 字符串使用的终止零字节。

UTF-8 字节长度与 Java String.length() 不一定相同。例如:

"é".length()       // Java code unit 数量为 1
"é".getBytes(UTF_8).length // UTF-8 字节数量为 2

strlen 返回的是字节数,不是 Java 字符数量。

第四步:执行本地函数

long length = (long) strlen.invokeExact(cString);

ADDRESS 参数在 Java 侧使用 MemorySegment 表示。因此这里传入的是 cString,而不是从 segment 中提取一个整数地址。

调用发生时,cString 仍然处于 try 作用域内,所属 Arena 尚未关闭。这样本地函数读取的地址仍然有效。

第五步:关闭 Arena

}

strlen 已经同步返回,因此本地函数不会再使用 cString。此时关闭 Arena 是安全的。

如果本地 API 会异步保存这个指针,例如:

void start_async_operation(const char *buffer);

那么下面的写法就是错误的:

try (Arena arena = Arena.ofConfined()) {
    MemorySegment buffer = arena.allocateFrom("data");
    startAsyncOperation.invokeExact(buffer);
} // 异步操作可能仍在使用 buffer

关闭 Arena 后,本地线程仍可能通过已经保存的裸地址访问失效内存。FFM 无法追踪本地代码是否偷偷保存了指针,因此异步 API 必须显式设计所有权和完成时机。


七、ADDRESS、指针和本地内存的关系

在 FFM 中,C 指针通常使用 ValueLayout.ADDRESS 表示。

例如:

int read_value(const int *p);

可以描述为:

FunctionDescriptor.of(
    ValueLayout.JAVA_INT,
    ValueLayout.ADDRESS
);

Java 调用时传入一个 MemorySegment

try (Arena arena = Arena.ofConfined()) {
    MemorySegment value = arena.allocate(ValueLayout.JAVA_INT);
    value.set(ValueLayout.JAVA_INT, 0, 42);

    int result = (int) readValue.invokeExact(value);
}

这里有两个不同层次:

MemorySegment 对象
    │
    └── 描述一段地址范围

ADDRESS 参数
    │
    └── 本地 ABI 中的指针值

Linker 负责把前者转换为后者。

7.1 空指针

C API 中的可选指针有时允许 NULL。这时应使用 FFM 表示空地址的 segment,而不是随意传入一个普通的零长度 segment。

但是否允许空指针是本地函数契约的一部分。例如:

void free_buffer(void *p);

可能允许 NULL;而:

size_t strlen(const char *s);

要求 s 指向合法的零终止字符串,传入空地址会导致本地代码出错。

FFM 可以表达空地址,但不会自动知道某个 C 函数是否允许它。

7.2 返回指针的函数

如果本地函数返回 ADDRESS,Java 侧得到的是一个表示该地址的 MemorySegment。此时必须确认:

  • 返回地址由谁分配;
  • 由谁释放;
  • 返回的内存是否仍然有效;
  • 本地库是否要求调用专用释放函数;
  • 该地址是否可以跨线程或跨请求保存。

不能因为返回值是 MemorySegment,就认为它一定由某个 Java Arena 自动管理。外部库自己分配的内存,仍然可能要求调用例如 free_buffer 之类的本地释放函数。


八、Downcall:Java 调用本地函数

downcall 的数据流如下:

sequenceDiagram
    participant J as Java 代码
    participant A as Arena
    participant L as Linker
    participant C as 本地函数

    J->>A: 分配输入/输出 segment
    J->>L: 符号地址 + FunctionDescriptor
    L-->>J: MethodHandle
    J->>C: invokeExact(segment, ...)
    C-->>J: 返回值或写入输出 segment
    J->>A: 调用完成后 close()

创建 MethodHandle 时,FFM 会根据函数描述生成适合当前平台 ABI 的调用适配器。这个适配器负责:

  • 将 Java 基本类型转换为本地标量;
  • MemorySegment 转换为指针参数;
  • 准备返回值;
  • 按照本地 ABI 完成寄存器和栈布局。

8.1 参数类型必须匹配

假设函数描述是:

FunctionDescriptor.of(
    ValueLayout.JAVA_INT,
    ValueLayout.JAVA_INT
)

那么对应的 MethodHandle 逻辑类型是:

(int) -> int

下面的代码使用 long 参数就不匹配:

long value = 1L;
// handle.invokeExact(value); // WrongMethodTypeException

invokeExact 不会进行普通 Java 方法调用那样的宽化或重载推断。需要转换时,应在建立句柄时使用正确的布局,或者使用显式的 MethodHandle 适配,而不是依赖猜测。

8.2 整数、指针和布尔值不能混为一谈

以下 C 类型虽然在某些平台上可能具有相同大小,但语义和 ABI 不一定相同:

int
long
size_t
void *
_Bool

不能因为它们都可能占 4 或 8 字节,就随意用同一个 ValueLayout

尤其需要关注:

  • C long 在 LP64 平台通常为 64 位,在 Windows 64 位平台通常仍为 32 位;
  • size_t 是无符号、平台相关宽度;
  • 指针应使用 ValueLayout.ADDRESS,而不是 JAVA_LONG
  • C 布尔类型不应简单假设等于 Java boolean 的内存表示。

九、Upcall:本地代码回调 Java

与 downcall 相反,upcall 是把 Java 方法包装成一个可以传给本地代码的函数指针。

假设本地 API 接受一个比较器:

typedef int (*comparator)(const void *, const void *);
void sort(void *base, size_t count, size_t size, comparator cmp);

Java 侧需要:

  1. 准备一个符合签名的 Java MethodHandle
  2. 使用 Linker.upcallStub 生成函数指针;
  3. 将返回的 MemorySegment 传给本地函数;
  4. 确保 stub 的 Arena 在本地代码不再回调前一直存活。

示意代码如下:

MethodHandle callback = MethodHandles.lookup().findStatic(
        Callbacks.class,
        "compare",
        MethodType.methodType(
                int.class,
                MemorySegment.class,
                MemorySegment.class
        )
);

FunctionDescriptor callbackDescriptor =
        FunctionDescriptor.of(
                ValueLayout.JAVA_INT,
                ValueLayout.ADDRESS,
                ValueLayout.ADDRESS
        );

try (Arena arena = Arena.ofShared()) {
    MemorySegment callbackStub = linker.upcallStub(
            callback,
            callbackDescriptor,
            arena
    );

    // 将 callbackStub 作为 ADDRESS 参数传给本地函数
}

对应的 Java 方法可以是:

public static int compare(
        MemorySegment left,
        MemorySegment right
) {
    return 0;
}

这里只展示了函数指针的建立。真正实现比较器时,还必须知道 leftright 指向的对象布局,不能直接把它们当作 Java 对象。

9.1 Upcall 的生命周期

upcallStub 返回的 segment 不是普通数据缓冲区,而是一个本地可调用的函数指针。它的有效期受创建它的 Arena 控制:

Arena 存活
   └── upcall stub 有效
         └── 本地代码可以回调 Java

Arena 关闭
   └── stub 失效
         └── 本地代码再次回调可能崩溃

因此,如果本地库把回调保存起来并在未来调用,不能把 stub 放入短生命周期的局部 Arena。异步回调通常还涉及线程问题:回调可能发生在本地创建的线程上,而不是创建 Arena 的 Java 线程上。若回调需要访问共享 segment,应考虑 Arena.ofShared() 和适当的同步。


十、内存分配器与批量资源管理

Arena 同时实现 SegmentAllocator,可以按大小、布局或字符串分配内存:

try (Arena arena = Arena.ofConfined()) {
    MemorySegment bytes = arena.allocate(128);
    MemorySegment integer = arena.allocate(ValueLayout.JAVA_INT);
    MemorySegment text = arena.allocateFrom("hello");
}

批量分配的价值在于,多个短生命周期对象可以绑定到一个 Arena:

请求开始
  └── 创建 Arena
        ├── 分配输入缓冲区
        ├── 分配输出缓冲区
        ├── 分配临时结构体
        └── 调用多个本地函数
请求结束
  └── 一次 close() 释放全部本地内存

这并不意味着每个资源都可以无条件共用一个 Arena。若某个本地库会长期持有一块内存,就必须把它绑定到更长生命周期的 Arena,并在本地库确认不再使用后关闭。


十一、与 JNI、ByteBuffer 和 Unsafe 的边界

11.1 与 JNI 的区别

JNI 通常要求:

  • 编写 C/C++ glue code;
  • 定义 native 方法;
  • 处理 JavaVM、JNIEnv 和引用;
  • 维护头文件或生成绑定代码;
  • 处理本地异常与资源释放。

FFM 将函数签名、内存布局和生命周期表示为 Java API 对象,因此简单函数调用不需要额外的 JNI 桥接层。

但 FFM 不会替代所有 JNI 能力。复杂的 Java 对象操作、特殊 JVM 集成或已有 JNI 生态仍可能继续使用 JNI。

11.2 与 ByteBuffer 的区别

ByteBuffer.allocateDirect 也能获得堆外缓冲区,但它更适合字节流式访问。FFM 的 MemorySegment 额外提供:

  • 明确的生命周期作用域;
  • 更系统的布局描述;
  • 与本地函数签名的直接集成;
  • 结构体、数组和指针的布局表达;
  • confined/shared 的线程访问模型。

如果只是向 Java NIO API 提供一块临时直接缓冲区,ByteBuffer 仍然可能更自然;如果需要调用 C API 或表达本地结构体,MemorySegment 更直接。

11.3 与 Unsafe 的区别

Unsafe 可以通过裸地址进行读写,但它不会自动绑定:

  • 地址与生命周期;
  • 地址范围;
  • 所有者线程;
  • 函数 ABI;
  • 本地符号和布局。

FFM 不是绝对安全的沙箱,因为本地函数本身仍可能破坏进程;但它将许多原本隐含的约束显式化了。


十二、失败表现与诊断路径

12.1 符号找不到

表现通常是:

UnsatisfiedLinkError

诊断顺序应是:

  1. 确认目标动态库已经加载;
  2. 确认平台和架构一致;
  3. 确认导出符号名称;
  4. 确认 C++ 函数使用了 extern "C",避免名称修饰;
  5. 确认使用了正确的 SymbolLookup

例如 C++ 函数:

int add(int a, int b);

如果没有 extern "C",导出的符号可能不是简单的 add

12.2 函数描述不匹配

表现可能包括:

  • WrongMethodTypeException
  • IllegalArgumentException
  • 本地函数返回错误值;
  • 进程直接崩溃。

前两类通常发生在 Java 侧类型或布局不匹配时。后两类尤其危险,因为错误的函数描述可能已经让 ABI 读取了错误的寄存器或栈位置。

典型错误包括:

long function(int value);

却在 Java 中按:

FunctionDescriptor.of(
    ValueLayout.JAVA_LONG,
    ValueLayout.JAVA_LONG
)

调用。即使某些平台上整数都能放进寄存器,这也不代表调用约定和参数语义正确。

12.3 访问已关闭或越界的 segment

表现通常是 Java 侧异常,例如:

  • IllegalStateException:segment 生命周期已结束;
  • WrongThreadException:违反 confined Arena 的线程约束;
  • IndexOutOfBoundsException 或相关边界异常:访问超出范围。

诊断时应记录:

  • segment 由哪个 Arena 创建;
  • Arena 在哪里关闭;
  • segment 是否跨线程传递;
  • 访问偏移量和访问布局的大小;
  • 本地函数是否异步保存了地址。

12.4 本地进程崩溃

如果 Java 进程出现 SIGSEGVSIGBUS 或 Windows 访问冲突,常见原因包括:

  • 传入错误的函数地址;
  • FunctionDescriptor 与真实签名不匹配;
  • 传入已释放的地址;
  • C 字符串缺少终止零字节;
  • 输出缓冲区容量不足;
  • 结构体布局错误;
  • 本地代码异步使用了已关闭 Arena 的内存;
  • upcall stub 已失效;
  • 本地函数内部本身存在错误。

FFM 的检查不能覆盖本地代码从裸地址进行的所有访问。例如,Java segment 可能有 16 字节范围,但 C 函数拿到指针后可以继续读取第 17 个字节;如果该访问发生在本地代码内部,Java 不会自动为它插入边界检查。


十三、受限方法与 native access

FFM 涉及加载本地库、取得本地函数地址和建立本地调用桥接,这些能力可能触发 Java 的 native access 限制。

运行未命名模块中的示例时,可以显式启用:

java --enable-native-access=ALL-UNNAMED FfmStrlen

在命名模块中,应将 ALL-UNNAMED 换成实际模块名:

java --enable-native-access=com.example.ffm ...

这不是普通业务代码的无害配置。启用 native access 表示该模块被允许执行与本地内存和本地代码有关的高风险操作。生产部署时应只对确实需要 FFM 的模块开放,而不是无条件对所有模块开放。

Java 25 中 FFM 已是标准 API,不需要 --enable-preview。但“已经标准化”不等于“本地代码自动安全”:生命周期、ABI、并发和本地库契约仍需由应用负责。


十四、生产边界:同步、异步与所有权

FFM 代码是否正确,通常取决于一个问题:

本地代码会在什么时候、由哪个线程、以什么方式继续使用这个地址?

同步本地函数

如果函数只在调用期间使用传入指针,调用完成后不再保存:

创建 Arena
  └── 分配 segment
        └── downcall
              └── 返回
                    └── close Arena

这种模式最简单,也最适合 ofConfined()

异步本地函数

如果函数会保存指针或稍后回调:

创建 Arena
  └── 分配 segment
        └── 启动异步本地操作
              └── Java 方法返回
                    └── 本地线程仍使用 segment

此时不能在 Java 方法返回时关闭 Arena。必须等本地库报告操作结束后再关闭,并处理:

  • 多线程访问;
  • 回调失败;
  • 超时;
  • 本地库未按约定完成;
  • Java 侧异常导致清理路径提前执行。

这也是 Arena.ofShared() 可能必要的地方,但 shared 只解决访问资格,不解决异步所有权协议。


十五、核心取舍

FFM 的抽象可以用一句话概括:

MemorySegment 表示“受生命周期和范围约束的地址”,用 MemoryLayout 表示“内存中的数据”,用 FunctionDescriptor 表示“本地调用的 ABI”,再由 Linker 将它们组合为 MethodHandle

因此,一次正确的本地调用至少需要同时满足四个条件:

  1. 符号正确:找到的地址确实对应目标函数;
  2. 布局正确:参数、返回值、结构体、指针和字节序与本地 ABI 一致;
  3. 生命周期正确:调用期间所有地址和 upcall stub 都保持有效;
  4. 并发正确:跨线程访问使用合适的 Arena 和同步机制。

缺少任何一个条件,都可能从 Java 异常升级为本地崩溃或数据损坏。Java 25 的 FFM API 提供了比 JNI 和裸 Unsafe 更清晰的边界,但它要求调用者明确承担本地接口契约,而不是把 C 的内存和 ABI 规则隐藏在一层不透明的桥接代码后面。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。