1. JNI简介
JNI - Java Native Interface,它是Java调用Native语言的一种特性。通过JNI可以在Java代码中调用C/C++语言的代码,同样也可以在C/C++代码中调用Java代码,这样可以发挥各语言的特性。特别是算法基本都是c/c++实现,这样确保运行的性能,而业务逻辑与交互则在Java层实现。JNI相当于担任了一个桥梁的角色,它将JVM与Native模块联系起来,从而实现了Java代码与Native代码的互访。
假定已有一份c++的人脸检测代码,现在我们想将它移植到手机上,从而实现Android版的人脸应用。这里就涉及JNI的知识,需将Java层视频或图片数据传递到native层,完成人脸检测后再将结果返给到Java层做相应的业务逻辑。要实现这个功能,大概要经历如下几步:
- 首先我们在java层定义好native方法,对应NativeMethod中的detect();
- 在JNI层,定义好与java对应的native方法 Jave_com_example_facedemo_NativeMethod_detect(),同时在该方法内再进一步调用c++实现的人脸检测方法;
- 在c/c++层,对应具体的人脸检测代码实现;
- 通过NDK将native代码编译成FaceDetection.so库文件;
- 完成android开发后整体打包成FaceDemo.apk可执行文件。
运行时,通过system.loadLibrary("FaceDetect")加载我们编译好的libFaceDetect.so库,通过detect方法将Java层的视频数据传递给Jni层,再调用c++的人脸检测模块。在这个过程中,Java层的detect函数到底是怎么和JNI层的detect函数建立关联的呢?这就涉及到动态注册和静态注册的概念,后续所有介绍都是基于静态注册的方式来实现的。
在JNI调用相关方法之前,需要对Java中以native关键字修饰的方法进行注册。通过注册,将指定的native方法和so中对应的方法绑定起来,这样就可以调用相应的so层的函数了。默认情况下,都会使用静态注册的方式,具有开发简单的优点,缺点是JNI函数名比较长。背后的运行原理大概为:
1)根据函数名将Java代码中的native方法与so中的JNI方法一一对应,当Java层调用so层的函数时,如果发现其上有JNIEXPORT和JNICALL两个宏定义申明时,就会将so层函数链接到对应的native方法上。
2)而native方法和so方法对应的规则是:以字符串 "Java" 为前缀,并且用 "_" 下划线将包名、类名以及native方法名链接起来就是对应的JNI函数名了。
在该例中,包名为com.example.facedemo,类名" 为NativeMethod,函数名为detect,则对应JNI的函数名为Java_com_example_facedemo_NativeMethod_detect。在JNI的detect函数中,除了图像数据和宽高这3个参数外,我们注意到其前面多了两个参数:JNIEnv *和jobject,它们是必需的。前者是一个JNIEnv 结构体的指针,这个结构体中定义了很多JNI的接口函数指针,使开发者可以使用JNI所定义的接口功能;后者指代的是调用这个JNI函数的Java对象,有点类似于C++中的this 指针。在上述两个参数之后,才是与Java层函数声明依次对应的参数。这时又引出下面将要介绍的内容,Java层和JNI层之间的数据定义是有差异的,那他们之间是如何对应的呢?
2. 数据类型
从上面人脸检测例子中可以看到,我们需要从Java层把视频数据当参数传递给c++层,同时底层完成人脸检测后需要把结果返回到Java层,需要清楚的一个问题是:Java编程语言中的数据类型与C/C++等本地编程语言中的数据类型是如何实现映射的。
JNI中定义了一组对应于Java编程语言中类型的C/C++类型。在Java编程语言中存在着两种类型:基本数据类型,如int、float和char,以及引用类型,如类、实例和数组。基本数据类型的映射是直接的,Java编程语言中的int类型映射为C/C++的jint(定义在jni.h中,为32位有符号整型数),Java编程语言中的float类型映射为C/C++的jfloat(定义在jni.h中,为32位浮点类型数),如下表所示。
Java类型 | Native类型 | 说明 |
boolean | jboolean | unsiged 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 |
JNI传递对象给本地方法作为不透明引用,不透明引用指的是引用Java虚拟机内部数据类型的C指针类型。本地代码可以通过JNIEnv接口指针指向的适当的函数来操作底层对象。例如,java.lang.String对应于JNI类型jstring,jstring引用的确切位置和本地代码是不相关的。JNI定义了一组在概念上是jobject的子类型的引用类型,这些子类型对应于Java编程语言中经常使用的引用类型。例如:jstring表示字符串,jobjectArray表示对象数据。
3. 访问数据
了解了Java与JNI之间数据类型的映射关系后,我们看下Jni层如何访问Java层传递的数据。对于基本的数据类型,比如int,float等,可以实现直接的访问;但对于String,数组等引用类型,就需要利用一系列函数来实现。
3.1 访问字符串
jstring类型在Java虚拟机代表着字符串,但不同于常规的C字符串(char *)。不能将jstring作为常规C字符串来使用,比如运行下面的代码不会得到期望的结果,可能会导致Java虚拟机崩溃。
JNIEXPORT jstring JNICALL Java_com_example_jnidemo_NativeMethod_nativeString
(JNIEnv *env,jobject thisz, jstring s)
{
/* ERROR: incorrect use of jstring as a char* pointer */
printf("%s", s);
...
}
我们通过下面的例子来说明如何通过JNI获取字符串内容,同时创建1个新的字符串返回。
JNIEXPORT jstring JNICALL Java_com_example_jnidemo_NativeMethod_nativeString
(JNIEnv *env,jobject thisz, jstring s)
{
const char *str = env->GetStringUTFChars(s,0);
if(str == NULL)
return NULL;
int len = env->GetStringUTFLength(s); //length of the string ,not include'\0'
//doing somthing about str
env->ReleaseStringUTFChars(s,str);
jstring strout = env->NewStringUTF("abc"); //create new string
return strout;
}
- 转换为本地字符串:通过调用JNI方法GetStringUTFChars来读取字符串的内容,它将通常在Java虚拟机中实现为Unicode序列的jstring引用转换为UTF-8格式的C式字符串。一定要检查GetStringUTFChars的返回值,这是因为Java虚拟机内部需要申请内存来容纳UTF-8字符串,内存的申请是有可能会失败的。如果内存申请失败,那么GetStringUTFChars将会返回NULL并且会抛出OutOfMemoryError异常。
-
释放本地字符串资源:使用完通过GetStringUTFChars获取的UTF-8字符串后,需要调用ReleaseStringUTFChars,表明本地代码不再需要GetStringUTFChars返回的UTF-8字符串了,调用ReleaseStringUTFChars就能够释放掉UTF-8字符串占用的内存。如果不调用该函数,则会产生内存泄漏,积累到一定程度最终导致内存耗尽。
- 创建新字符串:通过调用JNI函数NewStringUTF,你可以在本地代码中创建一个新的java.lang.String实例。NewStringUTF方法使用一个UTF-8格式的C式字符串作为参数并生成一个java.lang.String实例对象。如果虚拟机没办法申请足够的内存来构造java.lang.String实例的话,NewStringUTF会抛出一个OutOfMemoryError异常并返回NULL。
这里列出常见的几种JNI字符串函数:
JNI函数 | 描述 |
GetStringChars\ReleaseStringChars | 获取或释放指向Unicode格式的字符串内容指针 |
GetStringUTFChars\ReleaseStringUFTChars | 获取或释放指向UTF-8格式的字符串内容指针 |
GetStringLength | 返回字符串中Unicode字符的数量 |
GetStringUTFLength | 返回以UTF-8格式的字符串所需字节数,不包含尾数'\0' |
NewString | 创建Unicode格式的java.lang.String实例 |
NewStringUTF | 创建UTF-8格式的java.lang.String实例 |
3.2 访问数组
与3.1提到的 jstring 一样,jarray也不是C语言数据,不能直接访问jarray引用来完成相关操作。比如我们要对int数组的所有元素求和,利用下面的C代码是非法的。
/* This program is illegal! */
JNIEXPORT jint JNICALL Java_com_example_jnidemo_NativeMethod_sumArray
(JNIEnv *env, jobject obj, jintArray arr)
{
int i, sum = 0;
for (i = 0; i < 10; i++)
sum += arr[i];
return sum;
}
要想正确的访问jintArray数组元素,下面给出了正确的使用方法。
JNIEXPORT jint JNICALL Java_com_example_jnidemo_NativeMethod_sumArray
(JNIEnv *env, jobject obj, jintArray arr)
{
jint buf[10];
jint i, sum = 0;
env->GetIntArrayRegion(arr, 0, 10, buf);
for (i = 0; i < 10; i++)
sum += buf[i];
return sum;
}
GetIntArrayRegion函数来复制整型数组中的所有元素到C缓冲区中。第二个参数是需要复制的元素的起始索引,第三个参数表示需要复制的元素的总数。完成元素的复制后,我们就能够在本地代码中访问他们了。在这个例子中,我们已知数组的长度为10,因此不会引发越界问题。实际项目中我们需要知道传入数组的真实长度,这时候可以使用env->GetArrayLength(arr)来获取。
除了利用GetIntArrayRegion复制数据,另一种访问数据的方式是利用GetIntArrayElement函数
JNIEXPORT jint JNICALL Java_com_example_jnidemo_NativeMethod_sumArray
(JNIEnv *env, jobject obj, jintArray arr)
{
jint *pdata = env->GetIntArrayElements(arr,0);
if (pdata == NULL) {
return 0;
}
int len = env->GetArrayLength(arr);
int sum = 0;
for (i=0; i<len; i++) {
sum += pdata[i];
}
env->ReleaseIntArrayElements(arr, pdata, 0);
return sum;
}
与jstring操作一样,需要调用ReleaseIntArrayElements来释放内存。
这里列出常见的几种JNI数组函数:
JNI函数 | 描述 |
Get<Type>ArrayRegion | 复制type类型数组到c缓存区 |
Set<Type>ArrayRegion | 利用c缓存区数据修改type类型数组元素 |
Get<Type>ArrayElements\ Release<Type>ArrayElements | 获取\释放指向type类型的数组指针 |
GetArrayLength | 返回数组中元素的个数 |
New<Type>Array | 创建给定长度的数组 |
<Type>表示的是基本类型,比如 int,boolean,short等。
3.3访问对象数组
JNI提供了一对单独的函数来访问对象数组,GetObjectArrayElement返回给定索引处的元素,而SetObjectArrayElement更新给定索引处的元素。上面提到数组也是引用对象类型,下面我们利用一个本地生成二维数组的例子来说明对象数组的访问。
JNIEXPORT jobjectArray JNICALL Java_com_example_jnidemo_NativeMethod_init2DArray
(JNIEnv *env,jobject thisz,jint dim)
{
jobjectArray retArr;
jclass intArrCls = env->FindClass("[I");
if(intArrCls == NULL)
return NULL;
retArr = env->NewObjectArray(dim,intArrCls,0);
if(retArr == NULL)
return NULL;
for(int i=0; i<dim; i++)
{
int tmp[256]; //make sure larger than dim
jintArray iarr = env->NewIntArray(dim);
if(iarr == NULL)
return NULL;
for(int j=0; j<dim; j++)
tmp[j] = i+j;
env->SetIntArrayRegion(iarr,0,dim,tmp);
env->SetObjectArrayElement(retArr,i,iarr);
env->DeleteLocalRef(iarr);
}
return retArr;
}
initInt2DArray方法首先调用JNI函数FindClass来获取一个对二维int类型数组的元素类的引用。FindClass的参数 "[I" 是一个对应于Java编程语言中int[]类型的JNI类描述符。如果类型查询失败,FindClass会返回NULL并抛出异常(例如由于缺少类文件或者内存不足的情况)。
下一步,NewObjectArray函数分配一个数组,其元素类型由intArrCls类应用决定。NewObjectArray仅仅分配第一个维度,我们仍然需要填写构成第二个维度的数组元素。Java虚拟机中没有特殊的数据类型来表示多维数组。一个二维数组其实就是一个数组。
创建第二维数组的代码是简单易懂的。NewIntArray分配独立的数组元素,SetIntArrayRegion将tmp缓冲区的内容复制到新分配的一维数组中。完成SetObjectArrayElement调用后,第i个一维数组的第j个元素的值为i+j。在循环结尾调用DeleteLocalRef释放iarr,避免内存占用。
4. 成员变量交互
在实际工程中,除了Java层传递参数给JNI层,底层处理完的结果也需要返回到Java层。比如下面的例子中,NativeMethod类定义了name、bSucess、width、coord四个成员变量,调用accessField方法后JNI层对这些变量的值进行访问和修改,之后再在Java层利用这些变量来处理后续的业务逻辑。
class NativeMethod{
private String name="java_abc";
private boolean bSucess = False;
private int width = 100;
private int[] coord;
private native void accessField();
static {
system.loadLibrary("NativeMethod");
}
}
通过下面的JNI代码,我们获取变量的值,并在底层实现修改:
name: JNI获取Jave层的变量并打印出来,并在底层将其修改为"native_abc"
bSucess: JNI层获取它的值,并取反
width: JNI先获取初始值,新的值为初始值的2倍
coord: 在JNI层给它赋值为[1,2,3,4]的四维数组
JNIEXPORT void JNICALL Java_com_example_jnidemo_NativeMethod_accessField(JNIEnv *env,jobject thisz)
{
/* Get a reference to obj’s class */
jclass cls = env->GetObjectClass(thisz);
if(cls == NULL)
return;
jfieldID fid_width = env->GetFieldID(cls,"width","I");
if(fid_width != NULL)
{
jint jw = env->GetIntField(thisz,fid_width);
env->SetIntField(thisz,fid_width,jw*2);
}
jfieldID fid_coord = env->GetFieldID(cls,"coord","[I");
if(fid_coord != NULL)
{
jintArray arr = env->NewIntArray(4);
if(arr != NULL)
{
int tmp[4]={1,2,3,4};
env->SetIntArrayRegion(arr,0,4,tmp);
env->SetObjectField(thisz,fid_coord,arr);
env->DeleteLocalRef(arr);
}
}
/**/
jfieldID fid_sucess = env->GetFieldID(cls, "bSucess","Z");
if(fid_sucess != NULL)
{
jboolean jbln = env->GetBooleanField(thisz,fid_sucess);
if(jbln == false)
env->SetBooleanField(thisz,fid_sucess,true);
else
env->SetBooleanField(thisz,fid_sucess,false);
}
/* Look for the instance field ID in cls */
jfieldID fid_name = (env)->GetFieldID(cls, "name", "Ljava/lang/String;");
if(fid_name != NULL)
{
/* Read the instance field*/
jstring jstr = (jstring)env->GetObjectField(thisz, fid_name);
if(jstr!=NULL)
{
const char *str = env->GetStringUTFChars(jstr, NULL);
if (str != NULL)
{
env->ReleaseStringUTFChars(jstr, str);
/* Create a new string and overwrite the instance field */
jstr = env->NewStringUTF("native_abc");
if (jstr != NULL)
env->SetObjectField(thisz,fid_name,jstr);
}
}
}
}
上述代码较长,可能看起来有点晦涩难度。总结下来,要实现对变量的访问与修改,包括如下4步:
1. 获取类
JNI提供了两个接口,第一个传递jobject来获取class
jclass cls = env->GetObjectClass(thisz);
第二个是通过传递类名来获取
jclass objCls = env->FindClass("com/example/jnidemo/circleData");
2. 获取变量ID
jFieldID fid = env>GetFieldID("类对象", "参数名", "参数的字段描述符"); 比如下面获取变量width对应的ID,当没找到时返回NULL。最好做个判断,如果没找到而后续又使用会导致程序奔溃。
jfieldID fid_width = env->GetFieldID(cls,"width","I");
if(fid_width == NULL)
return;
下表给出了Java类型所对应的字段描述符细节
Java类型 | 字段描述符 | 备注 |
int | I | int的首字母,大写 |
float | F | float的首字母,大写 |
double | D | double的首字母,大写 |
short | S | short的首字母,大写 |
long | L | long的首字母,大写 |
char | C | char的首字母,大写 |
byte | B | byte的首字母,大写 |
boolean | Z | 因B被byte占用,使用Z |
object | L+/分隔完整类名 | 如 String对应Ljava/lang/String |
array | [+类型描述符 | 如 int[]对应 [I |
3. 获取变量的值
在知道了变量ID之后,那我们就可以获取他们的值。对于String和Array这类对象类型,使用GetObjectField来访问,第二个参数为该变量的ID。
jstring jstr = (jstring)env->GetObjectField(thisz, fid_name);
对于常见的数据类型,采用Get<Type>Field来访问,Type为基本数据类型,第二个参数为变量ID。
jint jw = env->GetIntField(thisz,fid_width);
4. 修改变量的值
除了获取变量的值,也能对它的值进行修改。对于String和Array这类对象类型,使用SetObjectField来修改,第二个参数为该变量的ID,第三个参数为新的值。
int tmp[4]={1,2,3,4};
env->SetIntArrayRegion(arr,0,4,tmp);
env->SetObjectField(thisz,fid_coord,arr);
对于常见的数据类型,采用Set<Type>Field来修改,Type为基本数据类型,第二个参数为变量ID,第三个参数为新的值。
jint jw = env->GetIntField(thisz,fid_width);
env->SetIntField(thisz,fid_width,jw*2);
备注:
如果成员变量定义为静态的,如 static String name,那么应该采用带有static的函数:GetStaticFieldID,GetStaticIntField,SetStaticIntField。
5. JNI局部引用
在阅读上面代码过程中,我们会发现出现了一个没介绍过的函数DeleteLocalRef,这和本节将要介绍的“局部引用”内容有关。局部引用也称本地引用,通常是在JNI 函数内部创建的 ,比如通过 NewLocalRef 和各种 JNI 接口(FindClass、NewObject、GetObjectClass和NewCharArray等)来创建,它们在 JNI 函数返回后无效。一般情况下,JVM会去自动释放局部引用,但下面一些情况下必须手动调用 DeleteLocalRef() 去释放,避免造成奔溃或不必要的内存占用。
1. 创建大量JNI局部引用
for (i = 0; i < len; i++) {
jstring jstr = env->GetObjectArrayElement(arr, i);
... /* 使用jstr */
env->DeleteLocalRef(jstr); // 使用完成之后马上释放
}
JNI局部引用表的数量是有限的,比如512个;当创建大量的局部引用,如果没即时释放,会导致JNI局部引用表的溢出,所以在不需要局部引用时就立即调用DeleteLocalRef手动删除。比如,在上面的代码中,本地代码遍历一个特别大的字符串数组(len很大),每遍历一个元素,都会创建一个局部引用 "jstr",当使用完这个元素的局部引用时,就应该马上手动释放它。
2. 创建大的对象引用
因为局部引用会阻止所引用的对象被GC回收所以需要即时手动释放不再需要的资源。
JNIEXPORT void JNICALL Java_pkg_Cls_func(JNIEnv *env, jobject this)
{
jintArray arr = (env)->NewIntArray(w*h); /* arr引用的是一个大的数组 */
... /* 在这里完成赋值操作,后续不再使用 */
(*env)->DeleteLocalRef(env, arr); /* 及时删除,并释放相应的资源*/
... /* 在里有个比较耗时的计算过程 */
return; /* 函数返回之前所有引用都已经释放 */
}
比如上面代码,在本地函数中刚开始需要访问一个大对象,w*h大小的intArray用于图像数据的处理,一开始就创建了一个对这个对象的引用,但在函数返回前会有一个大量的非常复杂的计算过程,而在这个计算过程当中是不需要前面创建的那个大对象的引用的。但是,在计算的过程当中,如果这个大对象的引用还没有被释放的话,会阻止GC回收这个对象,内存一直占用者,造成资源的浪费。所以这种情况下,在进行复杂计算之前就应该把引用给释放了,以免不必要的资源浪费。
到这里,我们把后续所需要的JNI编程基础知识都介绍完了。需要指出的是这只是JNI开发的冰山一角,要了解更多的内容,可以搜索有关《JNI/NDK开发指南》的文章,做更全面深入的学习。