外部函数和内存 API(Foreign Function & Memory API,简称 FFM API) 是 Java 17 中首次引入的一个重要特性,经过了 JEP 412 和 JEP 419 两个孵化版本,以及 JEP 424 和 JEP 434 两个预览版本,在 Java 21 中,这已经是第三个预览版本了。
在 Java 22 中,这个特性终于退出了预览版本。
近年来,随着人工智能、数据科学、图像处理等领域的发展,我们在越来越多的场景下接触到原生代码:
- Off-CPU Computing (CUDA, OpenCL)
- Deep Learning (Blas, cuBlas, cuDNN, Tensorflow)
- Graphics Processing (OpenGL, Vulkan, DirectX)
- Others (CRIU, fuse, io_uring, OpenSSL, V8, ucx, ...)
这些代码不太可能用 Java 重写,也没有必要,Java 急需一种能与本地库进行交互的方案,这就是 FFM API 诞生的背景。FFM API 最初作为 Panama 项目 中的核心组件,旨在改善 Java 与本地代码的互操作性。FFM API 是 Java 现代化进程中的一个重要里程碑,标志着 Java 在与本地代码互操作性方面迈出了重要一步,它的引入也为 Java 在人工智能、数据科学等领域的应用提供了更多的可能性,有望加速 Java 在这些领域的发展和应用。
FFM API 由两大部分组成:外部函数接口(Foreign Function Interface,简称 FFI) 和 内存 API(Memory API),FFI 用于实现 Java 代码和外部代码之间的相互操作,而 Memory API 则用于安全地管理堆外内存。
使用 JNI 调用外部函数
在引入外部函数之前,如果想要实现 Java 调用外部函数库,我们需要借助 JNI (Java Native Interface) 来实现。下面的代码是一个使用 JNI 调用外部函数的例子:
arduino
public class JNIDemo {
static {
System.loadLibrary("JNIDemo");
}
public static void main(String[] args) {
new JNIDemo().sayHello();
}
private native void sayHello();
}
其中 sayHello 函数使用了 native 修饰符,表明这是一个本地方法,该方法的实现不在 Java 代码中。这个本地方法可以使用 C 语言来实现,我们首先需要生成这个本地方法对应的 C 语言头文件:
ruby
$ javac -h . JNIDemo.java
javac 命令不仅可以将 .java 文件编译成 .class 字节码文件,而且还可以生成本地方法的头文件,参数 -h . 表示将头文件生成到当前目录。这个命令执行成功后,当前目录应该会生成 JNIDemo.class 和 JNIDemo.h 两个文件,JNIDemo.h 文件内容如下:
arduino
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class JNIDemo */
#ifndef _Included_JNIDemo
#define _Included_JNIDemo
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: JNIDemo
* Method: sayHello
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_JNIDemo_sayHello
(JNIEnv *, jobject);
#ifdef __cplusplus
}
#endif
#endif
正如我们所看到的,在这个头文件中定义了一个名为 Java_JNIDemo_sayHello 的函数,这个名称是根据包名、类名和方法名自动生成的。有了这个自动生成的头文件,我们就可以在 C 语言里实现这个这个方法了,于是接着创建一个 JNIDemo.c 文件,编写代码:
arduino
#include "jni.h"
#include "JNIDemo.h"
#include <stdio.h>
JNIEXPORT void JNICALL Java_JNIDemo_sayHello(JNIEnv *env, jobject jobj) {
printf("Hello World!\n");
}
这段代码很简单,直接调用标准库中的 printf 输出 Hello World!。
然后使用 gcc 将这个 C 文件编译成动态链接库:
ruby
$ gcc -I${JAVA_HOME}/include -I${JAVA_HOME}/include/darwin -dynamiclib JNIDemo.c -o libJNIDemo.dylib
这个命令会在当前目录下生成一个名为 libJNIDemo.dylib 的动态链接库文件,这个库文件正是我们在 Java 代码中通过 System.loadLibrary("JNIDemo") 加载的库文件。
注意这里我用的是 Mac 操作系统,动态链接库的名称必须以 lib 为前缀,以 .dylib 为扩展名,其他操作系统的命令略有区别。
Linux 系统:
ruby
$ gcc -I${JAVA_HOME}/include -I${JAVA_HOME}/include/linux -shared JNIDemo.c -o libJNIDemo.so
Windows 系统:
ruby
$ gcc -I${JAVA_HOME}/include -I${JAVA_HOME}/include/win32 -shared JNIDemo.c -o JNIDemo.dll
至此,我们就可以运行这个 Hello World 的本地实现了:
ini
$ java -cp . -Djava.library.path=. JNIDemo
以上步骤演示了如何使用 JNI 调用外部函数,这只是 JNI 的一个简单示例,更多 JNI 的高级功能,比如实现带参数的函数,在 C 代码中访问 Java 对象或方法等,可以参考 Baeldung 的这篇教程。
外部函数接口(Foreign Function Interface)
从上面的过程可以看出,JNI 的使用非常繁琐,一个简单的 Hello World 都要费好大劲:首先要在 Java 代码中定义 native 方法,然后从 Java 代码派生 C 头文件,最后还要使用 C 语言对其进行实现。Java 开发人员必须跨多个工具链工作,当本地库快速演变时,这个工作就会变得尤为枯燥乏味。
除此之外,JNI 还有几个更为严重的问题:
- Java 语言最大的特性是跨平台,所谓 一次编译,到处运行,但是使用本地接口需要涉及 C 语言的编译和链接,这是平台相关的,所以丧失了 Java 语言的跨平台特性;
- JNI 桩代码非常难以编写和维护,首先,JNI 在类型处理上很糟糕,由于 Java 和 C 的类型系统不一致,比如聚合数据在 Java 中用对象表示,而在 C 中用结构体表示,因此,任何传递给 native 方法的 Java 对象都必须由本地代码费力地解包;另外,假设某个本地库包含 1000 个函数,那么意味着我们要生成 1000 个对应的 JNI 桩代码,这么大量的 JNI 桩代码非常难以维护;
- 由于本地代码不受 JVM 的安全机制管理,所以 JNI 本质上是不安全的,它在使用上非常危险和脆弱,JNI 错误可能导致 JVM 的崩溃;
- JNI 的性能也不行,一方面是由于 JNI 方法调用不能从 JIT 优化中受益,另一方面是由于通过 JNI 传递 Java 对象很慢;这就导致开发人员更愿意使用 Unsafe API 来分配堆外内存,并将其地址传递给 native 方法,这使得 Java 代码非常不安全!
多年来,已经出现了许多框架来解决 JNI 遗留下来的问题,包括 JNA、JNR 和 JavaCPP。这些框架通常比 JNI 有显著改进,但情况仍然不尽理想,尤其是与提供一流本地互操作性的语言相比。例如,Python 的 ctypes 包可以动态地包装本地库中的函数,而无需任何胶水代码,Rust 则提供了从 C/C++ 头文件自动生成本地包装器的工具。
FFI 综合参考了其他语言的实现,试图更加优雅地解决这些问题,它实现了对外部函数库的原生接口,提供了一种更高效更安全的方式来访问本地内存和函数,从而取代了传统的 JNI。
下面的代码是使用 FFI 实现和上面相同的 Hello World 的例子:
ini
public class FFIDemo {
public static void main(String[] args) throws Throwable {
Linker linker = Linker.nativeLinker();
SymbolLookup symbolLookup = linker.defaultLookup();
MethodHandle printf = linker.downcallHandle(
symbolLookup.find("printf").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)
);
try (Arena arena = Arena.ofConfined()) {
MemorySegment hello = arena.allocateUtf8String("Hello World!\n");
printf.invoke(hello);
}
}
}
注意,Java 22 中取消了
Arena::allocateUtf8String()方法,改成了Arena::allocateFrom()方法。
相比于 JNI 的实现,FFI 的代码要简洁优雅得多。这里的代码涉及三个 FFI 中的重要接口:
LinkerSymbolLookupFunctionDescriptor
其中 SymbolLookup 用于从已加载的本地库中查找外部函数的地址,Linker 用于链接 Java 代码与外部函数,它同时支持下行调用(从 Java 代码调用本地代码)和上行调用(从本地代码返回到 Java 代码),FunctionDescriptor 用于描述外部函数的返回类型和参数类型,这些类型在 FFM API 中可以由 MemoryLayout 对象描述,例如 ValueLayout 表示值类型,GroupLayout 表示结构类型。
通过 FFI 提供的接口,我们可以生成对应外部函数的方法句柄(MethodHandle),方法句柄是 Java 7 引入的一个抽象概念,可以实现对方法的动态调用,它提供了比反射更高的性能和更灵活的使用方式,这里复用了方法句柄的概念,通过方法句柄的 invoke() 方法就可以实现外部函数的调用。
这里我们不再需要编写 C 代码,也不再需要编译链接生成动态库,所以,也就不存在平台相关的问题了。另一方面,FFI 接口的设计大多数情况下是安全的,由于都是 Java 代码,因此也受到 Java 安全机制的约束,虽然也有一部分接口是不安全的,但是比 JNI 来说要好多了。
OpenJDK 还提供了一个 jextract 工具,用于从本地库自动生成 Java 代码,有兴趣的同学可以尝试一下。
使用 ByteBuffer 和 Unsafe 访问堆外内存
上面说过,FFM API 的另一个主要部分是 内存 API(Memory API) ,用于安全地管理堆外内存。其实在 FFIDemo 的示例中我们已经见到内存 API 了,其中 printf 打印的 Hello World!\n 字符串,就是通过 Arena 这个内存 API 分配的。
但是在学习内存 API 之前,我们先来复习下 Java 在之前的版本中是如何处理堆外内存的。
内存的使用往往和程序性能挂钩,很多像 TensorFlow、Ignite、Netty 这样的类库,都对性能有很高的要求,为了避免垃圾收集器不可预测的行为以及额外的性能开销,这些类库一般倾向于使用 JVM 之外的内存来存储和管理数据,这就是我们常说的 堆外内存(off-heap memory)。
使用堆外内存有两个明显的好处:
- 使用堆外内存,也就意味着堆内内存较小,从而可以减少垃圾回收次数,以及垃圾回收停顿对于应用的影响;
- 在 I/O 通信过程中,通常会存在堆内内存和堆外内存之间的数据拷贝操作,频繁的内存拷贝是性能的主要障碍之一,为了极致的性能,一份数据应该只占一份内存空间,这就是所谓的 零拷贝,直接使用堆外内存可以提升程序 I/O 操作的性能。
ByteBuffer 是访问堆外内存最常用的方法:
csharp
private static void testDirect() {
ByteBuffer bb = ByteBuffer.allocateDirect(10);
bb.putInt(0);
bb.putInt(1);
bb.put((byte)0);
bb.put((byte)1);
bb.flip();
System.out.println(bb.getInt());
System.out.println(bb.getInt());
System.out.println(bb.get());
System.out.println(bb.get());
}
上面的代码使用 ByteBuffer.allocateDirect(10) 分配了 10 个字节的直接内存,然后通过 put 写内存,通过 get 读内存。
可以注意到这里的 int 是 4 个字节,byte 是 1 个字节,当写完 2 个 int 和 2 个 byte 后,如果再继续写,就会报 java.nio.BufferOverflowException 异常。
另外还有一点值得注意,我们并没有手动释放内存。虽然这个内存是直接从操作系统分配的,不受 JVM 的控制,但是创建 DirectByteBuffer 对象的同时也会创建一个 Cleaner 对象,它用于跟踪对象的垃圾回收,当 DirectByteBuffer 被垃圾回收时,分配的堆外内存也会一起被释放,所以我们不用手动释放内存。
ByteBuffer 是异步编程和非阻塞编程的核心类,从 java.nio.ByteBuffer 这个包名就可以看出这个类是为 NIO 而设计,可以说,几乎所有的 Java 异步模式或者非阻塞模式的代码,都要直接或者间接地使用 ByteBuffer 来管理数据。尽管如此,这个类仍然存在着一些无法摆脱的限制:
- 首先,它不支持手动释放内存,
ByteBuffer对应内存的释放,完全依赖于 JVM 的垃圾回收机制,这对于一些像 Netty 这样追求极致性能的类库来说并不满足,这些类库往往需要对内存进行精确的控制; - 其次,
ByteBuffer使用了 Java 的整数来表示存储空间的大小,这就导致,它的存储空间最多只有 2G;在网络编程的环境下,这可能并不是一个问题,但是在处理超过 2G 的文件时就不行了,而且像 Memcahed 这样的分布式缓存系统,内存 2G 的限制明显是不够的。
为了突破这些限制,有些类库选择了访问堆外内存的另一条路,使用 sun.misc.Unsafe 类。这个类提供了一些低级别不安全的方法,可以直接访问系统内存资源,自主管理内存资源:
csharp
private static void testUnsafe() throws Exception {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);
long address = unsafe.allocateMemory(10);
unsafe.putInt(address, 0);
unsafe.putInt(address+4, 1);
unsafe.putByte(address+8, (byte)0);
unsafe.putByte(address+9, (byte)1);
System.out.println(unsafe.getInt(address));
System.out.println(unsafe.getInt(address+4));
System.out.println(unsafe.getByte(address+8));
System.out.println(unsafe.getByte(address+9));
unsafe.freeMemory(address);
}
Unsafe 的使用方法和 ByteBuffer 很像,我们使用 unsafe.allocateMemory(10) 分配了 10 个字节的直接内存,然后通过 put 写内存,通过 get 读内存,区别在于我们要手动调整内存地址。
使用 Unsafe 操作内存就像是使用 C 语言中的指针一样,效率虽然提高了不少,但是很显然,它增加了 Java 语言的不安全性,因为它实际上可以访问到任意位置的内存,不正确使用 Unsafe 类会使得程序出错的概率变大。
注意,默认情况下,我们无法直接使用 Unsafe 类,直接使用的话会报下面这样的 SecurityException 异常:
php
Exception in thread "main" java.lang.SecurityException: Unsafe
at jdk.unsupported/sun.misc.Unsafe.getUnsafe(Unsafe.java:99)
at ByteBufferDemo.testUnsafe(ByteBufferDemo.java:33)
at ByteBufferDemo.main(ByteBufferDemo.java:10)
所以上面的代码通过反射的手段,使得我们可以使用 Unsafe。
说了这么多,总结一句话就是:ByteBuffer 安全但效率低,Unsafe 效率高但是不安全。此时,就轮到 内存 API 出场了。
内存 API(Memory API)
内存 API 基于前人的经验,使用了全新的接口设计,它的基本使用如下:
ini
private static void testAllocate() {
try (Arena offHeap = Arena.ofConfined()) {
MemorySegment address = offHeap.allocate(8);
address.setAtIndex(ValueLayout.JAVA_INT, 0, 1);
address.setAtIndex(ValueLayout.JAVA_INT, 1, 0);
System.out.println(address.getAtIndex(ValueLayout.JAVA_INT, 0));
System.out.println(address.getAtIndex(ValueLayout.JAVA_INT, 1));
}
}
这段代码使用 Arena::allocate() 分配了 8 个字节的外部内存,然后写入两个整型数字,最后再读取出来。下面是另一个示例,写入再读取字符串:
csharp
private static void testAllocateString() {
try (Arena offHeap = Arena.ofConfined()) {
MemorySegment str = offHeap.allocateUtf8String("hello");
System.out.println(str.getUtf8String(0));
}
}
这段代码使用 Arena::allocateUtf8String() 根据字符串的长度动态地分配外部内存,然后通过 MemorySegment::getUtf8String() 将其复制到 JVM 栈上并输出。
这两段代码中的 Arena 和 MemorySegment 是内存 API 的关键,MemorySegment 用于表示一段内存片段,既可以是堆内内存也可以是堆外内存;Arena 定义了内存资源的生命周期管理机制,它实现了 AutoCloseable 接口,所以可以使用 try-with-resource 语句及时地释放它管理的内存。
Arena.ofConfined() 表示定义一块受限区域,只有一个线程可以访问在受限区域中分配的内存段。除此之外,我们还可以定义其他类型的区域:
Arena.global()- 全局区域,分配的区域永远不会释放,随时可以访问;Arena.ofAuto()- 自动区域,由垃圾收集器自动检测并释放;Arena.ofShared()- 共享区域,可以被多个线程同时访问;
Arena 接口的设计经过了多次调整,在最初的版本中被称为 ResourceScope,后来改成 MemorySession,再后来又拆成了 Arena 和 SegmentScope 两个类,现在基本上稳定使用 Arena 就可以了。
除 Arena 接口,内存 API 还包括了下面这些接口,主要可以分为两大类:
Arena、MemorySegment、SegmentAllocator- 这几个接口用于控制外部内存的分配和释放MemoryLayout、VarHandle- 这几个接口用于操作和访问结构化的外部内存
内存 API 试图简化 Java 代码操作堆外内存的难度,通过它可以实现更高效的内存访问方式,同时可以保障一定的安全性,特别适用于下面这些场景:
- 大规模数据处理:在处理大规模数据集时,内存 API 的直接内存访问能力将显著提高程序的执行效率;
- 高性能计算:对于需要频繁进行数值计算的任务,内存 API 可以减少对象访问的开销,从而实现更高的计算性能;
- 与本地代码交互:内存 API 的使用可以使得 Java 代码更方便地与本地代码进行交互,结合外部函数接口,可以实现更灵活的数据传输和处理。
相信等内存 API 正式发布之后,之前使用 ByteBuffer 或 Unsafe 的很多类库估计都会考虑切换成使用内存 API 来获取性能的提升。
小结
今天我们学习了 外部函数和内存 API(Foreign Function & Memory API) 这一重要特性,它在 Java 17 开始引入,经过多个版本的迭代,在 Java 21 中是第三个预览版本,并在 Java 22 中正式发布。
FFM API 由两个部分组成:
- 外部函数接口(FFI) - 提供了一种比 JNI 更加优雅、安全和高效的方式来调用本地代码,消除了繁琐的 JNI 桩代码编写,使得 Java 与本地库的交互更加直观和类型安全;相比 JNI 的种种限制(跨平台性差、难以维护、性能低下),FFI 通过方法句柄动态生成对应的本地函数调用,既保证了 Java 的跨平台特性,又提供了更好的安全性和性能;
- 内存 API - 提供了一套统一的、安全的、高效的堆外内存管理方案,它汲取了
ByteBuffer的安全性优势和Unsafe的高性能优点,同时避免了两者的缺陷,通过Arena的生命周期管理和MemorySegment的内存访问抽象,实现了既安全又高效的堆外内存操作;
FFM API 的推出为 Java 打开了与本地代码交互的新大门,为 Java 在人工智能、数据科学、图像处理等需要调用本地库的领域提供了强有力的支持,有望加速 Java 在这些领域的发展和应用。随着 Java 生态中越来越多的高性能库(如 TensorFlow、CUDA 等)的本地绑定逐步迁移到 FFM API,Java 开发者将能够更加便捷地利用这些强大的库来构建高性能应用。
欢迎关注
如果这篇文章对您有所帮助,欢迎关注我的同名公众号:日习一技,每天学一点新技术。
我会每天花一个小时,记录下我学习的点点滴滴。内容包括但不限于:
- 某个产品的使用小窍门
- 开源项目的实践和心得
- 技术点的简单解读
目标是让大家用5分钟读完就能有所收获,不需要太费劲,但却可以轻松获取一些干货。不管你是技术新手还是老鸟,欢迎给我提建议,如果有想学习的技术,也欢迎交流!