第4章:JNI引用类型管理:全局引用、局部引用、弱全局引用、引用泄漏排查与内存优化

JNI引用管理,说白了就是Java和C++之间对象生命周期的协调问题。很多做NDK开发的朋友,一开始都会在这上面栽跟头。我自己早期做项目时,就遇到过因为引用没释放导致的内存暴涨,那叫一个惨烈。

今天咱们就把JNI的三种引用类型——局部引用、全局引用、弱全局引用——彻底讲透。顺便聊聊怎么排查引用泄漏,怎么优化内存。

4.1 局部引用:最常用,也最容易忘

局部引用是JNI默认创建的引用。你在Native方法里通过FindClassNewObjectGetStringUTFChars等函数拿到的,基本都是局部引用。

它的生命周期很明确:从创建开始,到当前Native函数返回时自动释放。嗯,这里要注意——自动释放不代表你可以不管。如果你在一个循环里频繁创建局部引用,JVM的局部引用表可能会爆掉。

⚠️ 避坑指南: 我曾经在一个视频解码的循环里,每帧都调用FindClass获取同一个类,结果跑了十几分钟就崩了。后来才发现,局部引用表默认只有16个或32个槽位(取决于JVM实现),循环里不释放,很快就满了。

解决办法很简单:手动调用DeleteLocalRef释放,或者用EnsureLocalCapacity提前申请足够的容量。

// 错误示范:循环里不释放局部引用
for (int i = 0; i < 10000; i++) {
    jclass clazz = env->FindClass("com/example/MyClass");
    // 使用clazz...
    // 忘记释放!局部引用表溢出
}

// 正确做法:及时释放
for (int i = 0; i < 10000; i++) {
    jclass clazz = env->FindClass("com/example/MyClass");
    // 使用clazz...
    env->DeleteLocalRef(clazz);  // 手动释放
}

4.2 全局引用:跨线程、跨函数调用的利器

局部引用只能在当前函数内用,函数返回就失效了。那如果你想让一个Java对象在多个Native函数、甚至多个线程里都能访问呢?这时候就需要全局引用。

创建全局引用用NewGlobalRef,释放用DeleteGlobalRef。它不会随着函数返回而消失,除非你主动释放,或者JVM卸载了Native库。

💡 个人经验: 我习惯在JNI_OnLoad里创建全局引用,比如缓存常用的jclass对象。这样后续所有Native函数都能直接用,不用每次都FindClass,性能提升很明显。
// 在JNI_OnLoad中缓存全局引用
jclass g_MyClass;

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
    JNIEnv* env;
    vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6);
    
    jclass localClazz = env->FindClass("com/example/MyClass");
    g_MyClass = static_cast<jclass>(env->NewGlobalRef(localClazz));
    env->DeleteLocalRef(localClazz);  // 释放局部引用
    
    return JNI_VERSION_1_6;
}

// 使用缓存的全局引用
void useCachedClass(JNIEnv* env) {
    jmethodID methodId = env->GetMethodID(g_MyClass, "doSomething", "()V");
    // ...
}

全局引用有个坑:它会让Java对象无法被GC回收。如果你创建了全局引用但忘了释放,就等于内存泄漏。我见过一个项目,全局引用创建了上千个,结果Java堆内存一直涨,最后OOM了。

4.3 弱全局引用:既要引用,又不想阻止GC

弱全局引用是全局引用的“温和版”。它允许你引用一个Java对象,但不会阻止GC回收它。如果对象被回收了,弱引用就变成NULL

创建用NewWeakGlobalRef,释放用DeleteWeakGlobalRef。使用前一定要检查是否为NULL,否则直接调用会崩溃。

📌 使用场景: 弱全局引用特别适合缓存那些“可有可无”的对象。比如你缓存了一个大的Bitmap对象,如果内存紧张,GC可以回收它,你的Native代码下次使用时重新加载就行。
// 创建弱全局引用
jweak weakRef = env->NewWeakGlobalRef(someObject);

// 使用前检查
jobject obj = env->NewLocalRef(weakRef);
if (obj != NULL) {
    // 对象还在,可以安全使用
    // ...
    env->DeleteLocalRef(obj);
} else {
    // 对象已被GC回收,需要重新创建
}

// 释放弱全局引用
env->DeleteWeakGlobalRef(weakRef);

4.4 引用泄漏排查:从现象到根因

引用泄漏的症状很明显:Java堆内存持续增长,或者Native层报JNI ERROR (app bug): local reference table overflow

排查方法我总结了几条:

  • 看日志: JVM在引用表溢出时会打印详细的调用栈,顺着栈找就能定位到问题代码。
  • 用工具: Android Studio的Memory Profiler可以看Java堆,但Native层的引用泄漏得靠valgrindAddressSanitizer
  • 代码审查: 重点检查循环、递归、回调函数里有没有忘记释放局部引用。
  • 加断言: 在Debug模式下,可以用PushLocalFrame/PopLocalFrame来管理局部引用,如果忘记释放,Pop时会报错。
⚠️ 避坑指南: 我曾经排查过一个诡异的泄漏——每次调用某个Native方法,Java堆就涨一点,但代码里明明释放了所有引用。最后发现,问题出在GetStringUTFChars返回的const char*,我用了ReleaseStringUTFChars释放,但NewStringUTF创建的jstring却忘了释放。嗯,细节决定成败。

4.5 内存优化实战:引用管理的黄金法则

做了这么多年NDK开发,我总结了几条引用管理的黄金法则:

场景 推荐做法 原因
函数内临时使用 局部引用 + 手动DeleteLocalRef 避免引用表溢出
跨函数/跨线程使用 全局引用 + 在JNI_OnLoad中创建 保证生命周期可控
缓存大对象 弱全局引用 + NULL检查 不阻止GC回收
频繁调用的类/方法ID 全局引用缓存jclass/jmethodID 避免重复FindClass/GetMethodID

另外,我建议你在项目里统一封装一个JNI引用管理类。比如用RAII思想,在构造函数里创建引用,析构函数里自动释放。这样能大大减少忘记释放的概率。

// RAII风格的JNI引用管理
class JniLocalRef {
public:
    JniLocalRef(JNIEnv* env, jobject obj) : m_env(env), m_obj(obj) {}
    ~JniLocalRef() {
        if (m_obj != nullptr) {
            m_env->DeleteLocalRef(m_obj);
        }
    }
    jobject get() const { return m_obj; }
private:
    JNIEnv* m_env;
    jobject m_obj;
};

// 使用示例
void someFunction(JNIEnv* env) {
    JniLocalRef ref(env, env->NewStringUTF("hello"));
    // 函数结束时自动释放,不用担心忘记
}

最后说一句:JNI引用管理没有银弹。你想想看,Java和C++的内存模型本来就不一样,强行桥接肯定有代价。我们能做的,就是理解每种引用的特性,然后根据场景选对工具。

下一章我们会聊JNI的线程模型和异常处理,到时候再细说跨线程调用时引用怎么传。嗯,今天就到这儿吧。