恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
(番外篇)从 GetEnv 到 AttachCurrentThread:彻底搞懂 JNI 的线程边界
首页
资讯中心
/
(番外篇)从 GetEnv 到 AttachCurrentThread:彻底搞懂 JNI 的线程边界
(番外篇)从 GetEnv 到 AttachCurrentThread:彻底搞懂 JNI 的线程边界
发布时间:2026/9/28 19:53:03
在 JNI 开发里你很容易遇到下面这段代码JNIEnv* env nullptr; bool attachedHere false; if (gVm-GetEnv( reinterpret_castvoid**(env), JNI_VERSION_1_6) JNI_EDETACHED) { gVm-AttachCurrentThread( reinterpret_castvoid**(env), nullptr); attachedHere true; }第一次看到时通常会连续冒出几个问题JavaVM*和JNIEnv*到底什么区别为什么JavaVM*可以跨线程用跨线程是不是 IPC为什么 native 线程不能直接拿JNIEnv*GetEnv()到底在干什么AttachCurrentThread()又是在干什么为什么这里突然出现了void**二级指针reinterpret_castvoid**(env)到底是什么意思其实这一小段代码背后正好串起了 JNI 多线程里最核心的一整套知识。一、先看这段代码到底想解决什么问题先不要管细节。把它翻译成人话当前线程想调用 Java但是它不知道自己有没有对应的JNIEnv*。于是先问 JVMgVm-GetEnv(...)如果 JVM 返回JNI_EDETACHED意思就是当前线程还没有接入 JVM。那就执行gVm-AttachCurrentThread(...)把当前线程挂到 JVM 上。挂完以后JVM 会给这个线程一个JNIEnv*之后这个线程才能调用env-FindClass(...) env-CallVoidMethod(...) env-NewStringUTF(...)所以整段代码本质只有一句话当前 native 线程要进入 JVM 世界先拿到属于自己的JNIEnv*。二、先分清 JavaVM* 和 JNIEnv*这是整篇最重要的基础。1. JavaVM*可以把JavaVM* gVm;理解成当前进程里的 JVM / ART 总入口。通常会在JNI_OnLoad里拿到JavaVM* gVm nullptr; JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void*) { gVm vm; return JNI_VERSION_1_6; }然后保存起来。JavaVM*最大的特点是同一个进程里的多个线程都可以使用它。比如App 进程 ├── Java 主线程 ├── Java 工作线程 ├── C std::thread ├── FFmpeg 回调线程 └── 音频处理线程这些线程虽然不同但都属于同一个进程。所以它们可以共享JavaVM* gVm;三、跨线程不是 IPC这里很容易误解。所谓JavaVM*可以跨线程使用并不是说发生了 IPC。IPC 是进程 A ↕ 进程 B例如App进程 ↕ Binder System Server这才叫进程间通信。而 JNI 这里说的跨线程是同一个进程 Thread A Thread B Thread C这些线程共享同一个进程地址空间。所以JavaVM* gVm;被多个线程访问只是普通的同进程内共享。没有 Binder。没有 Socket。也不是 IPC。一句话跨线程 ≠ 跨进程。四、为什么 JNIEnv* 不能跨线程再看JNIEnv* env;JNIEnv*和JavaVM*最大的区别是JNIEnv*是和线程绑定的。可以理解成Thread A → JNIEnv A Thread B → JNIEnv B Thread C → JNIEnv C每个线程都有自己对应的 JNI 环境。所以JNIEnv* envA;不能在线程 B 里乱用。例如JNIEnv* gEnv;然后在线程 A 中保存gEnv env;再在线程 B 中gEnv-CallVoidMethod(...);这是错误用法。因为这个JNIEnv*本质上属于线程 A。五、为什么 Java 调 JNI 时不用 GetEnv我们平时写 JNI 方法JNIEXPORT void JNICALL Java_com_demo_test( JNIEnv* env, jobject thiz) { }你会发现JNIEnv* envJVM 已经直接传进来了。为什么因为调用链是Java线程 ↓ JVM ↓ JNI ↓ C这个线程本身就是 JVM 管理的线程。所以 JVM 当然知道这个线程对应哪个JNIEnv*。因此直接作为参数给你。六、真正的问题出现在 C 自己创建线程时例如std::thread([] { // 做一些 native 工作 }).detach();这个线程是 C 创建的。调用链是C ↓ std::thread ↓ Native Thread它不是从 Java 调进来的。所以函数参数里根本没有JNIEnv* env这时候如果你还想调用 Javaenv-CallVoidMethod(...)问题就来了env从哪来答案就是JavaVM* ↓ GetEnv() ↓ AttachCurrentThread() ↓ JNIEnv*七、GetEnv() 到底干什么核心代码JNIEnv* env nullptr; jint result gVm-GetEnv( reinterpret_castvoid**(env), JNI_VERSION_1_6 );可以把它理解成JVM请帮我检查一下当前线程有没有自己的 JNI 环境。常见结果主要有两个。情况一JNI_OKresult JNI_OK说明当前线程已经接入 JVM。此时env已经被赋值。可以直接使用。情况二JNI_EDETACHEDresult JNI_EDETACHED说明当前线程还没有 Attach 到 JVM。这种情况最常见于std::thread pthread FFmpeg内部线程 音视频回调线程 第三方C/C库线程这时候就需要AttachCurrentThread()八、AttachCurrentThread() 到底是什么代码gVm-AttachCurrentThread( reinterpret_castvoid**(env), nullptr );意思是把当前 native 线程注册到 JVM。Attach 之前Native Thread X JVMJVM 不认识这个线程。Attach 之后Native Thread ↓ AttachCurrentThread ↓ JVM ↓ JNIEnv*于是当前线程就拥有了自己的JNIEnv* env;之后就可以调用 Java。九、为什么还要记录 attachedHere代码里有bool attachedHere false;Attach 成功以后attachedHere true;这是为了记录当前线程是不是我自己刚刚 Attach 的。因为一般原则是谁 Attach谁负责 Detach。所以后面通常会写if (attachedHere) { gVm-DetachCurrentThread(); }完整结构JNIEnv* env nullptr; bool attachedHere false; jint status gVm-GetEnv( reinterpret_castvoid**(env), JNI_VERSION_1_6 ); if (status JNI_EDETACHED) { gVm-AttachCurrentThread( reinterpret_castvoid**(env), nullptr ); attachedHere true; } // 使用 env 调 Java if (attachedHere) { gVm-DetachCurrentThread(); }十、最容易绕的地方为什么是 env现在进入 C/C 指针部分。先看JNIEnv* env nullptr;这里的env是一个一级指针。当前里面存的是nullptr我们希望GetEnv()调用之后它变成env ↓ 真正的 JNIEnv也就是说GetEnv()实际上需要修改env这个指针变量本身。十一、一级指针只能修改“指向的对象”例如int a 10; int* p a;此时p → a → 10如果函数拿到int* p那么*p 20;可以修改a因为它是通过地址找到对象。但p nullptr;只是在修改函数内部p的副本。外面的p不会改变。所以*p 20;属于修改指针指向的对象。而p nullptr;属于修改指针本身。这是两个完全不同的动作。十二、想改“指针本身”就得把指针的地址传进去假设int* p nullptr;如果函数想真正修改外面的pp 某个地址;就必须拿到p因为p是一个指针。那p就是这个指针变量自己的地址。类型自然就是int**所以pp → p → 对象这就是二级指针。一句话一级指针可以找到对象。二级指针可以找到那个一级指针。十三、回到 JNI我们有JNIEnv* env nullptr;现在 JVM 要完成env 当前线程真正的JNIEnv地址;那 JVM 就必须拿到env所以env的类型是JNIEnv**这就是为什么这里出现二级指针。本质不是为了炫技。而是因为JVM 需要修改外面的env指针变量本身。十四、reinterpret_castvoid**(env) 又是什么GetEnv()的接口大致定义成jint GetEnv(void** env, jint version);它要求第一个参数是void**但我们手里的env类型是JNIEnv**类型对不上。所以需要reinterpret_castvoid**(env)也就是JNIEnv** ↓ void**这里只是为了满足 JNI API 的参数类型要求。最核心的仍然是env而不是reinterpret_cast。十五、JNI_VERSION_1_6 是什么第二个参数JNI_VERSION_1_6只是告诉 JVM我按照 JNI 1.6 这个版本来获取环境。Android JNI 中非常常见。一般也会和JNI_OnLoad返回值保持一致JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void*) { gVm vm; return JNI_VERSION_1_6; }十六、完整流程串起来现在再看原始代码JNIEnv* env nullptr; bool attachedHere false; if (gVm-GetEnv( reinterpret_castvoid**(env), JNI_VERSION_1_6) JNI_EDETACHED) { gVm-AttachCurrentThread( reinterpret_castvoid**(env), nullptr); attachedHere true; }已经可以直接翻译成先准备一个 JNIEnv* 指针 ↓ 现在 env 还是 nullptr ↓ 通过 JavaVM 查询当前线程是否已经接入 JVM ↓ 如果已经接入 → JVM直接把当前线程的 JNIEnv* 写进 env ↓ 如果还没接入 → 返回 JNI_EDETACHED ↓ 调用 AttachCurrentThread ↓ 把当前 native 线程注册到 JVM ↓ JVM把当前线程对应的 JNIEnv* 写入 env ↓ attachedHere true ↓ 后续使用完成后负责 Detach十七、最终应该形成的几个条件反射第一JavaVM*想成进程级 JVM 入口。可以在同一进程的多个线程中使用。第二JNIEnv*想成当前线程和 JVM 通信的入口。不能跨线程乱用。第三看到GetEnv()想到查询当前线程是否已经有自己的JNIEnv*。第四看到AttachCurrentThread()想到native 线程正式接入 JVM。第五看到JNIEnv**或者env想到JVM 要修改env这个一级指针本身。第六看到reinterpret_castvoid**(env)想到env 本来是 JNIEnv** ↓ JNI API 要 void** ↓ 所以做一次类型转换十八、最后用一句话理解整段代码以后再看到GetEnv AttachCurrentThread DetachCurrentThread你可以直接在脑子里翻译成“这个 native 线程现在要调用 Java所以它先要加入 JVM并拿到属于自己的JNIEnv*。”这就是这段代码真正的核心。