跨语言编程是现代程序语言中非常重要的一个方向,也被广泛应用于复杂系统的设计与实现中。本文是 GIAC 2021(全球互联网架构大会) 中关于 Alibaba FFI — “跨语言编程的探索”主题分享的内容整理。两位分享人董登辉和顾天晓分别是龙蜥社区 Java SIG(Reliability,availability and serviceability)负责人和核心人员。
背景
前言
无疑,Java 是目前工业界最流行的应用编程语言之一。除了主流实现上(OpenJDK Hotspot)不俗的性能表现和成熟的研发生态(Spring 等)外,其成功的背后离不开语言本身较低(相比于 C/C++)的学习门槛。一个初学者可以利用现有的项目脚手架快速地搭建出一个初具规模的应用程序,但也因此许多 Java 程序员对程序底层的执行原理并不熟悉。本文将探讨一个在大部分 Java 相关的研发工作中不太涉及到的技术 — 跨语言编程。
回想起多年前第一次用 Java 在控制台上打印出 “Hello World” 时,出于好奇便翻阅 JDK 源码想一探实现的究竟 (在 C 语言中我们可以使用 printf 函数,而 printf 在具体实现上又依赖操作系统的接口),再一次次跳转后最终停留在了一个“看不到实现”的 native 方法上,额,然后就没有然后了。
我想有不少 Java 初级程序员对 native 方法的调用机制仍一知半解,毕竟在大部分研发工作中,我们直接实现一个自定义 native 方法的需求并不多,简单来说 native 方法是 Java 进行跨语言调用的接口,这也是 Java Native Interface 规范的一部分。
Java 跨语言编程技术的应用场景
常见场景
在介绍 Java 跨语言编程技术之前,首先简单地分析下实际编程过程中需要使用跨语言编程技术的场景,在这里我罗列了以下四个场景:
1、依赖字节码不支持的能力
换个角度看,目前标准的字节码提供了哪些能力呢?根据 Spec 规范,已有的字节码可以实现创建 Java 对象、访问对象字段和方法、常规计算(加减乘除与或非等)、比较、跳转以及异常、锁操作等等,但是像前言中提到的打印字符串到控制台这样的高阶功能,字节码并不直接支持,此外,像获取当前时间,分配堆外内存以及图形渲染等等字节码也都不支持,我们很难写出一个纯粹的 Java 方法(组合这些字节码)来实现这类能力,因为这些功能往往需要和系统资源产生交互。在这些场景下,我们就需要使用跨语言编程技术通过其他语言的实现来集成这些功能。
2、使用系统级语言(C、C++、Assembly)实现系统的关键路径
不需要显示释放对象是 Java 语言学习门槛低的原因之一,但因此也引入了 GC 的机制来清理程序中不再需要的对象。在主流 JVM 实现中,GC 会使得应用整体暂停,影响系统整体性能,包括响应与吞吐。
所以相对于 C/C++ ,Java 虽然减轻了程序员的研发负担,提高了产品研发效率,但也引入了运行时的开销。(Software engineering is largely the art of balancing competing trade-offs. )
当系统关键路径上的核心部分(比如一些复杂算法)使用 Java 实现会出现性能不稳定的情况下,可以尝试使用相对底层的编程语言来实现这部分逻辑,以达到性能稳定以及较低的资源消耗目的。
3、其他语言实现调用 Java
这个场景可能给大部分 Java 程序员的第一感觉是几乎没有遇到过,但事实上我们几乎每天都在经历这样的场景。
举个例子:通过 java <Main-Class> 跑一个 Java 程序就会经过 C 语言调用 Java 语言的过程,后文还会对此做提及。
4、历史遗留库
公司内部或者开源实现中存在一些 C/C++ 写的高性能库,用 Java 重写以及后期维护的成本非常大。当 Java 应用需要使用这些库提供的能力时,我们需要借助跨语言编程技术来复用。
Alibaba Grape
再简单谈谈阿里内部的一个场景:Alibaba Grape 项目,也是在跨语言编程技术方向上与我们团队合作的第一个业务方。
Grape 本身是一个并行图计算框架的开源项目(相关论文获得了 ACM SIGMOD Best Paper Award),主要使用 C++ 编写,工程实现上应用了大量的模板特性。对 Grape 项目感兴趣的同学可以参考 Github 上的相关文档,这里不再详细介绍。
该项目在内部应用中,存在很多使用 Java 作为主要编程语言的业务方,因此需要开发人员把 Grape 库封装成 Java SDK 供上层的 Java 应用调用。在实践过程中,遇到的两个显著问题:
System.out.println("hello ffi");通过 System.out 我们可以快速地实现控制台输出功能,我相信会有不少好奇的同学会关心这个调用到底是如何实现输出功能的,翻阅源码后,我们最终会看见这样一个 native 方法:
(该方法由 JDK 实现,具体实现可以参考 这里: https://github.com/openjdk/jdk/blob/master/src/java.base/share/native/libjava/io_util.c ) 那么我们是否可以自己实现这样的功能呢?答案是肯定的,大致步骤如下(省略了一些细节): a. 首先我们定义一个 Java native 方法,需要使用 native 关键字,同时不提供具体的实现(native 方法可以被重载)private native void writeBytes(byte b[], int off, int len, boolean append) throws
IOException;
static native void myHelloFFI();
b. 通过 javah 或者 javac -h (JDK 10)命令生成后续步骤依赖的头文件(该头文件可以被 C 或者 C++ 程序使用)
c. 实现头文件中的函数,在这里我们直接使用 printf 函数在控制台输出 “hello ffi”/* DO NOT EDIT THIS FILE - it is machine generated */
/* Header for class HelloFFI */
extern "C" {
/*
* Class: HelloFFI
* Method: myHelloFFI
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_HelloFFI_myHelloFFI
(JNIEnv *, jclass);
}
d. 通过 C/C++ 编译器(gcc/llvm 等)编译生成库文件 e. 使用 -Djava.library.path=... 参数指定库文件路径并在运行时调用 System.loadLibrary 加载上个步骤中生成的库,之后 Java 程序就可以正常调用我们自己实现的 myHelloFFI 方法了。 C 程序调用 Java 方法 上面是 Java 方法调用 C 函数的例子,通过 JNI 技术,我们还可以实现 C 程序中调用 Java 方法,这里面会涉及到 2 个概念:Invocation API 与 JNI function,在下面代码示例中省略了初始化虚拟机的步骤,仅给出最终实现调用的两个步骤。JNIEXPORT void JNICALL Java_HelloFFI_myHelloFFI
(JNIEnv * env, jclass c) {
printf("hello ffi");
}
// Init jvm ...// Get method idjmethodID mainID = (*env)->GetStaticMethodID(env, mainClass, "main", "([Ljava/lang/String;)V");/* Invoke */(*env)->CallStaticVoidMethod(env, mainClass, mainID, mainArgs);
示例中首先通过 GetStaticMethodID 获取方法的 “id”,之后通过 CallStaticVoidMethod 实现方法的调用,这两个函数都是 JNI function。
前面我们提到过,当我们 java <Main Class> 运行 Java 程序时是其他语言调用 Java 语言的场景,事实上 Java 命令在实现上就是应用类似上述代码的流程完成主类 main 方法的调用。顺带提一点,我们日常研发过程中常用的一些诊断命令,比如 jcmd、jmap、jstack,和 java 命令是同一份源码实现(可以从图中看出这几个二进制文件的大小差不多),只是在构建过程中使用了不同的构建参数。
那么 JNI 到底是什么呢?以下是我的理解。
遗憾的是,JNA 和 JNR 对 C++ 的支持并不友好,因此在调用 C++ 库的场景中使用受限。 JavaCPP The missing bridge between Java and native C++ 如果说 JNA/JNR 优化了 Java 调用 C 的编程体验,那么 JavaCPP 的目标则是优化 Java 调用 C++ 的编程体验,目前该项目也是工业界用得较多的SDK。 JavaCPP 已经支持大部分 C++ 特性,比如 Overloaded operators、Class & Function templates、Callback through function pointers 等。和 JNA/JNR 类似,JavaCPP 底层也是基于 JNI,实现上通过注解处理等机制自动生成类似的胶水代码以及一些构建脚本。 此外,该项目也提供了利用 JavaCPP 实现的一些常用 C++ 库的 Preset,如 LLVM、Caffe 等。 下面是使用 JavaCPP 封装 std::vector 的的示例:public class HelloWorld {
public interface LibC { // A representation of libC in Java
int puts(String s); // mapping of the puts function, in C `int puts(const char *s);`
}
public static void main(String[] args) {
LibC libc = LibraryLoader.create(LibC.class).load("c"); // load the "c" library into the libc variable
libc.puts("Hello World!"); // prints "Hello World!" to console
}
}
Graal & Panama Graal 和 Panama 是目前两个相对活跃的社区项目,与跨语言编程有直接的联系。但这两项技术还未在生产环境中大规模使用验证,在这里不做具体的描述,有机会的话会单独介绍这两个项目。 FBJNI FBJNI(https://github.com/facebookincubator/fbjni)是 Facebook 开源的一套辅助 C++ 开发人员使用 JNI 的框架。前面提到的大多是如何让Java用户快速的访问Native方法,实际在跨语言调用场景下,也存在 C++ 用户需要安全便捷的访问 Java 代码的场景。Alibaba FFI 目前关注的是如何让 Java 快速的访问 C++,例如假设一个需求是让 C++ 用户访问 Java 的 List 接口,那么 Alibaba FFI的做法是与其通过 JNI 接口函数来操作 Java 的 List 对象,不如将 C++的 std::vector 通过 FFI 包转成 Java 接口。 JNI 的开销 内联 JVM 高性能的最核心原因是内置了强大的及时编译器(Just in time,简称 JIT)。JIT 会将运行过程中的热点方法编译成可执行代码,使得这些方法可以直接运行(避免了解释字节码执行)。在编译过程中应用了许多优化技术,内联便是其中最重要的优化之一。简单来说,内联是把被调用方法的执行逻辑嵌入到调用者的逻辑中,这样不仅可以消除方法调用带来的开销,同时能够进行更多的程序优化。 但是在目前 hotspot 的实现中,JIT 仅支持Java 方法的内联,所以如果一个 Java 方法调用了 native 方法,则无法对这个 native 方法应用内联优化。 说到这里,肯定有人疑惑难道我们经常使用的一些 native 方法,比如 System.currentTimeMillis,没有办法被内联吗?实际上,针对这些在应用中会被经常使用的 native 方法,hotspot 会使用 Intrinsic 技术来提高调用性能(非 native 方法也可以被 Intrinsic)。个人认为 Intrinsic 有点类似 build-in 的概念,当 JIT 遇到这类方法调用时,能够在最终生成的代码中直接嵌入方法的实现,不过方法的 Intrinsic 支持通常需要直接修改 JVM。 参数传递 JNI 的另一个开销是参数传递(包括返回值)。由于不同语言的方法/函数的调用规约(Calling Convention)不同,因此在 Java 方法在调用 native 方法 的时候需要涉及到参数传递的过程,如下图(针对 x64 平台): 根据 JNI 规范,JVM 首先需要把 JNIEnv* 放入第一个参数寄存器(rdi)中,然后把剩下的几个参数包括 this(receiver)分别放入相应的寄存器中。为了让这一过程尽可能地快, hotspot 内部会根据方法签名动态生成转换过程的高效 stub。 状态切换 从 Java 方法进入 native 方法,以及 native 方法执行完成并返回到 Java 方法的过程中会涉及到状态切换。 如下图: 在实现上,状态切换需要引入 memory barrier 以及 safepoint check。 对象访问 JNI 的另一个开销存在于 native 方法中访问 Java 对象。 设想一下,我们需要在一个 C 函数中访问一个 Java 对象,最暴力的方式是直接获取对象的指针然后访问。但是由于 GC 的存在,Java 对象可能会被移动,因此需要一个机制让 native 方法中访问对象的逻辑与地址无关。 All problems in CS can be solved by another level of indirection 在具体的实现上,通过增加一个简介层 JNI Handle,同时使用 JNI Functions 进行对象的访问来解决这个问题,当然这个方案也势必引入了开销。 通过前面的介绍,我们知道现在主流的 Java 跨语言编程技术主要存在两个问题: 1、编程难度 2、跨语言通信的开销 针对问题 1,我们可以利用 JNA/JNR 、JavaCPP 这样技术来解决。那么针对问题 2,我们有相应的优化方案么? 下面正式介绍 Alibaba FFI 项目 Alibaba FFI 概览 Alibaba FFI 项目致力于解决 Java 跨语言编程中遇到的问题,从整体上看项目分为以下两个模块: a. FFI (解决编程难度问题)"<vector>") (include=
public class VectorTest {
"std::vector<std::vector<void*> >") (
public static class PointerVectorVector extends Pointer {
static { Loader.load(); }
public PointerVectorVector() { allocate(); }
public PointerVectorVector(long n) { allocate(n); }
public PointerVectorVector(Pointer p) { super(p); } // this = (vector<vector<void*> >*)p
/**
other methods ....
*/
public native void resize(long i, long n); // (*this)[i].resize(n)
public native Pointer get(long i, long j); // return (*this)[i][j]
public native void put(long i, long j, Pointer p); // (*this)[i][j] = p
}
public static void main(String[] args) {
PointerVectorVector v = new PointerVectorVector(13);
v.resize(0, 42); // v[0].resize(42)
Pointer p = new Pointer() { { address = 0xDEADBEEFL; } };
v.put(0, 0, p); // v[0][0] = p
PointerVectorVector v2 = new PointerVectorVector().put(v);
Pointer p2 = v2.get(0).get(); // p2 = *(&v[0][0])
System.out.println(v2.size() + " " + v2.size(0) + " " + p2);
v2.at(42);
}
}
FFIGen:指定最终生成库的名称 CXXHead:胶水代码中依赖的头文件 FFITypeAlias:C++ 的类名 CXXTemplate:实现 C++ 模板参数具体类型到 Java 类型的映射,相对于 JavaCPP,Alibaba FFI 提供了更灵活的配置 b. 编译过程中,注解处理器会生成最终调用过程中依赖的组件 接口的真实实现:"stdcxx-demo") (library =
"vector", "string"}) (system = {
"std::vector") (
"jint", java="Integer") (cxx=
"jbyte", java="Byte") (cxx=
public interface StdVector<E> extends CXXPointer {
interface Factory<E> {
StdVector<E> create();
}
long size();
"[]") E get(long index); (
"[]") void set(long index, @CXXReference E value); (
void push_back(@CXXValue E e);
long capacity();
void reserve(long size);
void resize(long size);
}
JNI 的胶水代码:public class StdVector_cxx_0x6b0caae2 extends FFIPointerImpl implements StdVector<Byte> {
static {
FFITypeFactory.loadNativeLibrary(StdVector_cxx_0x6b0caae2.class, "stdcxx-demo");
}
public StdVector_cxx_0x6b0caae2(final long address) {
super(address);
}
public long capacity() {
return nativeCapacity(address);
}
public static native long nativeCapacity(long ptr);
...
public long size() {
return nativeSize(address);
}
public static native long nativeSize(long ptr);
}
Crash Protection 在演进过程中,我们引入了一些优化机制,比如针对 C++ 函数返回临时对象的处理、异常的转换等。在这里介绍一下 Crash Protection,也是针对客户在实际场景遇到的问题的解决方案,在 JNA 和 JNR 中也有相应的处理。 有时候,Java 应用依赖的 C++ 库需要进行版本升级,为了防止 C++ 库中的 Bug 导致整个应用 Crash(对于 Java 中的 Bug 通常会表现为异常,多数情况下不会导致应用整体出现问题),我们需要引入保护机制。 如下:
extern "C" {
JNIEXPORT
jbyte JNICALL Java_com_alibaba_ffi_samples_StdVector_1cxx_10x6b0caae2_nativeGet(JNIEnv* env, jclass cls, jlong ptr, jlong arg0 /* index0 */) {
return (jbyte)((*reinterpret_cast<std::vector<jbyte>*>(ptr))[arg0]);
}
JNIEXPORT
jlong JNICALL Java_com_alibaba_ffi_samples_StdVector_1cxx_10x6b0caae2_nativeSize(JNIEnv* env, jclass cls, jlong ptr) {
return (jlong)(reinterpret_cast<std::vector<jbyte>*>(ptr)->size());
}
......
}
在第 3 行会出现内存访问越界的问题,如果不做特殊处理应用会 Crash。为了”隔离“这个问题,我们引入在保护机制,以下是 Linux 上的实现:JNIEXPORT void JNICALL Java_Demo_crash(JNIEnv* env, jclass) {
void* addr = 0;
*(int*)addr = 0; // (Crash)
}
通过实现自己的信号处理函数和 sigsetjmp/siglongjmp 机制来实现 Crash 的保护,需要注意的是由于 Hotspot 有自定义的信号处理器(safepoint check,implicit null check 等),为了防止冲突,需要在启动是 preload libjsig.so(Linux 上)这个库。最后在 handle_crash 中我们可以抛出 Java 异常供后续排查分析。 相关项目的对比PROTECTION_START // 生成胶水代码中插入宏
void* addr = 0;
*(int*)addr = 0;
PROTECTION_END // 宏
// 宏展开后的实现如下
// Pseudo code
// register signal handlers
signal(sig, signal_handler);
int sigsetjmp_rv;
sigsetjmp_rv = sigsetjmp(acquire_sigjmp_buf(), 1);
if (!sigsetjmp_rv) {
void* addr = 0;
*(int*)addr = 0;
}
release_sigjmp_buf();
// restore handler ...
if (sigsetjmp_rv) {
handle_crash(env, sigsetjmp_rv);
}
友好地支持 C++ | 不需要生成和编译源码 | 不需要运行时生成额外的 Stub | 支持 c++ 模板映射 Java 泛型 | |
JNA/JNR | ❌ | ✅ | ❌ | N.A. |
JavaCPP | ✅ | ❌ | ✅ | ❌ |
Alibaba FFI | ✅ | ❌ | ✅ | ✅ |
int v1 = i + j;
int v2 = i - j;
int v3 = i * j;
int v4 = i / j;
return v1 + v2 + v3 + v4;
%5 = sdiv i32 %2, %3
%6 = add i32 %3, 2
%7 = mul i32 %6, %2
%8 = add nsw i32 %5, %7
ret i32 %8
2、JNI Functions 的转换,目前已经支持 90+ 个。未来该功能会和fbjni等类似框架集成,打破Java和Native的代码边界,消除方法调用的额外开销。Code:
stack=2, locals=6, args_size=3
0: iload_1
1: iload_2
2: idiv
3: istore_3
4: iload_2
5: ldc #193 // int 2
7: iadd
8: iload_1
9: imul
10: istore 5
12: iload_3
13: iload 5
15: iadd
16: ireturn
jclass objectClass = env->FindClass(“java/util/List");
return env->IsInstanceOf(arg, objectClass);
3、C++ 对象访问。Alibaba FFI的另外一个好处是可以以面向对象的方式(C++是面向对象语言)来开发 Java off-heap 应用。当前基于Java的大数据平台大多需要支持 off-heap 的数据模块来减轻垃圾回收的压力,然而人工开发的 off-heap 模块需要小心仔细处理不同平台和架构的底层偏移和对齐,容易出错且耗时。通过 Aliabba FFI,我们可以采用 C++开发对象模型,再通过 Alibaba FFI 暴露给 Java 用户使用。Code:
stack=1, locals=3, args_size=2
0: ldc #205 // class java/util/List
2: astore_2
3: aload_1
4: instanceof #205 // class java/util/List
7: i2b
8: ireturn
class Pointer {
public:
int _x;
int _y;
Pointer(): _x(0), _y(0) {}
const int x() { return _x; }
const int y() { return _y; }
};
JNIEXPORT
jint JNICALL Java_Pointer_1cxx_10x4b57d61d_nativeX(JNIEnv*, jclass, jlong ptr) {
return (jint)(reinterpret_cast<Pointer*>(ptr)->x());
}
JNIEXPORT
jint JNICALL Java_Pointer_1cxx_10x4b57d61d_nativeY(JNIEnv*, jclass, jlong ptr) {
return (jint)(reinterpret_cast<Pointer*>(ptr)->y());
}
define i32 @Java_Pointer_1cxx_10x4b57d61d_nativeX
%4 = inttoptr i64 %2 to %class.Pointer*
%5 = getelementptr inbounds %class.Pointer, %class.Pointer* %4, i64 0, i32 0
%6 = load i32, i32* %5, align 4, !tbaa !3
ret i32 %6
define i32 @Java_Pointer_1cxx_10x4b57d61d_nativeY
%4 = inttoptr i64 %2 to %class.Pointer*
%5 = getelementptr inbounds %class.Pointer, %class.Pointer* %4, i64 0, i32 1
%6 = load i32, i32* %5, align 4, !tbaa !8
ret i32 %6
public int y();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #36 // Field address:J
4: invokestatic #84 // Method nativeY:(J)I
7: ireturn
LineNumberTable:
line 70: 0
public static int nativeY(long);
descriptor: (J)I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=4, locals=2, args_size=1
0: lload_0
1: ldc2_w #85 // long 4l
4: ladd
5: invokestatic #80 // Method com/alibaba/llvm4jni/runtime/JavaRuntime.getInt:(J)I
8: ireturn
在访问 C++ 对象的字段实现中,我们使用 Unsafe API 完成堆外内存的直接访问,从而避免了 Native 方法的调用。 性能数据 Grape 在应用 Alibaba FFI 实现的 SSSP(单源最短路径算法)的性能数据如下:public class JavaRuntime {
public static final Unsafe UNSAFE;
...
public static int getInt(long address) {
return UNSAFE.getInt(address);
}
...
}
SSSP (单源最短路径算法) | 归一化后 的 Job Time |
Grape C++ | 1 |
Alibaba FFI (without LLVM4JNI) | 8.3 |
Alibaba FFI (with LLVM4JNI) | 2.4 |
欢迎更多开发者加入Java语言与虚拟机SIG:
网址:https://openanolis.cn/sig/java
邮件列表:java-sig@lists.openanolis.cn
——完—— 加入龙蜥社群加入微信群:添加社区助理-龙蜥社区小龙(微信:openanolis_assis),备注【龙蜥】拉你入群;加入钉钉群:扫描下方钉钉群二维码。欢迎开发者/用户加入龙蜥社区(OpenAnolis)交流,共同推进龙蜥社区的发展,一起打造一个活跃的、健康的开源操作系统生态!
龙蜥社区钉钉交流群 龙蜥社区-小龙
关于龙蜥社区龙蜥社区(OpenAnolis)是由企事业单位、高等院校、科研单位、非营利性组织、个人等按照自愿、平等、开源、协作的基础上组成的非盈利性开源社区。龙蜥社区成立于2020年9月,旨在构建一个开源、中立、开放的Linux上游发行版社区及创新平台。
短期目标是开发龙蜥操作系统(Anolis OS)作为CentOS替代版,重新构建一个兼容国际Linux主流厂商发行版。中长期目标是探索打造一个面向未来的操作系统,建立统一的开源操作系统生态,孵化创新开源项目,繁荣开源生态。
龙蜥OS 8.4已发布,支持x86_64和ARM64架构,完善适配Intel、飞腾、海光、兆芯、鲲鹏芯片。
欢迎下载:
https://openanolis.cn/download
加入我们,一起打造面向未来的开源操作系统!
https://openanolis.cn
本文分享自微信公众号 - OpenAnolis龙蜥(OpenAnolis)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。
|