第4章:JNI引用类型管理:全局引用、局部引用、弱全局引用、引用泄漏排查与内存优化
JNI引用管理,说白了就是Java和C++之间对象生命周期的协调问题。很多做NDK开发的朋友,一开始都会在这上面栽跟头。我自己早期做项目时,就遇到过因为引用没释放导致的内存暴涨,那叫一个惨烈。
今天咱们就把JNI的三种引用类型——局部引用、全局引用、弱全局引用——彻底讲透。顺便聊聊怎么排查引用泄漏,怎么优化内存。
4.1 局部引用:最常用,也最容易忘
局部引用是JNI默认创建的引用。你在Native方法里通过FindClass、NewObject、GetStringUTFChars等函数拿到的,基本都是局部引用。
它的生命周期很明确:从创建开始,到当前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 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,否则直接调用会崩溃。
// 创建弱全局引用
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层的引用泄漏得靠
valgrind或AddressSanitizer。 - 代码审查: 重点检查循环、递归、回调函数里有没有忘记释放局部引用。
- 加断言: 在Debug模式下,可以用
PushLocalFrame/PopLocalFrame来管理局部引用,如果忘记释放,Pop时会报错。
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的线程模型和异常处理,到时候再细说跨线程调用时引用怎么传。嗯,今天就到这儿吧。