https://www.jianshu.com/p/ab8fd26014c1
作者:vivo 互联网服务器团队- Wei Qianzi、Li Haoxuan
在 Java 发展历程中,JNI 一直都是一个不可或缺的角色,但是在实际的项目开发中,JNI 这项技术应用的很少。在笔者经过艰难的踩坑之后,终于将 JNI 运用到了项目实战,本文笔者将简单介绍 JNI 技术,并介绍简单的原理和性能分析。通过分享我们的实践过程,带各位读者体验 JNI 技术的应用。
计算密集型场景中,Java 语言需要花费较多时间优化 GC 带来的额外开销。并且在一些底层指令优化方面,C++ 这种“亲核性”的语言有着较好的优势和大量的业界实践经验。那么作为一个多年的 Java 程序员,能否在 Java 服务上面运行 C++ 代码呢?答案是肯定的。
JNI (Java Native Interface) 技术正是应对该场景而提出的解决方案。虽然 JNI 技术让我们能够进行深度的性能优化,其较为繁琐的开发方式也不免让新人感到头疼。本文通过 step by step 的方式介绍如何完成 JNI 的开发,以及我们优化的效果和思考。
开始正文前我们可以思考三个问题:
为什么选择使用 JNI 技术?
如何在 Maven 项目中应用 JNI 技术?
JNI 真的好用吗?
JNI 的全称叫做 Java Native Interface ,翻译过来就是 Java 本地接口。爱看 JDK 源码的小伙伴会发现,JDK 中有些方法声明是带有 native 修饰符的,并且找不到具体实现,其实是在非 Java 语言上,这就是 JNI 技术的体现。
早在 JDK1.0 版本就已经有了 JNI,官方给 JNI 的定义是:
Java Native Interface (JNI) is a standard programming interface for writing Java native methods and embedding the Java virtual machine into native applications. The primary goal is binary compatibility of native method libraries across all Java virtual machine implementations on a given platform.
JNI 是一种标准的程序接口,用于编写 Java 本地方法,并且将 JVM 嵌入 Native 应用程序中。是为了给跨平台上的 JVM 实现本地方法库进行二进制兼容。
JNI 最初是为了保证跨平台的兼容性,而设计出来的一套接口协议。并且由于 Java 诞生很早,所以 JNI 技术绝大部分情况下调用的是 C/C++ 和系统的 lib 库,对其他语言的支持比较局限。随着时间的发展,JNI 也逐渐被开发者所关注,比如 Android 的 NDK,Google 的 JNA,都是对 JNI 的扩展,让这项技术能够更加轻松的被开发者所使用。
我们可以看一下在 JVM 中 JNI 相关的模块,如图 1:
在 JVM 的内存区域,Native Interface 是一个重要的环节,连接着执行引擎和运行时数据区。本地接口 (JNI) 的方法在本地方法栈中管理 native 方法,在 Execution Engine 执行时加载本地方法库。
JNI 就像是打破了 JVM 的束缚,拥有着和 JVM 同样的能力,可以直接使用处理器中的寄存器,不仅可以直接使用处理器中的寄存器,还可以直接找操作系统申请任意大小的内存,甚至能够访问到 JVM 虚拟机运行时的数据,比如搞点堆内存溢出什么的:)
JNI 拥有着强大的功能,那它能做哪些事呢?官方文档给出了参考答案。
标准 Java 类库不支持应用程序所需的平台相关特性。
您已经有一个用另一种语言编写的库,并希望通过 JNI 使其可供 Java 代码访问。
您想用较低级别的语言(例如汇编)实现一小部分耗时短的代码。
当然还有一些扩充,比如:
不希望所写的 Java 代码被反编译;
需要使用系统或已有的 lib 库;
期望使用更快速的语言去处理大量的计算;
对图像或本地文件操作频繁;
调用系统驱动的接口。
或许还有别的场景,可以使用到 JNI,可以看到 JNI 技术有着非常好的应用潜力。
我们的业务中存在一个计算密集型场景,需要从本地加载数据文件进行模型推理。项目组在 Java 版本进行了几轮优化后发现没有什么大的进展,主要表现为推理耗时较长,并且加载模型时存在性能抖动。经过调研,如果想进一步提高计算和加载文件的速度,可以使用 JNI 技术去编写一个 C++ 的 lib 库,由 Java native 方法进行调用,预计会有一定的提升。
然而项目组目前也没有 JNI 的实践经验,最终性能是否能有提升,还是要打个问号。本着初生牛犊不怕虎的精神,我鼓起勇气主动认领了这个优化任务。下面就分享一下我实践 JNI 的过程和遇到的问题,给大家抛砖引玉。
实战就不从 Hello world 开始了,我们直接敲定场景,思考该让 C++ 实现哪部分逻辑。
场景如下:
在计算服务中,我们将离线计算数据转换成 map 结构,输入一组 key 在 map 中查找并对 value 应用算法公式求值。通过分析 JVM 堆栈信息和火焰图 (flame graph),发现性能瓶颈主要在大量的逻辑回归运算和 GC 上面,由于缓存了量级很大的 Map 结构,导致占用 heap 内存很大,因此 GC Mark-and-Sweep 耗时很长,所以我们决定将加载文件和逻辑回归运算两个方法改造为 native 方法。
代码如下:
/**
* 加载文件
* @param path 文件本地路径
* @return C++ 创建的类对象的指针地址
*/
public static native long loadModel(String path);
/**
* 释放 C++ 相关类对象
* @param ptr C++ 创建的类对象的指针地址
*/
public static native void close(long ptr);
/**
* 执行计算
* @param ptr C++ 创建的类对象的指针地址
* @param keys 输入的列表
* @return 输出的计算结果
*/
public static native float compute(long ptr, long[] keys);
那么,我们为什么要传递指针呢,并且设计了一个 close 方法呢?
便于兼容现有实现的考虑:虽然整个计算过程都在 C++ 运行时中进行,但对象的生命周期管理是在 Java 中实现的,所以我们选择回传加载并初始化后的模型对象指针,之后每次求值时仅传递该指针即可;
内存正确释放的考虑:利用 Java 自身的 GC 和模型管理器代码机制,在模型卸载时显式调用 close 方法释放 C++ 运行时管理的内存,防止出现内存泄漏。
当然,这个建议只适用于需要 lib 执行时将部分数据缓存在内存中的场景,只使用 native 方法进行计算,无需考虑这种情况。
下面简单介绍一下我们所使用的环境和项目结构,这部分介绍的不是很多,如果有疑问可以参考文末的参考资料或者在网上进行查阅。
我们使用的是简单的 maven 项目,使用 Docker 的 ubuntu-20.04 容器进行编译和部署,需要在容器中安装 GCC,Bazel,Maven,openJDK-8 等。如果是在 Windows 下进行开发,也可以安装相应的工具并编译成 .dll 文件,效果是一样的。
我们创建好 maven 项目的目录,如下:
/src # 主目录
-/main
--/cpp # c++ 仓库目录
---export_jni.h # java 导出的文件
---computer.cc # 具体的 C++ 代码
---/third_party # 三方库
---WORKSPACE # bazel 根目录
---BUILD # bazel 构建文件
--/java # java 仓库目录
---/com
----/vivo
-----/demo
------/model
-------ModelComputer.java # java 代码
--/resources # 存放 lib 的资源目录
-/test
--/java
----ModelComputerTest.java # 测试类
pom.xml # maven pom
都已经准备好了,那么就直入正题:
package com.vivo.demo.model;
import java.io.*;
public class ModelComputer implements Closeable {
static {
// 加载 lib 库
loadPath("export_jni_lib");
}
/**
* C++ 类对象地址
*/
private Long ptr;
public ModelComputer(String path) {
// 构造函数,调用 C++ 的加载
ptr = loadModel(path);
}
/**
* 加载 lib 文件
*
* @param name lib名
*/
public static void loadPath(String name) {
String path = System.getProperty("user.dir") + "\\src\\main\\resources\\";
path += name;
String osName = System.getProperty("os.name").toLowerCase();
if (osName.contains("linux")) {
path += ".so";
} else if (osName.contains("windows")) {
path += ".dll";
}
// 如果存在本文件,直接加载,并返回
File file = new File(path);
if (file.exists() && file.isFile()) {
System.load(path);
return;
}
String fileName = path.substring(path.lastIndexOf('/') + 1);
String prefix = fileName.substring(0, fileName.lastIndexOf(".") - 1);
String suffix = fileName.substring(fileName.lastIndexOf("."));
// 创建临时文件,注意删除
try {
File tmp = File.createTempFile(prefix, suffix);
tmp.deleteOnExit();
byte[] buff = new byte[1024];
int len;
// 从jar中读取文件流
try (InputStream in = ModelComputer.class.getResourceAsStream(path);
OutputStream out = new FileOutputStream(tmp)) {
while ((len = in.read(buff)) != -1) {
out.write(buff, 0, len