JNIEnv、线程与引用生命周期:JNI 最容易崩的地方
如果 NDK 新手只记一条 JNI 规则,就记这条:JNIEnv* 不能跨线程保存和复用。
官方 JNI Tips 文档明确说明,JNIEnv 用于线程局部存储,因此不能在线程之间共享。如果某段代码没有 JNIEnv,应该共享 JavaVM,再通过 GetEnv 或 AttachCurrentThread 获取当前线程的 JNIEnv。
先理解 JNIEnv 和 JavaVM
JavaVM 代表当前进程里的 Java 虚拟机实例。Android 一个进程通常只有一个。
JNIEnv 代表“当前线程访问 JNI 函数的入口”。
JavaVM:整栋楼
JNIEnv:当前工人手里的门禁卡
门禁卡只属于当前工人。你不能把 A 线程的 JNIEnv* 交给 B 线程用。
错误示例:保存 JNIEnv
class BadCallback {
public:
void init(JNIEnv* env, jobject listener) {
env_ = env;
listener_ = listener;
}
void notifyFromWorkerThread() {
env_->CallVoidMethod(listener_, method_);
}
private:
JNIEnv* env_ = nullptr;
jobject listener_ = nullptr;
jmethodID method_ = nullptr;
};
这段代码有两个问题。
env_ 可能来自 UI 线程,却在 worker 线程使用
listener_ 是 local reference,native 方法返回后就失效
它可能看起来能跑,但在某些设备、某些 GC 时机、某些线程调度下崩溃。
正确思路:保存 JavaVM 和 GlobalRef
class JniCallback {
public:
JniCallback(JavaVM* vm, JNIEnv* env, jobject listener)
: vm_(vm), listener_(env->NewGlobalRef(listener)) {}
~JniCallback() {
JNIEnv* env = currentEnv();
if (env != nullptr && listener_ != nullptr) {
env->DeleteGlobalRef(listener_);
}
}
void notifyState(jint state) {
JNIEnv* env = currentEnv();
if (env == nullptr) return;
jclass clazz = env->GetObjectClass(listener_);
jmethodID method = env->GetMethodID(clazz, "onState", "(I)V");
env->CallVoidMethod(listener_, method, state);
env->DeleteLocalRef(clazz);
}
private:
JNIEnv* currentEnv() {
JNIEnv* env = nullptr;
if (vm_->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) == JNI_OK) {
return env;
}
if (vm_->AttachCurrentThread(&env, nullptr) == JNI_OK) {
attachedThreads_.insert(std::this_thread::get_id());
return env;
}
return nullptr;
}
JavaVM* vm_ = nullptr;
jobject listener_ = nullptr;
std::set<std::thread::id> attachedThreads_;
};
这只是教学版。生产代码还需要保证 DetachCurrentThread 在线程退出时执行,通常用 RAII 包装。
RAII 包装 Attach/Detach
class EnvScope {
public:
explicit EnvScope(JavaVM* vm) : vm_(vm) {
if (vm_->GetEnv(reinterpret_cast<void**>(&env_), JNI_VERSION_1_6) == JNI_OK) {
return;
}
if (vm_->AttachCurrentThread(&env_, nullptr) == JNI_OK) {
attached_ = true;
}
}
~EnvScope() {
if (attached_) {
vm_->DetachCurrentThread();
}
}
JNIEnv* get() const { return env_; }
private:
JavaVM* vm_ = nullptr;
JNIEnv* env_ = nullptr;
bool attached_ = false;
};
这个对象的生命周期像安全绳:进入 native 线程回调时 attach,离开作用域时自动 detach。
LocalRef 是什么
JNI 函数返回的很多对象都是 local reference。
jclass clazz = env->FindClass("com/zerobug/player/PlayerEvent");
clazz 默认是 local ref。它只在当前 native 方法、当前线程里有效。native 方法返回后,不能继续保存它。
GlobalRef 是什么
如果你要跨方法、跨线程保存 Java/Kotlin 对象,就要创建 global reference。
jobject globalListener = env->NewGlobalRef(listener);
用完必须释放。
env->DeleteGlobalRef(globalListener);
GlobalRef 像长期借用证。它比 LocalRef 稳定,但你不归还,就会泄漏。
LocalRef 也可能泄漏
LocalRef 会在 native 方法返回时自动释放。但如果 native 方法里有长循环,LocalRef 会不断堆积。
错误示例:
for (int i = 0; i < count; ++i) {
jstring item = env->NewStringUTF(values[i].c_str());
env->CallVoidMethod(list, addMethod, item);
}
修复:
for (int i = 0; i < count; ++i) {
jstring item = env->NewStringUTF(values[i].c_str());
env->CallVoidMethod(list, addMethod, item);
env->DeleteLocalRef(item);
}
本章实验
实验一:故意保存 JNIEnv*,从 worker 线程回调,观察崩溃或 CheckJNI 警告。
实验二:把 listener 改成 GlobalRef,把 JNIEnv 改成每次从 JavaVM 获取。
实验三:在 Android Studio Memory Profiler 里打开 JNI heap view,反复进入退出播放器页面,确认 GlobalRef 不持续增长。
工程风险与观测
JNI 引用问题通常不会在第一分钟暴露,而是在反复进入页面、切后台、线程退出时出现。
建议观测:
GlobalRef 数量
native worker thread 数量
AttachCurrentThread 次数
DetachCurrentThread 次数
播放器 release 后剩余线程数
如果 AttachCurrentThread 次数持续增长,但 DetachCurrentThread 没增长,说明线程附着生命周期有泄漏。
如果 GlobalRef 数量每次进入播放页都增加,说明资源释放不完整。这个问题必须在发布前阻断,因为它会变成长时间播放或多次进入后的 native 内存泄漏。
排查顺序:
先看是否保存了 JNIEnv
再看 jobject 是否从 LocalRef 错当长期引用
再看 GlobalRef 是否成对 DeleteGlobalRef
最后看回调线程是否正确 Detach
本章结论
JNI 稳定性不是靠运气。JNIEnv 只属于当前线程,LocalRef 只属于当前调用,GlobalRef 需要手动释放。把这三条边界守住,native 回调 Kotlin 才有地基。