FFM:用 Arena 和 MemorySegment 取代 JNI
学完你要能自己回答
- FFM 相比 JNI 和 sun.misc.Unsafe 分别解决了什么问题?
- Arena.ofConfined / ofShared / ofAuto / global 的寿命与线程约束分别是什么?
- downcall 与 upcall 的方向分别是什么?FunctionDescriptor 如何描述 C 函数?
- MemorySegment 的边界检查能防住什么,又防不住什么?
- JDK 21 与 JDK 22 的 FFM API 有哪些命名差异?
为什么还需要一套新的本地调用 API01
JNI 能调本地库,但需要写 C 头文件、生成胶水代码、处理引用和异常,构建链复杂;sun.misc.Unsafe 能直接读写内存,却没有边界检查和生命周期管理,一次越界就是 JVM 崩溃。FFM 试图把「调本地代码」变成 Java 语言内可描述、可检查的操作。
| 维度 | JNI | sun.misc.Unsafe | FFM |
|---|---|---|---|
| 胶水代码 | 需要 C 侧胶水 | 不需要 | 不需要 |
| 内存边界 | 自行保证 | 无检查 | MemorySegment 边界检查 |
| 生命周期 | 手动管理引用 | 手动 free | Arena 作用域管理 |
| 线程约束 | 自行保证 | 自行保证 | Confined / Shared 显式建模 |
| 调用开销 | 较高(JNI 边界) | — | 接近 JNI,通常优于反射 |
| 标准状态 | 长期标准 | 内部 API,不保证 | JDK 22 正式 |
FFM 不是「让 Java 变成 C」。它仍然需要你知道 C 函数的签名、平台相关的类型宽度和内存布局;它只是把最容易出错的边界与寿命交给了运行时检查。
五个对象:Arena、Segment、Layout、Linker、Lookup02
| 对象 | 角色 | 典型 API |
|---|---|---|
Arena | 内存分配器 + 生命周期 | ofConfined / ofShared / ofAuto / global / close |
MemorySegment | 带边界的本地内存视图 | allocate / get / set / asSlice / byteSize |
MemoryLayout | 描述 C 结构体 / 数组的布局 | structLayout / sequenceLayout / ValueLayout |
Linker | 把 Java 方法句柄接到本地函数 | nativeLinker / downcallHandle / upcallStub |
SymbolLookup | 按名字查找本地符号 | defaultLookup / libraryLookup |
downcall 是 Java → native:用 FunctionDescriptor 描述参数和返回值,拿到 MethodHandle,再用 invokeExact 调用。upcall 是 native → Java:把 Java 方法句柄包装成函数指针,交给本地代码回调。
// downcall 的典型形状
MethodHandle strlen = linker.downcallHandle(
lookup.find("strlen").orElseThrow(),
FunctionDescriptor.of(JAVA_LONG, ADDRESS));
// upcall 的典型形状
MemorySegment stub = linker.upcallStub(
javaMethodHandle,
FunctionDescriptor.of(JAVA_INT, JAVA_INT, JAVA_INT),
arena);
FunctionDescriptor 必须和 C 签名一致。C 的 int、long、size_t 在不同平台上宽度不同;跨平台代码应使用 ValueLayout 中对应的 C 类型,而不是想当然地用 JAVA_LONG。
三层安全:边界、寿命、线程03
- 空间安全:每次
get/set都带布局和偏移,越界会抛IndexOutOfBoundsException,而不是读写到相邻内存。 - 时间安全:Segment 归属于某个 Arena;Arena 关闭后继续访问会抛
IllegalStateException: Already closed。 - 线程安全:
ofConfined的 Segment 只能由创建它的线程访问,其他线程访问会抛WrongThreadException;需要跨线程就用ofShared。
// 本机 JDK 21 验证
confined other thread -> java.lang.WrongThreadException:
Attempted access outside owning thread
closed access -> java.lang.IllegalStateException: Already closed
它防不住的事情同样重要:本地代码内部越界、写坏指针、释放后继续使用、错误的 C 类型宽度、reinterpret 强行扩大边界。FFM 把 Java 侧的访问变得可检查,但无法验证 native 库内部的行为。
MemorySegment.reinterpret 是逃生舱:它会扩大或改变 Segment 的可见范围,绕过边界检查。除非 native API 明确返回「指针 + 长度」,否则不要使用。Arena:把释放责任变成作用域01
| Arena | 生命周期 | 线程约束 | 适用场景 |
|---|---|---|---|
ofConfined() | 手动 close() | 只能由创建线程访问 | 单线程短生命周期调用 |
ofShared() | 手动 close() | 可被多个线程访问 | 跨线程共享的本地内存 |
ofAuto() | GC 回收,不能 close | 无 | 想少管释放、可接受非确定性回收 |
global() | 永不释放 | 无 | 进程级长期常量,谨慎使用 |
绝大多数调用应该用 try (Arena arena = Arena.ofConfined()) { ... }。Arena 关闭时统一释放其中分配的 Segment;你不需要、也不能单独 free 某个 Segment。
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = arena.allocate(16);
segment.set(JAVA_INT, 0, 42);
System.out.println(segment.get(JAVA_INT, 0)); // 42
}
// 离开 try 后 arena 已关闭,segment 不能再用
ofConfined 的「confined」不是建议,而是强制:在另一个线程访问它的 Segment 会抛 WrongThreadException。这能尽早暴露「把 Segment 逃逸到线程池」的错误。
ofConfined 分配的 Segment 存进缓存或返回给调用方;它的寿命不会超过 Arena。需要跨线程或跨方法长期使用,应使用 ofShared 或明确的生命周期管理。MemorySegment:带边界的地址02
MemorySegment 由「地址 + 字节长度 + 所属 scope」组成。所有读写都通过 ValueLayout 指定类型和偏移,运行时检查偏移是否在范围内。
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = arena.allocate(2 * Integer.BYTES);
segment.set(JAVA_INT, 0, 7);
segment.set(JAVA_INT, Integer.BYTES, 9);
System.out.println(segment.get(JAVA_INT, 0)); // 7
System.out.println(segment.get(JAVA_INT, Integer.BYTES)); // 9
System.out.println(segment.byteSize()); // 8
MemorySegment second = segment.asSlice(Integer.BYTES, Integer.BYTES);
System.out.println(second.get(JAVA_INT, 0)); // 9
}
JDK 21 用 allocateUtf8String / getUtf8String 处理 C 字符串;JDK 22 转正后改名为 allocateFrom / getString。这两个名字在 21 与 22+ 之间不能混用。
| 操作 | JDK 21 预览 | JDK 22+ 正式 |
|---|---|---|
| 分配 UTF-8 字符串 | arena.allocateUtf8String(s) | arena.allocateFrom(s) |
| 读取 UTF-8 字符串 | segment.getUtf8String(0) | segment.getString(0) |
| 分配结构体 | arena.allocate(layout) | arena.allocate(layout) |
| 堆内数组视图 | MemorySegment.ofArray(array) | MemorySegment.ofArray(array) |
byteSize 是字节数,不是元素个数。UTF-8 字符串的字节数还包含结尾的 NUL,所以 "Hello, JDK 21!"(14 字节)分配出来是 15 字节。downcall:从 Java 调到 C03
以 C 标准库的 strlen(const char*) 为例:参数是指针(ADDRESS),返回值是 size_t。在 64 位平台上用 JAVA_LONG 接住返回值。
Linker linker = Linker.nativeLinker();
SymbolLookup lookup = linker.defaultLookup();
MethodHandle strlen = linker.downcallHandle(
lookup.find("strlen").orElseThrow(),
FunctionDescriptor.of(JAVA_LONG, ADDRESS));
try (Arena arena = Arena.ofConfined()) {
MemorySegment text = arena.allocateUtf8String("Hello, JDK 21!");
long length = (long) strlen.invokeExact(text);
System.out.println("strlen=" + length); // 14
System.out.println("size=" + text.byteSize()); // 15
}
javac --enable-preview --release 21 FfmStrlenDemo.java
java --enable-preview --enable-native-access=ALL-UNNAMED FfmStrlenDemo
strlen=14 size=15
lookup.find("strlen")在默认查找范围内找符号;找不到时返回空 Optional。FunctionDescriptor.of(returnLayout, paramLayouts...)的顺序必须与 C 签名一致。invokeExact的类型必须与方法句柄完全匹配,否则抛WrongMethodTypeException。- 返回值是
size_t;在 64 位平台用JAVA_LONG,跨平台应使用与平台宽度对应的布局。
--enable-native-access=ALL-UNNAMED 也能运行,但 JDK 21 会打印 restricted method 警告。生产启动参数应显式声明 native access,而不是依赖警告被忽略。upcall:从 C 调回 Java04
upcall 把 Java 方法句柄变成 native 可调用的函数指针。下面的例子不依赖外部库:先创建 upcall stub,再把 stub 当作函数指针,通过 downcall 调回 Java,验证参数和返回值都正确。
Linker linker = Linker.nativeLinker();
MethodHandle add = MethodHandles.lookup().findStatic(
FfmUpcallDemo.class, "add",
MethodType.methodType(int.class, int.class, int.class));
FunctionDescriptor descriptor =
FunctionDescriptor.of(JAVA_INT, JAVA_INT, JAVA_INT);
try (Arena arena = Arena.ofConfined()) {
MemorySegment stub = linker.upcallStub(add, descriptor, arena);
MethodHandle caller = linker.downcallHandle(stub, descriptor);
int result = (int) caller.invokeExact(20, 22);
System.out.println("upcall result=" + result); // 42
}
upcall result=42
- upcall stub 的生命周期由传入的 Arena 管理;Arena 关闭后再调用 stub 是未定义行为。
- 回调方法的参数/返回值必须与 FunctionDescriptor 匹配;C 侧不会做 Java 类型检查。
- 不要让异常越过 upcall 边界:在 Java 回调内部捕获并转换为错误码或返回值。
- upcall 适合事件回调、比较器、日志钩子等场景;高频回调要评估线程切换与异常处理成本。
可运行示例:调用 strlen01
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import static java.lang.foreign.ValueLayout.*;
public class FfmStrlenDemo {
public static void main(String[] args) throws Throwable {
Linker linker = Linker.nativeLinker();
SymbolLookup lookup = linker.defaultLookup();
MethodHandle strlen = linker.downcallHandle(
lookup.find("strlen").orElseThrow(),
FunctionDescriptor.of(JAVA_LONG, ADDRESS));
try (Arena arena = Arena.ofConfined()) {
MemorySegment text = arena.allocateUtf8String("Hello, JDK 21!");
long length = (long) strlen.invokeExact(text);
System.out.println("strlen=" + length);
System.out.println("size=" + text.byteSize());
}
}
}
javac --enable-preview --release 21 FfmStrlenDemo.java
java --enable-preview --enable-native-access=ALL-UNNAMED FfmStrlenDemo
strlen=14 size=15
defaultLookup() 在 Windows 与 Linux/macOS 上都能找到 C 运行时的 strlen;如果换成第三方库,需要用 SymbolLookup.libraryLookup("mylib", arena) 加载动态库。
可运行示例:upcall stub02
import java.lang.foreign.*;
import java.lang.invoke.*;
import static java.lang.foreign.ValueLayout.*;
public class FfmUpcallDemo {
static int add(int a, int b) {
return a + b;
}
public static void main(String[] args) throws Throwable {
Linker linker = Linker.nativeLinker();
MethodHandle add = MethodHandles.lookup().findStatic(
FfmUpcallDemo.class, "add",
MethodType.methodType(int.class, int.class, int.class));
FunctionDescriptor descriptor =
FunctionDescriptor.of(JAVA_INT, JAVA_INT, JAVA_INT);
try (Arena arena = Arena.ofConfined()) {
MemorySegment stub = linker.upcallStub(add, descriptor, arena);
MethodHandle caller = linker.downcallHandle(stub, descriptor);
int result = (int) caller.invokeExact(20, 22);
System.out.println("upcall result=" + result);
}
}
}
upcall result=42
这个例子的价值在于把 upcall 的整条链路都放在 Java 里,不需要编译 C 库。真实场景中,stub 会作为回调参数传给 qsort、事件循环或第三方库。
版本迁移与选型03
| 事项 | JDK 21 | JDK 22+ |
|---|---|---|
| 状态 | 第三轮预览(JEP 442) | 正式(JEP 454) |
| 编译 | --enable-preview --release 21 | --release 22 |
| 运行 | --enable-preview | 不需要 preview |
| native access | --enable-native-access=ALL-UNNAMED | 同样建议显式声明 |
| 字符串分配 | allocateUtf8String | allocateFrom |
| 字符串读取 | getUtf8String | getString |
# JDK 21
javac --enable-preview --release 21 FfmStrlenDemo.java
java --enable-preview --enable-native-access=ALL-UNNAMED FfmStrlenDemo
# JDK 22+(API 改名后)
javac --release 22 FfmStrlenDemo.java
java --enable-native-access=ALL-UNNAMED FfmStrlenDemo
什么时候值得用 FFM
- 已有 C/C++ 库,且不想再维护一层 JNI 胶水代码。
- 需要大块堆外内存、内存映射或与 native 库共享缓冲区。
- 需要回调 native 库(upcall),例如排序比较器、事件监听。
- 对启动/调用开销有要求,且标准库没有合适抽象。
什么时候不该用
- 普通文件、网络、序列化:优先
Files、HttpClient、成熟库。 - 只想「绕过 GC」:先确认堆外内存的分配、释放和排查成本是否值得。
- 跨平台库的 C 类型宽度没有核对:错误布局比性能问题更常见。
- 团队没有 native 调试能力:崩溃栈通常不会像 Java 异常那样友好。
常见坑04
- Segment 逃逸出 Arena:把
ofConfined的 Segment 返回或缓存,Arena 关闭后访问会Already closed。 - 跨线程使用 confined Segment:抛
WrongThreadException;跨线程用ofShared。 - 忘记 native access 参数:FFM 能运行但会警告;生产启动脚本应显式声明。
- C 类型宽度假设错误:Windows 的 C
long是 32 位,不要一律用JAVA_LONG。 reinterpret扩大边界:绕过检查,可能直接读坏内存。- upcall 中抛异常:异常不能安全跨越 native 边界,回调内部要自行处理。
- 把普通 IO 迁到 FFM:复杂度上升,收益通常为负。
IllegalStateException: Already closed(寿命)、WrongThreadException(线程)、IndexOutOfBoundsException(边界)。先按这三类定位,再怀疑 native 库本身。