拓冰建站拓冰建站
首页 / 资讯中心 / 正文

RocksDB Java API 的 FFI 原型实践:用 Panama Foreign Function Memory API 重建核心 `get()` 接口

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/rocksdbRocksDB 的 Java 绑定长期以来基于 JNIJava Native Interface实现JNI 需要在编译期生成头文件、编写 C 存根并处理复杂的类型映射。本文基于 RocksDB 官方博客《Java Foreign Function Interface》一文完整还原 Evolved Binary 团队在 Java 19 FFI Preview 之上构建的原型实现如何用java.lang.foreignPanama 项目的 Foreign Function Memory API直接调用 RocksDB 的 C 核心、如何以PinnableSlice为基座设计零拷贝的get()接口以及 JNI 与 FFI 在 JMH 基准下的真实性能对比与拷贝 vs 调用的取舍结论。读完本文你将掌握 FFI 原型中 C 侧结构体/存根设计与 Java 侧MethodHandle/VarHandle/MemorySegment的协作模式理解为什么一个既能填充输出缓冲区、又能返回 pinnable slice 的 API是两全其美的方向并可直接复现本文附录中的完整基准运行流程。一、为什么考虑用 FFI 重写 RocksDB Java APIRocksDB 的 Java API位于 java/rocksjni 目录通过 JNI 暴露 C 核心能力。JNI 虽然成熟稳定但其开发体验和运行模型存在几个长期痛点构建期代码生成JNI 需要预先通过javac -h等步骤生成头文件Java 侧声明native方法C 侧实现同名方法编译与链接环节被强行拆开类型映射脆弱Java 对象与 C 数据之间需要大量桥接代码jstring、jbyteArray、jobject等 JNI 类型的读写极易出错性能开销跨语言边界的每次调用都要经过 JNI 的类型转换与参数 marshalling。Java 19 引入的 FFIForeign Function InterfacePreview 提供了一套标准化的跨语言互操作模型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与FFIDBJava 侧由三个类协作完成对上述存根的调用FFIMethod为每个 C 存根方法发布一个java.lang.invoke.MethodHandle例如GetPinnable指向rocksdb_ffi_get_pinnable与ResetPinnable指向rocksdb_ffi_reset_pinnableMethodHandle 就是发现并调用本地方法的载体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 slicepublic record GetPinnableSlice(Status.Code code, OptionalFFIPinnableSlice 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-accessALL-UNNAMED -jar target/rocksdbjni-jmh-1.0-SNAPSHOT-benchmarks.jar -p keyCount100000 -p keySize128 -p valueSize4096,65536 -p columnFamilyTestTypeno_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()结果缓冲区由方法每次分配ffiGetvsgetJNI 略快且每次调用都伴随一次结果缓冲区的分配成本预分配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 的发布计划制约短期内它无法在生产环境替代 JNIPanama/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 构建一个稳健的 APIPanama/FFI 真正的闪光点是作为foreign memory 标准它给出了把 pinnable slice 的内容以MemorySegment呈现、高效返回数据的模型。如果聚焦于设计一个面向原生互操作的 API这将为 RocksDB 打开新的用途与机会。附录A. 代码与数据原型实现的源码、更多数据图以及所有数据图的 CSV 源文件均在对应的实验性 Pull Request 中提供对应文档发布时的原型分支。B. 运行基准以下是一个示例运行-p之后的 JMH 参数可以修改以测量不同 key 数量、key 大小与 value 大小下的性能java --enable-preview --enable-native-accessALL-UNNAMED -jar target/rocksdbjni-jmh-1.0-SNAPSHOT-benchmarks.jar -p keyCount100000 -p keySize128 -p valueSize4096,65536 -p columnFamilyTestTypeno_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 keyCount100000 and valueSize65536 -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),仅供参考
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门