JNI技术经验总结
JNI是Java Native Interface的缩写,为Java程序提供与本地程序交互的能力。使用JNI技术,能够使得Java程序充分利用本地代码的优势,如高性能,不必重复造轮子等,在生产中,有着诸多实用价值。
JNI工作流程
JNI的典型使用场景是:Java程序调用C,C++代码编译而成的动态库文件。动态库文件在Windows下是.dll文件,在Linux下为.so文件。其主要工作流程如图所示:
- 编写.java源代码,其中用native关键字标明需要本地实现的函数。
- 使用命令
javac -h <输出位置> <源代码路径>
编译出.h头文件。 - 编写.cpp源代码实现.h文件中声明的native函数。
- 编译.h头文件和.cpp源文件生成.dll动态库,并移动到.java源代码中指定的位置。
- 使用命令
javac <源代码路径>
将.java源代码编译成.class字节码。 - 使用命令
java <类名>
执行类名.class文件,调用.dll库文件得到输出。
Hello, world!
这里实现一个简单的Windows版本的demo,带读者来熟悉上述流程。
- 编写Hello.java源代码
public class Hello {
// native 关键字声明native函数
native void sayHelloToC();
// 加载 java2c.dll库文件
static { System.loadLibrary("java2c");}
public static void main(String []args){
Hello hello = new Hello();
hello.sayHelloToC();
}
}
- 在Hello.java所在目录,打开cmd命令行,编译出Hello.h头文件。(也一并编译出了Hello.class文件)
javac -encoding utf-8 -h . Hello.java
utf-8防止中文注释乱码,. 表示输出到当前路径下。
- 为了简化较长的.dll编译命令,此处我们借助 Dev-C++ 开发工具来完成C++代码的编写和编译。
- 打开Dev-C++,新建dll项目,将自动生成dll.h和dllmain.cpp两个文件。
- 将dll.h的内容替换为Hello.h。
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class Hello */
#ifndef _Included_Hello
#define _Included_Hello
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: Hello
* Method: sayHelloToC
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_Hello_sayHelloToC
(JNIEnv *, jobject);
#ifdef __cplusplus
}
#endif
#endif
- 清空dllmain.cpp中的内容,修改为如下
#include"dll.h"
#include<stdio.h>
// 实现dll.h中声明的函数
JNIEXPORT void JNICALL Java_Hello_sayHelloToC
(JNIEnv * env, jobject obj){
printf("Hello, C! I am Java.");
}
- 编译生成.dll库文件。
dll.h的第一行,引入了jni.h头文件。在java安装目录下的include文件夹,找到jni.h文件和jni_md.h。将它们复制到dev-c++项目的 工具->编译选项->目录->C++包含文件 中的任一目录下。 - 点击编译,生成hello_c++.dll文件(hello_c++为项目名),将其重命名为java2c.dll(与1中的java源代码保持一致),移动到Hello.java所在的文件夹。
- 编译Hello.java生成Hello.class字节码,其实在第2步已经顺带完成。
javac -encoding utf-8 Hello.java
- 在Hello.class目录下,打开cmd命令行,执行字节码程序。
java Hello
以上实现一个简单的demo,完成Java程序调用C++编写的动态库的目标。通过JNI技术还可以实现Java程序和C/C++程序间的数据传递,下面将一一陈述。
基本数据
Java类型 | JNI类型 | 描述 |
boolean | jboolean | unsigned 8 bits |
byte | jbyte | signed 8 bits |
char | jchar | unsigned 16 bits |
short | jshort | signed 16 bits |
int | jint | signed 32 bits |
long | jlong | signed 64 bits |
float | jfloat | 32 bits |
double | jdouble | 64 bits |
void | void | void |
JNI的基本数据类型与Java类型的对照表如上,下面演示传递基本类型的数据。
Hello.java中添加:
native double average(int x, int y);
Hello.h中新增:
/*
* Class: Hello
* Method: average
* Signature: (II)D
*/
JNIEXPORT jdouble JNICALL Java_Hello_average
(JNIEnv *, jobject, jint, jint);
java编译程序自动添加了三行注释,Class表示类名,Method表示方法名,Signature是java的函数签名,告诉我们函数的参数类型和返回类型。参数是两个Interger,返回是Double。
函数体代码中,函数返回值为jdouble与java中的double对映,函数名为程序自动生成的Java _类名 _方法名形式,JNIEnv参数为jni环境,jobject为native函数所在的java对象,这两个是jni自带的。后面两个jint参数对应原本java方法的int参数。
在dllmain.cpp中实现average的逻辑:
JNIEXPORT jdouble JNICALL Java_Hello_average
(JNIEnv * env, jobject obj, jint x, jint y){
return jdouble(x + y) / 2;
}
执行输出:
System.out.println(hello.average(1, 2));
// 1.5
字符串
JNI的字符串处理函数丰富全面,此处演示传递字符串的简单例子,并以此引出JNI处理复杂数据类型时的一般流程。更多API文件见参考链接的官方文档。
Hello.java中添加:
native String sendMessage(String msg);
Hello.h中生成:
/*
* Class: Hello
* Method: sendMessage
* Signature: (Ljava/lang/String;)Ljava/lang/String;
*/
JNIEXPORT jstring JNICALL Java_Hello_sendMessage
(JNIEnv *, jobject, jstring);
dllmain.cpp中实现:
JNIEXPORT jstring JNICALL Java_Hello_sendMessage
(JNIEnv * env, jobject obj, jstring msg){
// 1.convert jstring to cstring
const char* cMsg = env->GetStringUTFChars(msg, NULL);
if(NULL == cMsg) return NULL;
// 2.use cstring
printf("Java: %s\n", cMsg);
// 3.release resources
env->ReleaseStringUTFChars(msg, cMsg);
// 4.return
const char* cMsg2 = "Good afternoon! Miss.Java.";
return env->NewStringUTF(cMsg2);
}
注意:JNI中需要手动显式地释放资源,否则会造成内存泄漏。
执行输出:
String msg = "Good morning! Mr.C++.";
System.out.println("C++: " + hello.sendMessage(msg));
// Java: Good morning! Mr.C++.
// C++: Good afternoon! Miss.Java.
对象
JNI同样可以传递对象,也就是说,JNI让C/C++代码能够访问Java类中的成员变量和方法。
从这里开始,我们使用Maven来管理我们的代码。
新建maven项目,其目录结构如下:
Hello.java的代码如下
package org.example;
public class Hello {
static {
System.loadLibrary("java2c");
}
private int num = 2021;
private void printNum(){
System.out.println("In Java, num is " + num);
}
// 演示访问成员变量和方法
native void jniMethod();
public static void main(String []args){
Hello hello = new Hello();
hello.jniMethod();
}
}
此处引入了java包,且Hello中导入了Color类,所以如果直接在Hello.java同级文件夹下调用javac命令,会报找不到符号的错误,应在所有源文件的根目录,即maven项目的java文件夹下,调用javac命令。这样编译器能将包名和路径对应起来。
进入src\main\java路径,运行:
javac -encoding utf-8 -h . ./org/example/Hello.java
如果源文件还引入了其他位置的包,如test文件夹下的源文件引入了main文件夹下的包,可使用javac -cp <依赖路径>
告知java编译器依赖包的位置。更多javac选项,可使用javac -help
自行查看。
Hello.h中生成:
/*
* Class: org_example_Hello
* Method: jniMethod
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_org_example_Hello_jniMethod
(JNIEnv *, jobject);
dllmain.h中实现定义的函数:
JNIEXPORT void JNICALL Java_org_example_Hello_jniMethod
(JNIEnv* env, jobject obj){
jclass cls = env->GetObjectClass(obj);
// fieldID is certain for a class
jfieldID numField = env->GetFieldID(cls, "num", "I");
// get number
jint num = env->GetIntField(obj, numField);
printf("In C++, num is %d\n", num);
// change number
env->SetIntField(obj, numField, 2035);
// call method
jmethodID printNumField = env->GetMethodID(cls, "printNum", "()V");
env->CallVoidMethod(obj, printNumField);
return;
}
执行编译,在maven项目中编译执行的路径不再是源文件所在的路径。为简单起见,我们将生成的dll文件放到系统环境变量中,以便java虚拟机能够找到。执行输出得到:
public static void main(String []args){
Hello hello = new Hello();
hello.jniMethod();
}
// In Java, num is 2035
// In C++, num is 2021
通过JNI,获取对象的成员变量或调用对象的主要过程如下:
-
GetObjectClass
通过jobject获得jclass,或者FindClass
通过类名直接获得jclass - 通过jclass获得jfieldID获得jmethodID。
- 使用jfieldID获取成员变量的值或使用jmethodID调用对象的方法。
dllmain.cpp中的"I"、"()V"是java的签名字符串,可以在终端通过javap
命令获取。
数组
jni也可以传递基本类型的数组和对象数组,此处通过向量加法来演示此功能。
Hello.java中新增:
native void vectorAdd(int[] a, int[] b, int[] c);
public static void main(String []args){
Hello hello = new Hello();
int []a = {1, 2, 3};
int []b = {4, 5, 6};
int []c = new int[3];
hello.vectorAdd(a, b, c);
for(int i:c){
System.out.print(i + " ");
}
javac -h
重新编译后,Hello.h中生成:
/*
* Class: org_example_Hello
* Method: vectorAdd
* Signature: ([I[I[I)V
*/
JNIEXPORT void JNICALL Java_org_example_Hello_vectorAdd
(JNIEnv *, jobject, jintArray, jintArray, jintArray);
在dllmain.h中实现上述函数:
JNIEXPORT void JNICALL Java_org_example_Hello_vectorAdd
(JNIEnv *env, jobject obj, jintArray a, jintArray b, jintArray c){
// 1.convert JNI jintArray to C jint[]
jint *cA = env->GetIntArrayElements(a, NULL);
jint *cB = env->GetIntArrayElements(b, NULL);
jint *cC = env->GetIntArrayElements(c, NULL);
jint len = env->GetArrayLength(a);
// 2.use array
for(jint i=0; i<len; i++){
cC[i] = cA[i] + cB[i];
}
// 3.release resources
env->ReleaseIntArrayElements(a, cA, JNI_ABORT);
env->ReleaseIntArrayElements(b, cB, JNI_ABORT);
env->ReleaseIntArrayElements(c, cC, 0);
return;
}
函数依旧分为:转化数据、使用数据、释放资源三步。
Release函数第三个参数为mode,有三个备选值如下:
mode | 行为 |
0 | copy back the content and free the elems buffer |
JNI_COMMIT | copy back the content but do not free the elems buffer |
JNI_ABORT | free the buffer without copying back the possible changes |
编译后执行得输出如下:
5 7 9
对象数组与基本类型数组的方法相似,但是没有Get<PrimitiveType>
ArrayElements对数组元素进行批量转化,究其原因,Java对象不能直接转化为C/C++的对象。
使用如下两个函数,可以操作基本数据类型数组的直接指针,极大的加快程序运行的效率。但同时,我们要保证在调用ReleasePrimitiveArrayCritical函数之前,不能进行任何可能导致线程阻塞的操作。
void * GetPrimitiveArrayCritical(JNIEnv *env, jarray array, jboolean *isCopy);
void ReleasePrimitiveArrayCritical(JNIEnv *env, jarray array, void *carray, jint mode);
工程经验
c语言宏定义
观察javac -h
生成的函数声明,我们会发现都是如下结构:
Java_包名_方法名
JNIEXPORT void JNICALL Java_org_example_Hello_jniMethod(...)
JNIEXPORT void JNICALL Java_org_example_Hello_vectorAdd(...)
当程序越来越复杂,我们调整Java的包结构,每一个地方都要修改。可以使用C++的宏替换来减少这种冗余如下:
#define FULL_FUNC_NAME(SHORT_NAME) Java_org_example_Hello_##SHORT_NAME
JNIEXPORT void JNICALL FULL_FUNC_NAME(jniMethod)(...)
JNIEXPORT void JNICALL FULL_FUNC_NAME(vectorAdd)(...)
将dll动态库打入jar包
首先我们要明白Java加载动态库的方式:
- System.load
System.load加载绝对路径下的库文件,如:
System.load("D:\\workplace\\java2c.dll");
- System.loadLibrary
System.loadLibrary加载相对路径下的库文件,参数为库文件名,不包含库文件的扩展名,如:
System.loadLibrary("java2c");
这里java2c.dll必须在java.library.path这一jvm变量指向的路径中。
可以通过如下方法来获得该变量的值:
System.getProperty("java.library.path");
默认情况下,在Windows平台下,该值包含如下位置:
1)和jre相关的一些目录
2)程序当前目录
3)Windows目录
4)系统目录(system32)
5)系统环境变量path指定目录
上述两个函数都是以路径作为参数的,但是jar包中的文件没有路径,只能以文件流的形式获取。为了能将动态库文件打入jar包,并且能够顺利调用,我们的策略是将jar包中的动态库写入系统临时目录下,再调用System.load载入,参考代码如下:
static {
// copy .so from jar to syetem tmp dir
String libName = "java2c.dll";
String nativeTempDir = System.getProperty("java.io.tmpdir");
File extractedLibFile = new File(nativeTempDir + File.separator + libName);
InputStream in = null;
BufferedInputStream reader = null;
FileOutputStream writer = null;
if(!extractedLibFile.exists()){
try{
in = Level2.class.getClassLoader().getResourceAsStream(libName);
reader = new BufferedInputStream(in);
writer = new FileOutputStream(extractedLibFile);
byte[] buffer = new byte[1024];
while(reader.read(buffer) > 0){
writer.write(buffer);
}
}catch(IOException e){
e.printStackTrace();
}finally {
if (in != null){
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (writer != null){
try {
writer.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
System.load(extractedLibFile.toString());
}
参考链接