JNI 引用问题梳理
最近项目中有个视频文件分块上传的模块,核心逻辑是 C/C++ 实现的,Android 上层调用自然又要写 JNI。
其中有个需求是 Native 层上传进度更新时需要回调 Java 代码,这里我用了 C++11 的 Lambda 表达式:
std::function<void(unsigned)> cxx_progress_callback;
if (jcallback) {
cxx_progress_callback = [&env, &jcallback]
(unsigned progress) -> void {
jclass jcallback_class = env->GetObjectClass(jcallback);
if (jcallback_class) {
jmethodID methodId = env->GetMethodID(jcallback_class, "onProgressChanged", "(I)V");
if (methodId) {
env->CallVoidMethod(jcallback, methodId, progress);
}
}
};
}
一开始跑起来也没发现问题,但当我把 C/C++ 代码定义的 BLOCK_SIZE
从 512K 改为 1K 时,不幸又踩了 JNI 引用的坑:
JNI ERROR (app bug): local reference table overflow (max=512)
为什么说是又?之前也踩过类似的坑;只不过上次是全局引用,这次是局部引用;
两次踩坑,所以决心这次在解决问题之余,也详细梳理下 JNI 引用相关问题。
回到上面的问题,可以看到 Lambda 表达式的函数体内部临时创建了 jclass
对象和 jmethodID
变量,其中 jclass
属于局部引用;
由于这里 Lambda 表达式作为回调会被频繁调用,如果不去手动释放这个引用的话,JNI 维护的 Local Reference Table 就会 Overflow;
所以解决方式有两种:
在 Lambda 表达式内部该引用对象使用完毕后手动调用
env->DeleteLocalRef(jcallback_class)
作释放;将该引用对象的定义放到 Lambda 表达式外部(需要添加一个引用类型的捕获值),而在内部只做赋值操作,避免每次都重新创建新的引用;
具体改进后的代码就不贴了,下面详细梳理下 JNI 引用的相关问题。
局部引用:
JNI 函数内部创建的 jobject
对象及其子类(jclass
、jstring
、jarray
等) 对象都是局部引用,它们在 JNI 函数返回后无效;
一般情况下,我们应该依赖 JVM 去自动释放 JNI 局部引用;但下面两种情况必须手动调用 DeleteLocalRef()
去释放:
(在循环体或回调函数中)创建大量 JNI 局部引用,即使它们并不会被同时使用,因为 JVM 需要足够的空间去跟踪所有的 JNI 引用,所以可能会造成内存溢出或者栈溢出;
如果对一个大的 Java 对象创建了 JNI 局部引用,也必须在使用完后手动释放该引用,否则 GC 迟迟无法回收该 Java 对象也会引发内存泄漏.
全局引用:
全局引用允许你持有一个 JNI 对象更长的时间,直到你手动销毁;但需要显式调用 NewGlobalRef()
和 DeleteGlobalRef()
:
class MyPeer {
private:
jstring s;
public:
MyPeer(JNIEnv* env, jstring s) {
this->s = env->NewGlobalRef(s);
}
~MyPeer() {
env->DeleteGlobalRef(s);
s = NULL;
}
};
弱全局引用
弱全局引用类似 Java 中的弱引用,它允许对应的 Java 对象被 GC 回收;
类似地,创建和释放也是通过 NewWeakGlobalRef()
和 DeleteWeakGlobalRef()
;
调用 IsSameObject(env, jobj, NULL)
可以判断该弱全局引用指向的 Java 对象是否已被 GC 回收。
jobject 对象的引用值不唯一
同一个 jobject
对象的不同引用可能拥有不同的值,比如同一 jobject
对象每次调用 NewGlobalRef()
可能返回不同的值;
要检查两个引用是否指向同一个 jobject
对象,必须调用 IsSameObject()
,而不要使用 ==
去比较;
用于描述一个 jobject
对象的 32 位值可能在方法多次调用后发生变化,而两个不同 jobject
对象却可能在多次方法调用拥有相同的值,所以千万不能将 jobject
对象的值当作 key 使用;
jmethodID 和 jfieldID:
在 JNI 层执行 Java 代码常用到 FindClass()
、 GetMethodID()
、 GetFieldID()
;
但只有第一个函数返回的 jclass
属于 JNI (局部)引用对象,而 jmethodID
和 jfieldID
并不是,它们是指向内部 Runtime 数据结构的指针;
实际上这些 ID 是用于缓存的静态对象:第一次查找会做一次字符串比较,但后面再次调用就能直接读取而变得很快;
JVM 会保证这些 ID 是合法的,直到 Class
被 unload;
所以,jmethodID
和 jfieldID
是不需要手动释放的,当然也不能作为 JNI 全局引用。
其他非 JNI 引用:
除了上面提到的 ID,类似 GetStringUTFChars()
和 GetByteArrayElements()
/GetCharArrayElements()
等函数返回的也是 Raw Data 指针,而非 JNI 引用;
在调用相对应的 ReleaseXXX()
函数释放前,它们都是合法的;
批量操作 JNI 引用:
一般情况下要避免大量创建 JNI 局部引用,最好用完后立即释放(实际上目前的实现只预留了 16 个局部引用的空间);
如果确实需要大量操作 JNI 局部引用,要么调用 EnsureLocalCapacity()
指定更多的空间,要么调用PushLocalFrame()
/PopLocalFrame()
批量分配/释放:
env->PushLocalFrame(128);
jobjectArray array = env->NewObjectArray(128, gMyClass, NULL);
for (int i = 0; i < 128; ++i) {
env->SetObjectArrayElement(array, i, newMyClass(i));
}
env->PopLocalFrame(array);
开启 CheckJNI 检查 JNI 引用问题:
Android 提供了一种叫做 CheckJNI 的模式用于检测常见的 JNI 错误,其中和 JNI 引用相关的错误有:
将
DeleteGlobalRef()
/DeleteLocalRef()
用于错误的 JNI 引用类型;jfieldID
/jmethodID
为空或者类型不合法;
但是 CheckJNI 暂时还不能检测 JNI 局部引用的滥用问题,比如:存储了一个 JNI 局部引用,然后在 JNI 函数返回后继续使用。这种情况很显然应该使用 NewGlobalRef()
创建全局 JNI 引用。
开启 CheckJNI 只需一行命令即可:
adb shell setprop debug.checkjni 1
参考: