跨语言编程的探索 | 龙蜥技术
跨语言编程是现代程序语言中非常重要的一个方向,也被广泛应用于复杂系统的设计与实现中。本文是 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 应用调用。在实践过程中,遇到的两个显著问题:
封装 SDK 的工作非常繁琐,尤其对于像 Grape 这样依赖模板的库,在初期基于手动的封装操作经常出错
运行时性能远低于 C++ 应用
为了解决这些问题,两个团队展开了合作,Alibaba FFI 项目正式开始演进,该项目的实现目前也主要针对 Java 调用 C++ 场景。
Java 跨语言编程技术
下面介绍一些在工业界中相对成熟、应用较多的 Java 跨语言调用技术。
Java Native Interface
谈到 Java 跨语言编程,首先不得不提的就是 Java Native Interface,简称 JNI。后面提到的 JNA/ JNR、JavaCPP 技术都会依赖 JNI。首先,通过两个例子来简单回顾一下。
控制台输出的例子
通过 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 方法可以被重载)
b. 通过 javah 或者 javac -h (JDK 10)命令生成后续步骤依赖的头文件(该头文件可以被 C 或者 C++ 程序使用)
c. 实现头文件中的函数,在这里我们直接使用 printf 函数在控制台输出 “hello ffi”
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,在下面代码示例中省略了初始化虚拟机的步骤,仅给出最终实现调用的两个步骤。
示例中首先通过 GetStaticMethodID 获取方法的 “id”,之后通过 CallStaticVoidMethod 实现方法的调用,这两个函数都是 JNI function。
前面我们提到过,当我们 java <Main Class> 运行 Java 程序时是其他语言调用 Java 语言的场景,事实上 Java 命令在实现上就是应用类似上述代码的流程完成主类 main 方法的调用。顺带提一点,我们日常研发过程中常用的一些诊断命令,比如 jcmd、jmap、jstack,和 java 命令是同一份源码实现(可以从图中看出这几个二进制文件的大小差不多),只是在构建过程中使用了不同的构建参数。
那么 JNI 到底是什么呢?以下是我的理解。
首先,JNI 是 Java 跨语言访问的接口规范,主要面向 C、C++、Assembly(为什么没有其他语言?我个人认为是由于这几种语言在当时规范设计之初足以覆盖绝大部分场景)
规范本身考虑了主流虚拟机的实现(hotspot),但本身不和任何具体的实现绑定,换句话说,Java 程序中跨语言编程的部分理论上可以跑在任何实现这个规范的虚拟机上
规范定义了其他语言如何访问 Java 对象、类、方法、异常,如何启动虚拟机,也定义了 Java 程序如何调用其他语言(C、C++、Assembly)
在具体使用和实际运行效果的表现用一句话总结:Powerful, but slow, hard to use, and error-prone
Java Native Access & Java Native Runtime
通过前面对 Java Native Interface 的介绍,我们可以认识到使用 JNI 技术实现 Java 方法调用 C 语言的步骤是非常麻烦的,因此为了降低 Java 跨语言编程(指 Java 调用 C/C++ 程序)的难度,开源社区诞生了 Java Native Access(JNA) 和 Java Native Runtime(JNR)这两个项目。本质上,这两个技术底层仍然是基于 JNI,因此在运行时性能上不会优于 JNI。
通过 JNA/JNR 进行 C/C++ 程序的封装,开发者就不需要主动生成或者编写底层的胶水代码,快速地实现跨语言的调用。此外两者还提供了其他优化,比如 Crash Protection(后文会有介绍)等。在实现上,JNR 会动态生成一些 Stub 优化运行时的性能。
JNA/JNR 和 JNI 的关系如下入:
下面是 JNR 官方给出的示例。首先创建 LibC 接口封装目标 C 函数,然后调用 LibraryLoader 的 API 创建 LibC 的具体实例,最后通过接口完成调用:
遗憾的是,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 的的示例:
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 (解决编程难度问题)
一套 Java 注解和类型
包含一个注解处理器,用于生成胶水代码
运行时支持
b. LLVM4JNI(解决运行时开销问题)
实现 bitcode 到 bytecode 的翻译,打破 Java 方法与 Native 函数的边界
基于 FFI 的纯 Java 接口定义,底层依赖 LLVM,通过 FFI 访问 LLVM 的 C++ API
目前 Alibaba FFI 主要针对 C++ ,下文也主要以 C++ 作为目标通信语言。
通过 Alibaba FFI 进行跨语言编程的 workflow:
1、包含用户需要使用的 C++ API 声明的头文件
2、用 Java 语言封装 C++ API,目前这个步骤仍需要手动进行,在未来我们会提供 SDK,用户仅需手动编写配置文件即可生成这部分代码
3、通过 FFI 中的注解处理器生成的胶水代码:包括 Java 层和 native 层的代码
4、库的具体实现
5、Clinet 应用在运行阶段会 load 上述过程的产物
注:实线表示运行前阶段源码与产物之间的关系,虚线表示运行阶段应用与库和产物之间的关系
FFI
FFI 模块提供了一套注解和类型,用于封装其他语言的接口,可以在下图中看到最顶层是一个 FFIType(FFI -> Foreign function interface)接口。
在面向 C++ 的具体实现中,一个底层的 C++ 对象会映射到一个 Java 对象,因此需要在 Java 对象中包含 C++ 对象的地址。由于 C++ 对象不会被移动,所以我们可以在 Java 对象中直接保存裸指针。
本质上 FFI 模块是通过注解处理器生成跨语言调用中需要的相关代码,用户仅需要依赖 FFI 的相关库(插件),并用 FFI 提供的 api 封装需要调用的目标函数即可。
示例
下面是一个封装 std::vector 的过程。
a. 通过注解和类型封装需要调用的底层函数
FFIGen:指定最终生成库的名称
CXXHead:胶水代码中依赖的头文件
FFITypeAlias:C++ 的类名
CXXTemplate:实现 C++ 模板参数具体类型到 Java 类型的映射,相对于 JavaCPP,Alibaba FFI 提供了更灵活的配置
b. 编译过程中,注解处理器会生成最终调用过程中依赖的组件
接口的真实实现:
JNI 的胶水代码:
Crash Protection
在演进过程中,我们引入了一些优化机制,比如针对 C++ 函数返回临时对象的处理、异常的转换等。在这里介绍一下 Crash Protection,也是针对客户在实际场景遇到的问题的解决方案,在 JNA 和 JNR 中也有相应的处理。
有时候,Java 应用依赖的 C++ 库需要进行版本升级,为了防止 C++ 库中的 Bug 导致整个应用 Crash(对于 Java 中的 Bug 通常会表现为异常,多数情况下不会导致应用整体出现问题),我们需要引入保护机制。
如下:
在第 3 行会出现内存访问越界的问题,如果不做特殊处理应用会 Crash。为了”隔离“这个问题,我们引入在保护机制,以下是 Linux 上的实现:
通过实现自己的信号处理函数和 sigsetjmp/siglongjmp 机制来实现 Crash 的保护,需要注意的是由于 Hotspot 有自定义的信号处理器(safepoint check,implicit null check 等),为了防止冲突,需要在启动是 preload libjsig.so(Linux 上)这个库。最后在 handle_crash 中我们可以抛出 Java 异常供后续排查分析。
相关项目的对比
LLVM4JNI
LLVM4JNI 实现了 bitcode 到 bytecode 的翻译,这样一个 Native 函数就是被转换成一个 Java 方法,从而消除前面提到的一系列开销问题。
翻译过程是在应用运行前完成的,其核心就是将 bitcode 的语义用 bytecode 来实现,本文不会介绍具体的实现细节(待项目开源后做详细介绍)。下面演示几例简单过程的翻译结果。
1、简单的四则运算:
source
bitcode
bytecode
2、JNI Functions 的转换,目前已经支持 90+ 个。未来该功能会和 fbjni 等类似框架集成,打破 Java 和 Native 的代码边界,消除方法调用的额外开销。
source
bytecode
3、C++ 对象访问。Alibaba FFI 的另外一个好处是可以以面向对象的方式(C++是面向对象语言)来开发 Java off-heap 应用。当前基于 Java 的大数据平台大多需要支持 off-heap 的数据模块来减轻垃圾回收的压力,然而人工开发的 off-heap 模块需要小心仔细处理不同平台和架构的底层偏移和对齐,容易出错且耗时。通过 Aliabba FFI,我们可以采用 C++开发对象模型,再通过 Alibaba FFI 暴露给 Java 用户使用。
source
bytecode
JavaRuntime
在访问 C++ 对象的字段实现中,我们使用 Unsafe API 完成堆外内存的直接访问,从而避免了 Native 方法的调用。
性能数据
Grape 在应用 Alibaba FFI 实现的 SSSP(单源最短路径算法)的性能数据如下:
这里比较三种模式:
纯粹的 C++ 实现
基于 Aibaba FFI 的 Java 实现,但是关闭 LLVM4JNI,JNI 的额外开销没有任何消除
基于 Alibaba FFI 的 Java 实现,同时开启 LLVM4JNI,一些 native 方法的额外开销被消除
这里我们以算法完成的时间(Job Time)为指标,将最终结果以 C++ 的计算时间为单位一做归一化处理。
结语
跨语言编程是现代编程语言的一个重要方向,在社区中存在许多方案来实现针对不同语言的通信过程。
Alibaba FFI 目前主要针对 C++,在未来我们会尝试 Java 与其他语言通信过程的实现与优化,项目也会正式开源,欢迎大家持续关注。
加入 SIG
欢迎更多开发者加入 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
加入我们,一起打造面向未来的开源操作系统!
版权声明: 本文为 InfoQ 作者【OpenAnolis小助手】的原创文章。
原文链接:【http://xie.infoq.cn/article/3444522c1bde130f265fc4fca】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论