内容提要
JNI崩溃常源于引用管理错误。局部引用在方法返回后失效,循环中不释放会导致引用表溢出,可用DeleteLocalRef或PushLocalFrame修复;缓存局部引用会变悬空句柄,需用NewGlobalRef提升为全局引用。全局引用须手动删除,否则泄漏。弱全局引用不保活对象,使用前须用NewLocalRef提升。JNIEnv绑定单线程,应缓存JavaVM并附加线程。用CheckJNI和RAII可提前发现并避免这些错误。
延伸解读
引用表容量与溢出风险
JNI 引用表有固定容量,局部引用表在旧版 Android 上仅 512 项,全局引用表在 Android 上限制为 51,200 项。超出限制会导致进程中止,并报出 local reference table overflow 或 global reference table overflow。因此,在循环中创建引用而不释放,或不断创建全局引用而不删除,都会最终触发溢出。开发者应关注引用数量,及时释放不再使用的引用。
线程绑定与类查找陷阱
JNIEnv 指针与线程绑定,不能跨线程使用。原生线程需通过 JavaVM 的 AttachCurrentThread 获取自己的 JNIEnv,并在退出前 DetachCurrentThread。此外,从原生线程调用 FindClass 会使用系统类加载器,无法找到应用类,返回 nullptr 并抛出 ClassNotFoundException。正确做法是在 JNI_OnLoad 中缓存应用类的全局引用,避免在原生线程中查找。
弱全局引用的正确使用方式
弱全局引用不会阻止对象被垃圾回收,因此不能直接使用。在调用方法前,必须先用 NewLocalRef 将其提升为局部引用,以确保对象在调用期间不被回收。直接使用弱引用会与 GC 产生竞态条件,导致崩溃。使用完毕后,应使用 DeleteWeakGlobalRef 释放弱引用,而不是 DeleteGlobalRef。
利用工具与模式提前发现错误
CheckJNI 和 -Xcheck:jni 能在开发阶段捕获引用错误,如使用过期的局部引用、跨线程使用 JNIEnv、忽略待处理异常等。RAII 包装器(如 ScopedLocalRef 和 ScopedGlobalRef)可自动管理引用生命周期,避免手动释放遗漏。结合这些工具和模式,可以显著减少 JNI 崩溃,提高代码健壮性。
Q&A
JNI中的局部引用、全局引用和弱全局引用有什么区别?
局部引用在本地方法返回时自动释放,且只能由创建它的线程使用;全局引用通过NewGlobalRef创建,需手动调用DeleteGlobalRef释放,可跨线程使用并保持对象存活;弱全局引用通过NewWeakGlobalRef创建,也需手动释放,但不阻止对象被垃圾回收,使用前必须用NewLocalRef提升为局部引用。
为什么在循环中创建局部引用会导致JNI崩溃?如何修复?
局部引用表容量有限,循环中每次迭代都创建新的局部引用而不释放,会导致引用表溢出,进程崩溃。修复方法是在每次迭代结束时调用DeleteLocalRef释放引用,或使用PushLocalFrame和PopLocalFrame管理一批引用。
为什么不能将局部引用缓存到静态变量中?应该怎么做?
局部引用在本地方法返回后即失效,缓存到静态变量会导致悬空句柄,后续使用可能访问已释放的内存或错误对象。正确做法是用NewGlobalRef将局部引用提升为全局引用后再缓存,并适时删除原局部引用。
如何安全地在多个线程中使用JNIEnv?
JNIEnv是线程私有的,不能跨线程共享。应缓存JavaVM指针,每个线程通过AttachCurrentThread获取自己的JNIEnv,使用完毕后调用DetachCurrentThread分离。注意在Android上,附加的线程退出前必须分离,否则会中止进程。
为什么在原生线程中调用FindClass会失败?如何解决?
原生线程没有Java栈帧,FindClass会使用系统类加载器,无法找到应用类,返回null并抛出ClassNotFoundException。解决方案是在JNI_OnLoad中查找并缓存所需类的全局引用,避免在原生线程中调用FindClass。
如何使用CheckJNI和RAII来预防JNI引用错误?
CheckJNI是扩展验证模式,在Android上可通过adb shell setprop debug.checkjni 1启用,桌面JVM使用-Xcheck:jni,能立即检测引用错误并中止进程。RAII通过封装ScopedLocalRef和ScopedGlobalRef等类,利用析构函数自动释放引用,避免手动管理遗漏。