作者: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:为什么会抉择它?
2.1 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:
图 1 – JVM 内存和引擎执行关系
在 JVM 的内存区域,Native Interface 是一个重要的环节,连贯着执行引擎和运行时数据区。本地接口 (JNI) 的办法在本地办法栈中治理 native 办法,在 Execution Engine 执行时加载本地办法库。
JNI 就像是突破了 JVM 的解放,领有着和 JVM 同样的能力,能够间接应用处理器中的寄存器,不仅能够间接应用处理器中的寄存器,还能够间接找操作系统申请任意大小的内存,甚至可能拜访到 JVM 虚拟机运行时的数据,比方搞点堆内存溢出什么的:)
2.2 JNI 的性能
JNI 领有着弱小的性能,那它能做哪些事呢?官网文档给出了参考答案。
- 规范 Java 类库不反对应用程序所需的平台相干个性。
- 您曾经有一个用另一种语言编写的库,并心愿通过 JNI 使其可供 Java 代码拜访。
- 您想用较低级别的语言(例如汇编)实现一小部分耗时短的代码。
当然还有一些裁减,比方:
- 不心愿所写的 Java 代码被反编译;
- 须要应用零碎或已有的 lib 库;
- 冀望应用更疾速的语言去解决大量的计算;
- 对图像或本地文件操作频繁;
- 调用零碎驱动的接口。
或者还有别的场景,能够应用到 JNI,能够看到 JNI 技术有着十分好的利用后劲。
三、JNI 实战:探索踩坑的全过程
咱们的业务中存在一个计算密集型场景,须要从本地加载数据文件进行模型推理。项目组在 Java 版本进行了几轮优化后发现没有什么大的停顿,次要体现为推理耗时较长,并且加载模型时存在性能抖动。通过调研,如果想进一步提高计算和加载文件的速度,能够应用 JNI 技术去编写一个 C++ 的 lib 库,由 Java native 办法进行调用,预计会有肯定的晋升。
然而项目组目前也没有 JNI 的实践经验,最终性能是否能有晋升,还是要打个问号。本着初生牛犊不怕虎的精力,我鼓起勇气被动认领了这个优化工作。上面就分享一下我实际 JNI 的过程和遇到的问题,给大家抛砖引玉。
3.1 场景筹备
实战就不从 Hello world 开始了,咱们间接敲定场景,思考该让 C++ 实现哪局部逻辑。
场景如下:
图 2 实战场景
在计算服务中,咱们将离线计算数据转换成 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 办法进行计算,无需思考这种状况。
3.2 环境搭建
上面简略介绍一下咱们所应用的环境和我的项目构造,这部分介绍的不是很多,如果有疑难能够参考文末的参考资料或者在网上进行查阅。
咱们应用的是简略的 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
3.3 实战过程
都曾经筹备好了,那么就直入正题:
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);
}
}
// 加载库文件
System.load(tmp.getAbsolutePath());
} catch (Exception e) {throw new RuntimeException();
}
}
// native 办法
public static native long loadModel(String path);
public static native void close(long ptr);
public static native float compute(long ptr, long[] keys);
@Override
public void close() {
Long tmp = ptr;
ptr = null;
// 敞开 C++ 对象
close(tmp);
}
/**
* 计算
* @param keys 输出的列表
* @return 输入的后果
*/
public float compute(long[] keys) {return compute(ptr, keys);
}
}
- 踩坑 1:启动时报 java.lang.UnsatisfiedLinkError 异样
这是因为 lib 文件在压缩包中,而加载 lib 的函数寻找的是零碎门路下的文件,通过 InputStream 和 File 操作从压缩包中读取该文件到长期文件夹,获取其门路,再进行加载就能够了。上文中 getPath 办法作为解决办法的示例能够参考:System.load() 函数输出的门路必须是全门路下的文件名,也能够应用 System.loadLibrary() 加载 java.library.path 下的 lib 库,不须要 lib 文件的后缀。
保留上文的 Java 代码,通过 Javah 指令能够生成对应的 C++ 头文件,前文目录构造中的 export_jni.h 就是通过该指令生成的。
javah -jni -encoding utf-8 -classpath com.vivo.demo.model.ModelComputer -o ../cpp/extern_jni.h
# -classpath 示意所在的 package
# -d 示意输入的文件名
关上能够看到生成进去的文件如下:
#include <jni.h> // 引入的头文件,该头文件在 $JAVA_HOME/include 下,随 Java 版本变动而扭转
#ifndef _Included_com_vivo_demo_model_ModelComputer // 宏定义 格局 _Included_包名_类名
#define _Included_com_vivo_demo_model_ModelComputer
#ifdef __cplusplus
extern "C" { // 保障函数、变量、枚举等在所有的源文件中保持一致,这里利用于导出的函数名称不被扭转
#endif
// 生成的 loadModel 函数,能够看到 JNI 的润饰和 jlong 返回值,函数名称格局为 Java_包名_类名_函数名
// 函数的前两个参数是 JNIEnv 示意以后线程的 JVM 环境参数,jclass 示意调用的 class 对象,能够通过这两个参数去操作 Java 对象。JNIEXPORT jlong JNICALL Java_com_vivo_demo_model_ModelComputer_loadModel
(JNIEnv *, jclass, jstring);
JNIEXPORT void JNICALL Java_com_vivo_demo_model_ModelComputer_close
(JNIEnv *, jclass, jlong);
JNIEXPORT jfloat JNICALL Java_com_vivo_demo_model_ModelComputer_compute
(JNIEnv *, jclass, jlong, jlongArray);
#ifdef __cplusplus
}
#endif
#endif
- 踩坑 2:Javah 运行失败
如果生成失败,能够参考下面 JNI 格局的“.h”文件手写一个进去,只有格局无误,成果是一样的。其中 jni.h 是 JDK 门路下的一个文件,外面定义了一些 JNI 的类型,返回值,异样,JavaVM 构造体以及一些办法(类型转化,字段获取,JVM 信息获取等)。jni.h 还依赖了一个 jni_md.h 文件,其中定义了 jbyte,jint 和 jlong,这三个类型在不同的机器下的定义是有差别的。
咱们能够看下 JNI 罕用数据类型与 Java 的对应关系:
图 3 JNI 罕用数据类型
如图 3,JNI 定义了一些根本数据类型和援用数据类型,能够实现 Java 和 C++ 的数据转化。JNIEnv 是一个指向本地线程数据的接口指针,艰深的来讲,咱们通过 JNIEnv 中的办法,能够实现 Java 和 C++ 的数据转化,通过它,能够使 C++ 拜访 Java 的堆内存。
对于根本的数据类型,通过值传递,能够进行强制转化,能够了解为只是定义的名称产生扭转,和 java 根本数据类型差别不大。
而援用数据类型,JNI 定义了 Object 类型的援用,那么就意味着,java 能够通过援用传递任意对象到 C++ 中。对于像根底类型的数组和 string 类型,如果通过援用传递,那么 C++ 就要拜访 Java 的堆内存,通过 JNIEnv 中的办法来拜访 Java 对象,尽管不须要咱们关怀具体逻辑,然而其性能耗费要高于 C++ 指针操作对象的。所以 JNI 将数组和 string 复制到本地内存(缓冲区)中,这样岂但进步了访问速度,还加重了 GC 的压力,毛病就是须要应用 JNI 提供的办法进行创立和开释。
// 能够应用下列三组函数,其中 tpye 为根本数据类型, 后两组有 Get 和 Release 办法,Release 办法的作用是揭示 JVM 开释内存
// 数据量小的时候应用此办法,原理是将数据复制到 C 缓冲区,调配在 C 堆栈上,因而只实用于大量的元素,Set 操作是对缓存区进行批改
Get<type>ArrayRegion
Set<type>ArrayRegion
// 将数组的内容拷贝到本地内存中,供 C++ 应用
Get<type>ArrayElement
Release<type>ArrayElement
// 有可能间接返回 JVM 中的指针,否则的话也会拷贝一个数组进去,和 GetArrayElement 性能雷同
GetPrimitiveArrayCritical
ReleasePrimitiveArrayCritical
通过这三组办法的介绍,也就大抵理解了 JNI 的数据类型转化,如果没有 C++ 创立批改 Java Object 的操作的话,那编写 C++ 代码和失常的 C++ 开发无异,上面给出了“export_jni.h”代码示例。
#include "jni.h" // 这里改为绝对援用,是因为把 jni.h 和 jni_md.h 拷贝到我的项目中,不便编译
#include "computer.cc"
#ifndef _Included_com_vivo_demo_model_ModelComputer
#define _Included_com_vivo_demo_model_ModelComputer
#ifdef __cplusplus
extern "C" {
#endif
JNIEXPORT jlong JNICALL Java_com_vivo_demo_model_ModelComputer_loadModel
(JNIEnv* env, jclass clazz, jstring path) {vivo::Computer* ptr = new vivo::Computer();
const char* cpath = env->GetStringUTFChars(path, 0); // 将 String 转为 char*
ptr->init_model(cpath);
env->ReleaseStringUTFChars(path, cpath); // 开释 String
return (long)ptr;
};
JNIEXPORT void JNICALL Java_com_vivo_demo_model_ModelComputer_close
(JNIEnv* env, jclass clazz, jlong ptr) {vivo::Computer* computer = (vivo::Computer*)ptr; // 获取到对象
delete computer; // 删除对象
};
JNIEXPORT jfloat JNICALL Java_com_vivo_demo_model_ModelComputer_compute
(JNIEnv* env, jclass clazz, jlong ptr, jlongArray array) {jlong* idx_ptr = env->GetLongArrayElements(array, NULL); // 将 array 转为 jlong*
vivo::Computer* computer = (vivo::Computer*)ptr; // 获取到 C++ 对象
float result = computer->compute((long *)idx_ptr); // 执行 C++ 办法
env->ReleaseLongArrayElements(array, idx_ptr, 0); // 开释 array
return result; // 返回后果
};
#ifdef __cplusplus
}
#endif
#endif
C++ 代码编译实现后,把 lib 文件放到 resource 目录指定地位,如果为了不便,能够写个 shell 脚本一键执行。
- 踩坑 3:服务器启动时报 java.lang.UnsatisfiedLinkError 异样
又是这个异样,前文曾经介绍了一种解决方案,但在理论利用中依然频繁呈现,比方:
- 运行环境有问题(比方在 linux 下编译在 windows 上运行,这是不能够的);
- JVM 位数和 lib 的位数不统一(比方一个是 32 位,一个是 64 位);
- C++ 函数名写错;
- 生成的 lib 文件中并没有绝对应的办法。
对于这些问题,只有认真剖析异样日志,便能够逐个解决,也有工具能够帮助咱们解决问题。
应用 dumpbin/objdump 剖析 lib,更疾速地解决 UnsatisfiedLinkError。
对于 lib 库中的函数查看,不同操作系统也提供了不同的工具。
在 windows 下,能够应用 dumpbin 工具或者 Dependency Walker 工具剖析 lib 中是否存在所编写的 C++ 办法。dumpbin 指令如下:
dumpbin /EXPORTS xxx.dll
图 4 dumpbin 查看 dll 文件
而 Dependency Walker 只须要关上 dll 文件就能够看到相干信息了。
图 5 Dependency Walker 查看 dll 文件
在 Linux 下,能够应用 objdump 工具剖析 so 文件中的信息。
objdump 指令如下:
objdump -t xxx.so
图 6 objdump 查看 so 文件
3.4 性能剖析
依据之前的调研,咱们留神到 Java 对 native 办法的调用自身也存在额定性能开销,针对此咱们用 JMH 进行了简略测试。图 7 展现的是 JNI 空办法调用和 Java 的比照:
图 7 – 空函数调用比照(数据源自集体机器 JMH 测试,仅供参考)
其中 JmhTest.code 为调用 native 空办法,JmhTest.jcode 为调用 java 空办法,从中能够看出,间接调用 java 的办法要比调用 native 办法快十倍还要多。咱们对堆栈调用进行了简略剖析,发现调用 native 的过程比间接调用 java 办法要繁琐一些,进入了 ClassLoad 的 findNative 办法。
// Invoked in the VM class linking code.
// loader 为类加载器,name 为 C ++ 办法的 name,eg: Java_com_vivo_demo_model_ModelComputer_compute
static long findNative(ClassLoader loader, String name) {
// 抉择 nativeLibary
Vector<NativeLibrary> libs =
loader != null ? loader.nativeLibraries : systemNativeLibraries;
synchronized (libs) {int size = libs.size();
for (int i = 0; i < size; i++) {NativeLibrary lib = libs.elementAt(i);
// 找到 name 持有的 handel
long entry = lib.find(name);
if (entry != 0)
// 返回 handel
return entry;
}
}
return 0;
}
堆栈信息如下:
图 8 调用 native 堆栈信息
find 办法是一个 native 办法,堆栈上也打印不出相干信息,但不难得出,通过 find 办法去调用 lib 库中的办法,还要再通过至多一轮的映射能力找到对应的 C++ 函数执行,而后将后果返回。霎时回想起图一,这种调用链路,通过 Native Interface 来串起本地办法栈,虚拟机栈,nativeLibrary 和执行引擎之间的关系,逻辑势必会简单一些,绝对的调用耗时也会减少。
做了这么多工作,差点忘了咱们的指标:进步咱们的计算和加载速度。通过上文的优化后,咱们在压测环境进行了全链路压测,发现即便 native 的调用存在额定开销,全链路的性能依然有了较为显著的晋升。
咱们的服务在模型推理的外围计算上耗时升高了 80%,加载和解析模型文件耗时也升高了 60%(分钟级到秒级),GC 的均匀耗时也升高了 30%,整体的收益非常明显。
图 9 young GC 耗时比照
四、思考和总结:JNI 带来的收益
JNI 在一些特定场景下的胜利利用关上了咱们的优化思路,尤其是在 Java 上进行了较多优化尝试后并没有停顿时,JNI 的确值得一试。
又回到了最后的问题:JNI 真的好用吗?我的答案是:它并不是很好用。如果是一名很少接触 C++ 编程的工程师,那么在第一步的环境搭建和编译上,就要消耗大量的工夫,再到后续的代码保护,C++ 调优等等,是一个十分头疼的事件。但我还是十分举荐去理解这项技术和这项技术的利用,去思考这项技术可能给本人的服务器性能带来晋升。
或者有一天,JNI 能为你所用!
参考资料:
- Oracle JNI Guide: Java Native Interface
- bazel 概述
- docker hub
- JMH GitHub
- Dumpbin refrence