开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第5天,点击查看活动详情
JNI系列文章导引
JNI函数使用
全局引用和局部引用
JNI支持三种引用:局部引用、全局引用和全局弱引用(弱引用)
局部引用和全局引用局具有不同的生命周期。当本地方法返回时,局部引用会自动释放。而全局引用和弱引用必须手动释放
局部引用和全局引用会组织GC回收它们所引用的对象,而弱引用不会
不是所有的引用可以被用于所有的场合,一个本地方法中创建一个局部引用并返回后,再对这个局部引用进行访问是非法的
局部引用
JNI函数中创建的引用就是局部引用。局部引用只在本地方法中有效,本地方法返回后,局部引用会被自动释放。
注意
1. 不能把局部引用储存在static变量中缓存起来供下次使用。变量ID,方法ID那些不属于局部对象引用。
2. 局部引用只在创建它们的线程中有效,不要在一个线程中创建局部引用并存储到全局引用中,然后到另外一个线程去使用。
如本地方法中
//在本地方法中缓存局部引用不合法
static jclass stringCls;
if(stringCls == NULL){
stringCls = (*env)->FindClass(env,"java/lang/String");
}
...
本地方法返回后,局部变量会被释放,再次访问无效的局部引用,会导致非法内存访问甚至崩溃。
-
释放一个局部引用有两种方式:
- 本地方法执行完毕后 VM 自动释放
- 通过
DeleteLocalRef
手动释放
既然 VM 会自动释放局部引用,为什么还需要手动释放呢?因为局部引用会阻止它所引用的对象被 GC 回收。
但是也会存在方法体中创建局部变量临时使用的情况,在使用完毕后应该立刻手动释放。不然可能会导致JNI局部引用表溢出。例如在for循环中创建局部变量,当对这个元素遍历完成或,就手动释放它。
-
本地方法中返回局部引用
局部变量在本地方法返回后失效,那怎么才能返回一个局部引用呢?答案是使用
NewLocalRef
void f(JNIEnv *env,...){ ... //返回局部引用,使用NewLocalRef jstring jstr = MyNewString1(env, "hahha", strlen("hahha")); return (*env)->NewLocalRef(env, jstr); }
-
管理局部引用
管理局部引用的函数:
EnsureLocalCapacity
,PushLocalFrame
,PopLocalFrame
,NewLocalRef
。JNI规范中,VM会确保每个本地方法创建至少16个局部引用,这个数量可以满足大部分的方法。如果真的需要创建更多的引用,本地方法可以调用
EnsureLocalCapacity
来支持更多的局部引用,但是不能保证分配成功,如果失败,VM会退出。PushLocalFrame
为一定数量的局部引用创建了一个使用堆栈,而PopLocalFrame
负责销毁堆栈栈顶的引用//在for循环中可以使用的最大局部引用数量 jint N_REFS = 10; for (int i = 0; i < 10; ++i) { if ((*env)->PushLocalFrame(env, N_REFS) < 0) { //OOM } //create local object //.... //销毁局部变量 (*env)->PopLocalFrame(env, NULL); }
全局引用
全局引用可以跨方法、跨线程使用,直到它被手动释放才会失效。全局引用也会阻止所引用对象被GC回收。
全局引用只能使用NewGlobalRef
创建。如果不手动释放,即使不使用了,VM也不会回收这个所引用的对象。
JNIEXPORT void JNICALL
Java_com_example_jnifirst_ObjectReference_globalRefNative(JNIEnv *env, jobject thiz) {
static jstring stringClass;
//创建全局引用过程
if (stringClass == NULL) {
//1. 创建局部引用
jclass jstrLocalRef = (*env)->FindClass(env, "java/lang/String");
if (jstrLocalRef == NULL) {
return;
}
//2. 将局部引用保存在全局引用中
stringClass = (*env)->NewGlobalRef(env, jstrLocalRef);
//3. 局部引用不再使用,可以销毁
(*env)->DeleteLocalRef(env, jstrLocalRef);
//4. 判断全局引用是否创建成功
if (stringClass == NULL) {
return;
}
}
//......
}
弱全局引用
弱引用使用NewGlobalWeakRef
创建,使用DleteGlobalWeakRef
释放。弱引用可以跨方法、跨线程使用;但是弱引用不会阻止GC回收所指向的对象。如果不手动释放,即使不使用了,VM会回收这个所引用的对象,但是弱引用本身在引用表中所占的内存永远不会被回收。
当本地代码中缓存的引用不一定要阻止GC回收它所指向的对象是,弱引用就是一个很好的选择。
JNIEXPORT void JNICALL
Java_com_example_jnifirst_ObjectReference_globalWeakRefNative(JNIEnv *env, jobject thiz) {
static jstring stringClass;
//创建全局弱引用过程,判断弱引用是否被回收;使用obj == NULL或者IsSameObject(env,obj1,NULL)==JNI_TRUE均可
//若是判断对象相等,需要使用IsSameObject(env,obj1,obj2)
if (stringClass == NULL) {
//1. 创建局部引用
jclass jstrLocalRef = (*env)->FindClass(env, "java/lang/String");
if (jstrLocalRef == NULL) {
return;
}
//2. 将局部引用保存在全局弱引用中
stringClass = (*env)->NewWeakGlobalRef(env, jstrLocalRef);
//3. 局部引用不再使用,可以销毁
(*env)->DeleteLocalRef(env, jstrLocalRef);
//4. 判断全局弱引用是否创建成功
if (stringClass == NULL) {
return;
}
}
//....
}
弱引用必须检查缓存过的弱引用指向的活动对象是否活动。
引用比较
两个引用,可以使用IsSameObject
来判断是否指向相同的对象,相同会返回JNI_TRUE
,否则返回JNI_FALSE
。
如果是引用NULL
,可以使用(*env)->IsSameObject(env,obj,NULL)
或者obj == NULL
检查obj
是否为NULL。
若使用IsSameObject
判断弱引用,如果弱引用指向的对象已经被回收就会返回JNI_TRUE
,如果指向的对象任然活动,就会返回JNI_FALSE
。
异常
很多情况下,本地代码左JNI调用或都要检查是否有错误发生。
处理异常
例如,在Java方法中定义一个方法供JNI中调用,但是这个方法可能会抛出异常,下面展示了怎么检查异常,打印异常,清除异常,以及抛出一个新的异常
fun catchTrowInJni() {
try {
throwInJni()
} catch (e: IllegalArgumentException) {
e.printStackTrace()
}
}
//在java中抛出异常,jni中调用java方法
private fun callbackThrow() {
throw NullPointerException("java throw exception")
}
/**
*
* @throws IllegalArgumentException
*/
private external fun throwInJni()
JNIEXPORT void JNICALL
Java_com_example_jnifirst_CatchThrow_throwInJni(JNIEnv *env, jobject thiz) {
//jni中检查java方法中的异常;并抛出新的异常,处理异常
//调用java方法,并检查异常
jclass cls = (*env)->GetObjectClass(env, thiz);
jmethodID mid = (*env)->GetMethodID(env, cls, "callbackThrow", "()V");
if (mid == NULL) {
return;
}
(*env)->CallVoidMethod(env, thiz, mid);
//检查异常,ExceptionOccurred获取异常的指针
jthrowable exc = (*env)->ExceptionOccurred(env);
if (exc) {
//发生异常,打印异常信息,clear异常,这样相当于异常被抓住
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
//抛出新的异常,出现异常后若不做clear,就做资源回收操作,不应该继续进行后续操作
jclass illegalExceptionCls = (*env)->FindClass(env, "java/lang/IllegalArgumentException");
(*env)->ThrowNew(env, illegalExceptionCls, "throw exception from C");
}
}
本地方法中使用ExceptionOccurred
检查异常的发生,当异常被检测到时,使用ExceptionDescribe
输出异常信息,并通过调用ExceptionClear
清除异常信息,最后使用ThrowNew
抛出一个新的异常。
和Java中的异常机制不一样,JNI抛出的异常不处理的话,不会立刻终止本地方法的执行。异常发生后,必须手动处理。
-
制作抛出异常工具方法
抛出异常需要两步:通过
FindClass
找到异常类,之后调用ThrowNew
抛出异常。简化这个过程,设计一个工具方法void JUN_ThrowByName(JNIEnv *env, const char *name, const char *msg) { jclass cls = (*env)->FindClass(env, name); if (cls == NULL) { return; } (*env)->ThrowNew(env, cls, msg); (*env)->DeleteLocalRef(env, cls); }
-
检查异常的方法
-
大部分的JNI函数会通过特定的返回值来表示已经发生了一个错误,并且当前线程中有一个异常需要处理,这在C语言中很常见。
-
使用
ExceptionOccurred
或者ExceptionCheck
检查异常两则的区别:
ExceptionOccurred
会返回异常的引用,后续可以使用ExceptionDescribe
,ExceptionClear
等处理异常。若没有发生异常,会返回NULLExceptionCheck
如果已经发生了异常,则返回JNI_TRUE,否则返回JNI_FALSE
-
-
异常处理
- 异常发生后立刻返回,让调用者处理这个异常
- 通过
ExceptionClear
清除异常,然后执行自己的异常处理代码。一旦发生,必须先检查、处理、清除异常后再做其他JNI函数调用,不然结果未知。当异常发生时,释放资源是很重要的事情。
JNI和线程
JNI可以在相同的地址空间内执行多个线程。多线程可以共享资源,增加了程序的复杂性。
-
约束限制
JNIEnv
指针只在它所在的线程中有效,不能跨线程传递和使用。不同线程调用一个本地方法时,传入的JNIEnv
指针是不同的- 局部引用只在创建它们的线程中有效,不能跨线程传递,但是可以转化成全局引用供多线程使用
-
实现
使用
MonitorEnter
和MonitorExit
实现进入同步块和退出同步块,功能类似Java中的synchorized
代码块。jint result = (*env)->MonitorEnter(env, obj); if (result != JNI_OK) { //进入失败,处理错误 } //同步代码块 ... result = (*env)->MonitorExit(env, obj); if (result != JNI_OK) { //退出失败,处理错误 }
程序必须先进入obj的监视器,再执行同步代码块中的代码。一个线程A进入obj的监视器后,另一个线程B想要进入,此时线程B就会阻塞等待,只有当线程A退出后线程B才能继续执行。
如果当前线程不拥有监视器的情况下调用
MonitorExit
,会抛出IllegalMonitorStateException
。同时MonitorEnter
和MonitorExit
的调用可能出现调用错误,必须检查异常。只调用
MonitorEnter
而不调用MonitorExit
很可能会引起死锁。总的来说,对比Java中的实现,JNI中的处理要复杂的多,尽量用Java来做同步,将同步相关的代码放在Java中。
-
监视器的等待和唤醒
JNI中没有提供对等Java线程等待唤醒的函数,只能调用Java中的方法
//线程wait和notify jni没有提供方法,只能自行调用java中的 static jmethodID MID_Object_wait; static jmethodID MID_Object_notify; static jmethodID MID_Object_notifyAll; void MonitorWait(JNIEnv *env, jobject obj, jlong timeout) { (*env)->CallVoidMethod(env, obj, MID_Object_wait, timeout); } void MonitorNotify(JNIEnv *env, jobject obj) { (*env)->CallVoidMethod(env, obj, MID_Object_notify); } void MonitorNotifyAll(JNIEnv *env, jobject obj) { (*env)->CallVoidMethod(env, obj, MID_Object_notifyAll); }
获取JNIEnv指针
JNEnv
指针只在当前线程中有效。那么有没有办法可以从本地代码的任意地方获取到 JNIEnv
指针呢?比如,一个操作系统的回调函数中,本地代码是无法通过传参的方式获取到 JNIENnv
指针的
可以通过调用JNIInvokeInterface
中的 AttachcurrentThread
方法来获取到当前线程中的 JNIEnv
指针:
JavaVM *jvm;
void f(){
JNIEnv *env;
(*jvm)->AttachCurrentThread(jvm,(void **)&env,NULL);
...
}
一旦当前线程被附加到JVM上,AttachCurrentThread
就会返回一个属于当前线程的JNIEnv
指针。
可以使用JNIInvokeInterface
中的GetEnv
检查当前线程是否有附加到JVM上,然后返回属于当前线程中的 JNIEnv
指针。一旦每附加上去,那么AttachcurrentThread
和GetEnv
是等价的。
获取JavaVM指针
获取JNIEnv
指针需要JavaVM
指针,那怎么获取呢?
- 在VM创建的时候记录下来
- 通过
JNI_GetCreatedJavaVMs
查询被创建的JVM JNIEnv
中的GetJavaVM
或者JNI_OnLoad
时获取,JavaVM只要被缓存在全局引用中,是可以被跨线程使用的
总结JNI中容易出错的地方
-
检查错误
很容易忘记,但是很重要
-
向JNI函数传递非法参数
JNI不会检查参数是否正确,自己不保证参数正确,可能会出现未知的结果或错误。通常,不检查参数的有效性在C/C++库中比较常见
-
jclass
和jobject
弄混jclss
是类引用,jobject
是对象引用。比如获取ID需要传入
jclass
作为参数,这是要从一个类中获取字段的描述,但是在Get<Type>Field
获取参数的值时,传入的是jobject
,这是要从一个对象实例中获取字段的值。 -
jboolean会有数据截取问题
jboolean
是一个8-bit unsigned
的 C 类型,可以存储 0255 的值。其中,0 对应常量255 对应常量JNI_FALSE
,而 1JNI_TRUE
。但是,32 或者 16 位的值,如果最低的 8 位是 0 的话,就会引起问题。比如
print(256)
会打印false
。256是0x100,低8位都是0。解决方法就是n = 256; print(n ? JNI_TRUE : JNI_FALSE)
。 -
Java和C的选择
- 尽量让Java和C之间接口简单化,过于复杂难以维护,优化也难
- 尽量少些本地代码,本地代码不安全,不可移植,错误检查还麻烦
- 让本地方法尽量独立,本地方法尽量都在同一个包或者同一个类中
-
混淆ID和引用
本地方法中需要使用ID访问Java中的字段和方法。
引用指向的是可以由本地代码管理的JVM中的资源;而ID是由JVM管理的,只由属于的类被unload时才会失效,本地代码不能显示的删除一个ID。
本地代码可以创建多个引用并让它们指向相同的对象。比如,一个全局引用和一个局部引用可能指向相同的对象。而字段 ID 和方法 ID 是唯一的。比如类 A 定义了一个方法 f,而类 B 从类 A 中继承了方法 f,那么下面的调用结果是相同的:
jmethodID mid_a_f = (*env)->GetMethodID(env,A,"f",()V); jmethodID mid_b_f = (*env)->GetMethodID(env,B,"f",()V);
-
缓存字段ID和方法ID
open class C { private var i:Int=0 external fun f() }
JNIEXPORT void JNICALL Java_com_example_jnifirst_extendstrouble_C_f(JNIEnv *env, jobject thiz) { jclass cls = (*env)->FindClass(env, thiz); jfieldID fid_C_i = (*env)->GetFieldID(env, cls, "i", "I"); if(fid_C_i == NULL){ return; } jint C_i = (*env)->GetIntField(env, thiz, fid_C_i); //... }
此时一切正常,获取的都是C中的变量。但是,如果D继承自C,也定义一个私有的字段i
/** * 继承C,定义相同的i变量,在构造函数中调用继承自C的f方法 */ class D: C() { private val i:Int = 1 init { //此时调用f,f中的FindClass指向的是D方法,那么读取到的i字段是D类中定义的i的值 f() } }
此时的JNI方法f被D调用时,
FindClass
指向的是D,那么读取到的i字段是D类中定义的i的值解决方案是在JNI被初始化是,把C的字段i的ID缓存起来,那么后面再使用的时候,也是C中i的ID,因此不管本地方法中接收的jobject是哪个类的实例,访问的永远都是C的i。
-
访问权限
本地代码中方法Java代码不受Java语言规定的限制,比如修改private和final修饰的字段。并且,JNI中可以访问和修改heap中任意位置的内存,这会造成意想不到的结果。因此对于Java中不可修改的类,也最好不要破坏。
-
确保资源释放
特别是在发生错误时,一定要首先释放资源
-
使用已经失效的局部引用
不应该把局部变量缓存在static中,局部引用只能在方法调用期间使用。
-
跨进程使用JNIEnv指针
JNIEnv指针只能在当前线程使用,不要跨线程使用