RocksDB Java API 的 FFI 原型实践:用 Panama Foreign Function & Memory API 重建核心get()接口
【免费下载链接】rocksdbA library that provides an embeddable, persistent key-value store for fast storage.项目地址: https://gitcode.com/gh_mirrors/ro/rocksdb
RocksDB 的 Java 绑定长期以来基于 JNI(Java Native Interface)实现,JNI 需要在编译期生成头文件、编写 C++ 存根并处理复杂的类型映射。本文基于 RocksDB 官方博客《Java Foreign Function Interface》一文,完整还原 Evolved Binary 团队在 Java 19 FFI Preview 之上构建的原型实现:如何用java.lang.foreign(Panama 项目的 Foreign Function & Memory API)直接调用 RocksDB 的 C++ 核心、如何以PinnableSlice为基座设计零拷贝的get()接口,以及 JNI 与 FFI 在 JMH 基准下的真实性能对比与"拷贝 vs 调用"的取舍结论。读完本文,你将掌握 FFI 原型中 C++ 侧结构体/存根设计与 Java 侧MethodHandle/VarHandle/MemorySegment的协作模式,理解为什么"一个既能填充输出缓冲区、又能返回 pinnable slice 的 API"是两全其美的方向,并可直接复现本文附录中的完整基准运行流程。
一、为什么考虑用 FFI 重写 RocksDB Java API
RocksDB 的 Java API(位于 java/rocksjni 目录)通过 JNI 暴露 C++ 核心能力。JNI 虽然成熟稳定,但其开发体验和运行模型存在几个长期痛点:
- 构建期代码生成:JNI 需要预先通过
javac -h等步骤生成头文件,Java 侧声明native方法,C++ 侧实现同名方法,编译与链接环节被强行拆开; - 类型映射脆弱:Java 对象与 C++ 数据之间需要大量桥接代码,
jstring、jbyteArray、jobject等 JNI 类型的读写极易出错; - 性能开销:跨语言边界的每次调用都要经过 JNI 的类型转换与参数 marshalling。
Java 19 引入的 FFI(Foreign Function Interface)Preview 提供了一套标准化的跨语言互操作模型:Java 程序可以高效调用 JVM 之外的本地函数,并安全地访问不受 JVM 管理的内存。如果"高效 + 安全"这两个承诺都能兑现,那么用它重建 RocksDB Java API 将带来三方面收益:
- 移除 JNI 访问 C++ RocksDB 的复杂性;
- 提升 RocksDB Java API 的性能;
- 减少 Java API 编码出错的机会。
Evolved Binary 团队据此开展了一项原型研究:创建独立的 FFI 原型分支、把 RocksDB Java 构建升级到 Java 19、用 FFI Preview API 实现核心的get()功能,并扩展现有 JMH 基准来对比 FFI 与 JNI 的性能。由于 JNI 与 FFI 可以在同一进程中和平共存,原型直接复用了现有 RocksDB Java 绑定来做get()之外的支持性工作,从而把实验范围精准地收敛在 FFI 本身。
二、两种跨语言机制:JNI 与 FFI 的工作原理
2.1 JNI 如何工作
JNI 在构建/编译阶段需要一个预处理步骤来生成头文件,纯 Java 代码通过声明native方法与这些头文件建立链接;随后为头文件中的每个方法编写 C++ 实现,最后将整体链接起来。C++ 方法内部要使用一整套 JNI 库设施来读写 Java 的值和对象,并在需要时反向创建 Java 对象——也就是说,每一次调用都要穿越"Java 对象 ↔ JNI 类型 ↔ C++ 对象"三层映射,这正是 JNI 样板代码和出错风险的来源。
2.2 FFI 如何工作
与 JNI 相反,FFI 让 Java 直接调用已有的本地(本仓库即 C++)代码,无需在编译阶段生成任何支持文件。FFI 提供了两个核心抽象:
- 本地内存模型:可以在本地内存中分配、读写内存块与其中的原生结构体;
- 方法发现与调用模型:可以按"本地内存引用 + 值类型参数"的方式发现并调用本地方法。
被调用的 C++ 代码完全以本地方式执行,不需要读取任何 Java 对象来获取数据,因此现有 C++ 包乃至其他足够底层的语言库都可以被 Java 直接调用,无需在 C++ 侧实现存根。FFI 也支持外部工具jextract自动生成常见的样板代码以减少出错,但原型阶段刻意没有使用它——目的正是为了更深入地理解 FFI 的真实运作机制。
2.3 原型的技术路线
C++ 的类与对象无法直接在 FFI 的模型里表达,因此原型采取了一条务实的路线:在 C++ 侧编写少量 C 风格的极简存根方法,由它们立即切入 RocksDB 面向对象的 C++ 核心;同时定义若干 C 结构体,用于向存根方法传参和接收返回值。这样既绕开了 FFI 表达对象模型的短板,又把 FFI 的收益(无构建期代码生成、纯 Java 侧组装调用)完整保留下来。
三、原型实现:C++ 侧存根与 Java 侧封装
3.1 C++ 侧:rocksdb_ffi_get_pinnable()与两个结构体
原型实现的第一个方法是一个extern "C"导出、可直接被 FFI 定位的函数:
extern "C" int rocksdb_ffi_get_pinnable( ROCKSDB_NAMESPACE::DB* db, ROCKSDB_NAMESPACE::ReadOptions* read_options, ROCKSDB_NAMESPACE::ColumnFamilyHandle* cf, rocksdb_input_slice_t* key, rocksdb_pinnable_slice_t* value);输入结构体描述 key(数据指针 + 长度):
typedef struct rocksdb_input_slice { const char* data; size_t size; } rocksdb_input_slice_t;输出结构体是一个 pinnable slice(详见第四节):
typedef struct rocksdb_pinnable_slice { const char* data; size_t size; ROCKSDB_NAMESPACE::PinnableSlice* pinnable_slice; bool is_pinned; } rocksdb_pinnable_slice_t;这个设计有一个值得注意的点:结构体里既携带了可直接读取的data/size,又保存了底层PinnableSlice*指针和is_pinned标志,后者是后续实现"按需释放/重置"接口(对应 Java 侧FFIPinnableSlice.reset())的锚点。
3.2 Java 侧:FFIMethod、FFILayout与FFIDB
Java 侧由三个类协作完成对上述存根的调用:
FFIMethod:为每个 C++ 存根方法发布一个java.lang.invoke.MethodHandle,例如GetPinnable(指向rocksdb_ffi_get_pinnable)与ResetPinnable(指向rocksdb_ffi_reset_pinnable),MethodHandle 就是"发现并调用本地方法"的载体;FFILayout:用java.lang.foreign的GroupLayout在 Java 侧描述传入/传出的 C 结构体布局,并为每个字段(Data、Size、IsPinned)发布VarHandle,用于读写本地内存中的结构体字段;FFIDB:实现公开的 Java FFI API 方法,内部持有MemorySession与SegmentAllocator——前者控制本地内存会话的生命周期,后者负责分配"生命周期受限的本地内存",这些内存既可供 Java 读写,也可直接传给本地方法。
原型的FFILayout大致形如(伪代码示意):
public static class InputSlice { static final GroupLayout Layout = ...; static final VarHandle Data = ...; static final VarHandle Size = ...; }; public static class PinnableSlice { static final GroupLayout Layout = ...; static final VarHandle Data = ...; static final VarHandle Size = ...; static final VarHandle IsPinned = ...; }; public static class OutputSlice { static final GroupLayout Layout = ...; static final VarHandle Data = ...; static final VarHandle Size = ...; };在用户层,原型对外呈现一个把FFIMethod与FFILayout细节全部封装掉的、单个核心 Java API 方法:
public GetPinnableSlice getPinnableSlice(final ReadOptions readOptions, final ColumnFamilyHandle columnFamilyHandle, final MemorySegment keySegment, final GetParams getParams)getPinnableSlice()的实现流程,与其他任何核心 RocksDB FFI API 方法完全一致,分为四步:
- 依据
FFILayout中的Layout,为 C++ 结构体分配MemorySegment; - 用
FFILayout中的VarHandle向分配好的结构体写入数据; - 用
FFIMethod中的MethodHandle调用本地方法,参数为实例化MemorySegment的地址或值类型; - 再次借助
FFILayout的VarHandle读取调用结果与输出参数,完成映射。
getPinnableSlice()成功返回后,PinnableSlice对象中即包含承载所请求 value 的 pinnable slice 的data与size字段;随后构造一个指向该 pinnable slice 本地内存的MemorySegment,交给客户端按需取用——整个过程不复制数据。
四、以 PinnableSlice 为基座:零拷贝的get()设计
RocksDB 的 C++ 核心 API 通过PinnableSlice返回取到的数据值,其目的是把拷贝次数降到最低。该类型的定义位于 include/rocksdb/slice.h:它继承自Slice与Cleanable,可以"固定(pin)"一段数据并挂载清理任务,Reset()或析构时统一释放,从而避免不必要的memcpy。
从源码可以看到其关键能力:
PinSlice():将data_/size_直接指向 RocksDB 内部缓冲区并注册清理回调,全程无拷贝;PinSelf():当无法 pin 内部缓冲区时,把数据拷贝进自身持有的std::string buf_再引用之;Reset():调用Cleanable::Reset()执行清理、复位pinned_标志并清空size_;IsPinned():暴露当前是否处于 pin 状态。
原型的中央get()方法族正是建立在PinnableSlice之上。首先实现最底层的方法(直接暴露 pinnable slice):
public record GetPinnableSlice(Status.Code code, Optional<FFIPinnableSlice> pinnableSlice) {} public GetPinnableSlice getPinnableSlice( final ColumnFamilyHandle columnFamilyHandle, final byte[] key)再在其上包装出与现有 JNI API 形态一致的纯 Java 方法(把结果拷贝进byte[]):
public record GetBytes(Status.Code code, byte[] value, long size) {} public GetBytes get(final ColumnFamilyHandle columnFamilyHandle, final byte[] key)这样,"镜像现有 JNI API 的方法"就全部变成纯 Java 的薄封装,而真正接触本地内存的只有极小的一层 pinnable slice 接口。这与PinnableSlice的源码语义完全对应:getPinnableSlice()对应 pin 内部缓冲区的零拷贝路径,get()则多一次拷贝(对应PinSelf()的兜底路径或 Java 侧的显式拷贝)。
五、基准测试:JNI 与 FFI 的真实性能对比
5.1 基准的搭建方式
原型扩展现有的 RocksDB Java JMH 基准,新增了基于 FFI 的基准。现有 JNI 基准位于 java/jmh/src/main/java/org/rocksdb/jmh/GetBenchmarks.java,其中get()、preallocatedGet()(预分配结果缓冲区)、preallocatedByteBufferGet()等 benchmark 用@Param控制keyCount、keySize、valueSize与列族数量(columnFamilyTestType)。FFI 版基准与 JNI 版一一对应,尽量保持参数与访问模式一致,例如同样区分"基本 get(结果缓冲区由方法分配)"与"预分配 get(结果缓冲区由调用方提供并复用)"。
在 Ubuntu 上完整运行(含新增基准)的命令:
java --enable-preview --enable-native-access=ALL-UNNAMED -jar target/rocksdbjni-jmh-1.0-SNAPSHOT-benchmarks.jar -p keyCount=100000 -p keySize=128 -p valueSize=4096,65536 -p columnFamilyTestType="no_column_family" -rf csv org.rocksdb.jmh.GetBenchmarks上图对比了若干组等价的 JNI 与 FFI 基准(操作数越多越好)。可以从 CSV 中进一步筛选出指定参数组合下的数据:
q "select Benchmark,Score from ./plot/jmh-result-fixed.csv where \"Param: keyCount\"=100000 and \"Param: valueSize\"=65536 -d, -H5.2 结果讨论
- 总体结论:对全部拥有等价 JNI/FFI 对的基准而言,JNI 仅"非常轻微地"快于 FFI。FFI 已经成功优化掉了新调用机制里的大部分额外安全检查。原型初版 FFI 基准曾明显落后于 JNI,但在 Panama 团队(Maurizio Cimadamore)的帮助下大幅优化;剩余的小差距被认为是 FFI 保留的额外边界检查(bounds checking)带来的。
- 基本
get()(结果缓冲区由方法每次分配):ffiGetvsget,JNI 略快,且每次调用都伴随一次结果缓冲区的分配成本; - 预分配
get()(调用方提供并复用结果缓冲区):preallocatedGet()比基本get()快得多;JNI 与 FFI 之间仍然只差那么一小点; - 随机 key 的变体:为消除顺序访问的缓存效应而实现的随机 key 基准,观察到的差异保持一致;
ffiGetPinnableSlice():直接访问原始PinnableSliceAPI 的基准是所有方法中最快的——它返回指向 RocksDB 内存(包含该 slice)的句柄,并以 FFIMemorySegment呈现,不拷贝该内存段中的任何数据。当然这并非严格对等比较,因为 JNI 世界没有对应的零拷贝暴露方式;ffiGetOutputSlice()(在 C++ 侧把结果拷贝进 Java 分配的本地内存段):比ffiPreallocatedGet()更快,至少与 JNI 世界的近亲preallocatedGet()持平。
由此可以判断:用 FFI 构建一个与 JNI 性能等价的 API 是可行的。至于ffiGetPinnableSlice()系调用与 JNI 调用之间的微小差距,合理预期部分成本来自"额外的一次 FFI 调用去释放 pinned slice"(空的 FFI 调用极快,但仍需耗时)。文中同时建议:当 Panama 在 Java 21 中走出 Preview 后,值得重新审视 FFI 实现的表现(至少在 Java 20 上,FFI 基准性能与 Java 19 版本没有显著差异)。
5.3 拷贝 vs 调用:ffiGetOutputSlice与ffiGetPinnableSlice
跨 FFI 边界释放 pinnable slice 需要第二次方法调用,这是有成本的。为了量化它,原型在固定 key 大小(128 字节,key 大小基本无关紧要)的前提下,把读取的 value 大小从 16 字节变化到 16k,得到如下结果:
- 读取的 value ≤ 1k 时,
ffiGetOutputSlice()更快:C++ 侧从 pinnable slice 缓冲区向"由 Java Foreign Memory API 分配的缓冲区"多拷一次的成本,小于"额外一次释放 pinnable slice 的调用"的成本; - 读取的 value ≥ 4k 时,
ffiGetPinnableSlice()更快:与直觉一致,读取值越大优势越明显。
关键在于:RocksDB 的 API 结构决定了两个方法之间ffiGetOutputSlice()始终比ffiGetPinnableSlice()多恰好 1 次拷贝——底层 C++ API 在判定"无法 pin 内部缓冲区"时总会先拷贝进自己的临时缓冲区,再以 pinnable slice 返回。这里存在一个潜在优化:把那个临时缓冲区替换成ffiGetOutputSlice()提供的输出缓冲区,但实践中这是很难 hack 的改动,其有效性还取决于 RocksDB 有多频繁地无法 pin 内部缓冲区。原型的结论是:一个"要么填充缓冲区、要么返回 pinnable slice"的解决方案,才能两全其美。
六、其他结论:构建、安全性与本地内存
6.1 构建处理
- 用 FFI 实现接口比 JNI 更简单:实现这个原型不需要任何中间构建处理或代码生成步骤;
- 面向生产环境,则强烈建议使用
jextract自动化"从一组支持存根生成 Java API 方法"的过程。
6.2 安全性
使用jextract能带来与 JNI 相当的跨语言边界类型安全。但就方法调用本身而言,FFI 并不比 JNI 显著更类型安全——当然,也不比 JNI 更不安全。
6.3 本地内存:Panama 项目最有价值的部分
Panama 的Foreign-Memory Access API被原型视为整个项目中最有意义的部分。在 RocksDB Java 侧,它给出了一个干净的载体(MemorySegment)来持有 RocksDB 数据(例如一次get()的结果),供其随后转发给客户端代码或网络缓冲区。原型正是借助这一机制提供了核心的FFIDB.getPinnableSlice()方法;其余镜像现有 JNI API 的原型get()方法,则是构建在FFIDB.getPinnableSlice()与FFIPinnableSlice.reset()之上的纯 Java 库。
统一的 foreign memory 标准还打开了 RocksDB 与 Java 客户端(例如 Kafka 这类重度依赖本地缓冲区的系统)之间高效互操作的可能性,这也是通往"更高性能、更深度集成"的 Java 系统的钥匙:
- 数据可能永远不需要被拷贝进 Java 内存,或拷贝次数大幅减少——多个协作的 Java 客户端之间直接交接原生
MemorySegment即可。当两个及以上客户端互操作时,这部分额外性能潜力极其可观;前提仍是提供一层像原型get()这样、与现有 Java API 同级别的极简包装; - 需要进一步思考这种架构如何与 RocksDB 的缓存层(cache layer(s))交互、能否在现有架构内容纳:第三方应用能在不干扰 RocksDB 正常行为(如 compaction)的前提下,把缓存页 pin 多久?
七、总结
- Panama/FFI(当前仓库文档写作时为 Preview 状态)是(重)建 RocksDB Java API 的高潜力技术;不过受 RocksDB 支持的 Java 语言级别与 Panama 的发布计划制约,短期内它无法在生产环境替代 JNI;
- Panama/FFI 的性能与 JNI 相当,单独重建一个 RocksDB Java API 并没有很强的性能动机;但它提供了自然暴露 pinnable slice 式 API 的机会,灵活性很高——一个高效 API 可以大部分用 Java 构建,只保留极小的底层 pinnable slice 接口层;
- Panama/FFI 能去掉部分样板代码(native 方法声明),并让 Java 直接访问 C 库而无需存根;但调用 C++ 库仍需 C 存根。可行的方向有两个:以 RocksDB C API(见 include/rocksdb/c.h)为重建 Java API 的基础(可移除全部现有 JNI 样板,并把支持精力集中到 C API 上);或基于引用计数(参考 RocksDB 的引用计数相关改造思路)但改用 FFI 构建一个稳健的 API;
- Panama/FFI 真正的闪光点是作为foreign memory 标准:它给出了"把 pinnable slice 的内容以
MemorySegment呈现、高效返回数据"的模型。如果聚焦于设计一个面向原生互操作的 API,这将为 RocksDB 打开新的用途与机会。
附录
A. 代码与数据
原型实现的源码、更多数据图以及所有数据图的 CSV 源文件,均在对应的实验性 Pull Request 中提供(对应文档发布时的原型分支)。
B. 运行基准
以下是一个示例运行;-p之后的 JMH 参数可以修改,以测量不同 key 数量、key 大小与 value 大小下的性能:
java --enable-preview --enable-native-access=ALL-UNNAMED -jar target/rocksdbjni-jmh-1.0-SNAPSHOT-benchmarks.jar -p keyCount=100000 -p keySize=128 -p valueSize=4096,65536 -p columnFamilyTestType="no_column_family" -rf csv org.rocksdb.jmh.GetBenchmarks -wi 1 -to 1m -i 1C. 结果处理
使用q工具筛选 CSV 输出,供分析与绘图使用。注意:原型为便于处理,编辑过 CSV 的列标题。
q "select Benchmark,Score,Error from ./plot/jmh-result.csv where keyCount=100000 and valueSize=65536" -d, -H -C readwriteD. Java 19 安装与构建环境
原型按 Azul Zulu 的 Debian 安装指引安装 JDK 19,然后在本地选择合适的 Java 实例:
sudo update-alternatives --config java sudo update-alternatives --config javac并正确设置JAVA_HOME。sudo update-alternatives --config java会列出几个 JVM,例如:
0 /usr/lib/jvm/bellsoft-java8-full-amd64/bin/java 20803123 auto mode 1 /usr/lib/jvm/bellsoft-java8-full-amd64/bin/java 20803123 manual mode 2 /usr/lib/jvm/java-11-openjdk-amd64/bin/java 1111 manual mode * 3 /usr/lib/jvm/zulu19/bin/java 2193001 manual mode本例环境对应的设置:
export JAVA_HOME=/usr/lib/jvm/zulu19一个重要的环境前提:Ubuntu 软件仓库自带的默认 Maven 版本(3.6.3)与 Java 19 不兼容,需要另行安装更新的 Maven 版本(原型验证使用 3.8.7 成功)。
E. Java 20、21、22 及后续版本
原型使用的 FFI 版本在 Java 19 中还是 Preview,相关接口在直到 Java 22 的演进中持续变化,并在 Java 22 中最终定型。该原型后续的迭代工作,都需要把代码更新到变化后的接口上。这也提醒读者:本文所述 API 形态以原型编写时的 Java 19/20 为准,实际使用时请对照你所处 JDK 版本对应的java.lang.foreign最终 API。
【免费下载链接】rocksdbA library that provides an embeddable, persistent key-value store for fast storage.项目地址: https://gitcode.com/gh_mirrors/ro/rocksdb
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考