第一次接触 JNI 多线程,很容易被这些词绕进去:
JNIEnv
JavaVM
std::thread
AttachCurrentThread
DetachCurrentThread
单独查每个 API,好像都不难。但它们一放到一起,问题就来了:
Java 调 JNI 为什么天然就有 JNIEnv?
C++ 自己创建线程以后,
Linux 不是已经管理这条线程了吗?
为什么还要 AttachCurrentThread?
Attach 到底 Attach 了什么?
std::thread(...).detach()
和
JavaVM->DetachCurrentThread()
又是不是一回事?
后来我发现,这些问题其实都可以从一个问题开始推:
当前到底是哪一条线程?这条线程是谁创建的?Linux 认不认识它?ART 又认不认识它?
先把线程身份弄清楚,JNIEnv、JavaVM、Attach / Detach 就不用死记了。
一、先别谈 JNI:线程到底是什么?
一个 Android App 通常运行在一个 Linux 进程里。
进程提供地址空间,而真正被 CPU 调度执行代码的是线程。一个进程里可以同时存在很多线程。
这些线程共享 Java Heap、Native Heap、全局变量等大量数据,但每条线程又拥有自己的调用栈、寄存器、程序计数器等执行状态。JNI多线程_从线程本质到真正理解透
这里首先要建立一个很重要的认识:
Java Thread
Kotlin Thread
C++ std::thread
↓
最终都落到
↓
Linux / OS Thread
OS 是 Operating System,也就是操作系统。
Android 底层使用 Linux 内核,所以在我们当前讨论里,可以先粗略理解为:
OS Thread ≈ Linux Thread
也就是说,不管上面写的是:
new Thread(...)
还是:
std::thread(...)
最终下面都要对应真实的 Linux 线程。
真正值得关注的区别不是:
"它是不是线程?"
而是:
这条线程是谁创建的,以及它现在被哪些运行时认识。
这也是后面 JNI 多线程所有问题的起点。JNI多线程_从线程本质到真正理解透
二、Java 调 JNI,为什么默认不会切线程?
假设 Kotlin 主线程调用:
external fun nativeFoo()
nativeFoo()
对应 C++:
JNIEXPORT void JNICALL
Java_com_demo_MainActivity_nativeFoo(
JNIEnv* env,
jobject thiz) {
// Native 代码
}
看到:
Java
↓
JNI
↓
C++
很容易误以为:
"进入 C++ 以后,是不是已经换了一条线程?"
其实没有。
正常情况下,它只是:
Main Thread
↓
Kotlin 方法
↓
JNI Bridge
↓
C++ nativeFoo()
还是原来的 Main Thread。
只是这条线程执行的代码从 Java/Kotlin 进入了 Native。
所以:
跨 JNI ≠ 切线程。
真正会让线程发生变化的是你主动做了线程调度,例如:
std::thread
Executor
Handler
Coroutine Dispatcher
而不是 JNI 边界本身。JNI多线程_从线程本质到真正理解透
所以:
nativeFoo(JNIEnv* env, ...)
这里的 env 属于谁?
如果主线程调用:
Main Thread
↓
nativeFoo()
↓
当前线程对应的 JNIEnv
如果 Java 工作线程调用:
Worker Thread
↓
nativeFoo()
↓
这条 Worker Thread 对应的 JNIEnv
于是问题来了:
JNIEnv 到底是什么?
三、JNIEnv 不只是"函数表"
以前我很容易把 JNIEnv 理解成:
"JNI 函数表。"
因为我们经常这么写:
env->FindClass(...);
env->GetMethodID(...);
env->CallVoidMethod(...);
env->NewStringUTF(...);
从使用形式上看,这个理解并不算错。
但如果只理解到这里,就解释不了一个非常重要的问题:
为什么一个线程拿到的
JNIEnv*,不能随便给另一个线程使用?
更准确的理解是:
JNIEnv 是当前线程使用 JNI、进入 Java/ART 世界时的线程级入口。
它背后不仅提供 JNI 函数调用能力,还和当前线程的 JNI / VM 状态关联。JNI多线程_从线程本质到真正理解透
ART 在处理 JNI 调用时,不只是要知道:
你要调用哪个 Java 方法?
它还必须知道:
现在是哪条线程在调用?
因为很多东西本身就是线程相关的,例如局部引用表、异常状态,以及运行时和 GC 协作需要维护的线程状态。
所以概念上可以这样理解:
ART
Thread A
↓
JNIEnv A
Thread B
↓
JNIEnv B
这张图强调的是线程关联关系,不是说一定提前创建了两个独立对象在那里等着。
最重要的是:
Thread A 的 JNIEnv
不能直接拿给
Thread B 使用
所以这种代码有问题:
static JNIEnv* gEnv = nullptr;
JNIEXPORT void JNICALL
nativeStart(JNIEnv* env, jobject thiz) {
gEnv = env;
std::thread([] {
gEnv->CallVoidMethod(...); // 错误风险
}).detach();
}
为什么?
一步一步看。
nativeStart() 是某条 Java 线程调用进来的,所以:
env
属于原来的 Java 调用线程
而:
std::thread(...)
又创建了一条新的 Linux 线程。
于是变成:
Thread A 的 JNIEnv
↓
拿给了
↓
Thread B 使用
真正的问题不是"不能把 JNIEnv 放全局变量"。
而是:
JNIEnv 所对应的线程身份错了。
四、那 JavaVM 是干什么的?
到这里自然会出现一个问题:
JNIEnv是线程级的,那么 C++ 自己创建出来的新线程,一开始连可用的JNIEnv都没有,它靠什么进入 Java 世界?
这就是 JavaVM* 的作用。
先把两个概念分开:
JNIEnv
→ 线程级 JNI 入口
JavaVM
→ VM 级接入入口
通常我们会在:
JNI_OnLoad()
中保存 JavaVM*:
static JavaVM* gVm = nullptr;
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void*) {
gVm = vm;
return JNI_VERSION_1_6;
}
JavaVM 不属于某一条具体线程,所以它可以被保存下来。
以后某个 Native 线程需要进入 Java/ART 世界时,可以通过它做:
GetEnv
AttachCurrentThread
DetachCurrentThread
这些事情。
而具体:
FindClass
CallVoidMethod
NewStringUTF
仍然是通过当前线程的 JNIEnv 完成。JNI多线程_从线程本质到真正理解透
所以可以先记成:
JavaVM
→ 解决"当前线程怎么接入 VM"
JNIEnv
→ 解决"当前线程接入以后怎么使用 JNI"
五、C++ 创建线程以后,Linux 不是已经管理它了吗?
比如:
std::thread([] {
// Native Thread
}).detach();
std::thread 创建成功以后,这已经是一条真实的 Linux / OS 线程。
Linux 当然已经在管理它。
比如 Linux 会关心:
线程 ID
CPU 调度
线程运行 / 睡眠状态
寄存器
线程栈
上下文切换
所以:
Linux:我认识它。
完全没问题。
但是:
ART:我还不一定认识它。
这就是最重要的分层。
可以画成:
Native Thread D
Linux
└── Thread D ✅
ART
└── Thread D ❌
这条线程此时完全可以做普通 C/C++ 工作:
计算
文件 IO
Native 数据处理
Socket 操作
因为这些只需要 Linux 和 Native 环境。
但是,如果它突然想:
env->CallVoidMethod(...)
或者:
env->FindClass(...)
性质就变了。
它已经不只是普通 Native 代码了,而是想进入:
Java / ART Runtime
这时 ART 必须认识:
"现在到底是哪条 Linux 线程正在进入我的运行时?"
六、AttachCurrentThread 到底 Attach 的是什么?
所以:
gVm->AttachCurrentThread(...)
不是创建线程。
线程早就已经被:
std::thread
创建好了。
它也不是简单:
"给当前线程复制一张 JNI 函数表。"
更准确地理解:
AttachCurrentThread 是把当前这条已经存在的 Linux / Native 线程接入 ART/VM 的线程体系。
概念上的过程是:
std::thread
↓
创建 Linux Thread D
↓
Linux 已认识
ART 未认识
↓
AttachCurrentThread()
↓
ART 为当前线程建立/关联
它需要的运行时线程状态
↓
返回当前线程可使用的 JNIEnv*
于是:
Native Thread D
Linux
└── Thread D ✅
ART
└── Thread D ✅
↓
当前线程可用的 JNIEnv*
所以我们经常说:
"Attach 以后获得这条线程自己的
JNIEnv。"
这个"自己的"并不是说:
"原来 somewhere 已经放好了一个
JNIEnv D,现在把它取出来。"
而是说:
ART 已经正确建立了当前线程与 JNI/运行时状态之间的关系,因此现在可以拿到当前线程可使用的
JNIEnv*。
文档中对这一点的描述很重要:Native 自建线程已经是真实存在的 Linux 线程,但因为它不是从 Java → JNI 调用链进入,所以不能假设它天然拥有一个可使用的 JNIEnv*。JNI多线程_从线程本质到真正理解透
七、GetEnv、Attach、Detach,不要背 API
真正写代码时,我们不应该想:
"Native 线程一定先 Attach。"
而应该先问:
当前这条线程现在是不是已经 Attach 到 VM?
所以会看到:
JNIEnv* env = nullptr;
bool attachedHere = false;
if (gVm->GetEnv(
reinterpret_cast<void**>(&env),
JNI_VERSION_1_6) == JNI_EDETACHED) {
gVm->AttachCurrentThread(
reinterpret_cast<void**>(&env),
nullptr);
attachedHere = true;
}
GetEnv() 的作用可以先理解成:
问 VM:
"当前这条线程你认识吗?"
认识
→ 返回当前线程对应的 JNIEnv
不认识
→ JNI_EDETACHED
如果没 Attach:
AttachCurrentThread()
让 ART 接入当前线程。
使用完以后:
if (attachedHere) {
gVm->DetachCurrentThread();
}
为什么判断 attachedHere?
因为原则很简单:
如果是我自己 Attach 的,就由我负责 Detach。
文档里也是按照这个"当前线程是否已经 Attach"的状态来推导 GetEnv / Attach / Detach 的。JNI多线程_从线程本质到真正理解透
八、这里有两个 detach,千万别混
这里特别容易踩一个坑。
前面代码中出现过:
std::thread([] {
// Native Thread
}).detach();
后面又出现:
gVm->DetachCurrentThread();
名字都有:
detach
但它们完全不是一回事。
下面这部分属于 C++ std::thread 自身的线程语义,是为了帮助区分这两个同名操作,并不是 JNI 文档本身的重点。
先看:
std::thread::detach()
例如:
std::thread t([] {
doWork();
});
t.detach();
它表达的是:
这条线程继续自己运行,我不再通过这个
std::thread对象等待它结束。
如果写:
t.join();
意思则是:
当前线程
↓
等待 t 执行结束
↓
然后继续
所以可以粗略记:
join()
= 我等你执行完
detach()
= 你自己跑,我不等你
而:
gVm->DetachCurrentThread();
完全是另一回事。
它表示:
解除当前 Native Thread 与 ART/VM 之间建立的 Attach 关系。
所以两个 detach 应该这样区分:
std::thread::detach()
↓
C++ Thread 层面
↓
"调用者不再 join / 等待这条线程"
而:
JavaVM->DetachCurrentThread()
↓
JNI / ART 层面
↓
"当前线程退出 ART/VM 的接入关系"
它们甚至可以同时存在:
std::thread([] {
JNIEnv* env = nullptr;
gVm->AttachCurrentThread(
reinterpret_cast<void**>(&env),
nullptr);
// 使用 env 调 Java
gVm->DetachCurrentThread();
}).detach();
这里:
gVm->DetachCurrentThread();
是在说:
当前 Native Thread
退出 ART
而最后:
}).detach();
是在说:
创建它的 std::thread 对象
以后不负责 join 等它
所以这两个一定不要因为名字一样就混为一谈。
九、把 Java Thread 和 Native Thread 放到一起
现在整个模型就已经很清楚了。
Java/Kotlin 创建、并且已经运行在 ART 管理体系里的线程:
Java Thread
↓
ART 已认识
↓
调用 JNI
↓
线程没有改变
↓
获得当前线程可用的 JNIEnv
所以:
Java/Kotlin → JNI
通常不需要自己再做:
AttachCurrentThread()
而 C++ 自己:
std::thread(...)
创建出来的线程:
Native Thread
↓
Linux 已认识
↓
ART 未必认识
如果它只做 Native 工作:
不需要 Attach
如果它要进入 Java:
Native Thread
↓
JavaVM
↓
GetEnv
↓
未 Attach
↓
AttachCurrentThread
↓
得到当前线程的 JNIEnv
↓
调用 Java
↓
如果是自己 Attach
↓
DetachCurrentThread
这条线就是 JNI 多线程最基础、也是最重要的一条线。
十、现在再看 JNIEnv,就不应该只想到"函数表"
JNIEnv 背后确实提供了一套 JNI 函数调用能力。
但真正理解以后,脑子里应该多一层:
JNIEnv 是当前线程与 JNI/ART 交互的线程级入口。
因此:
Thread A
↓
JNIEnv A
Thread B
↓
JNIEnv B
不要做:
Thread A 的 env
↓
交给 Thread B
这也是为什么学 JNI 多线程时,最值得形成的习惯不是背:
JNIEnv 不能跨线程
而是每看到一个 env,都先问:
这个 env 到底属于哪条线程?
这才是真正稳定的理解方式。
十一、最后只留下一张图
Android 进程
│
Linux / OS Thread
│
┌────────────┴────────────┐
│ │
Java/Kotlin Thread C++ std::thread
│ │
ART 已认识 Linux 已认识
│ ART 未必认识
│ │
调用 JNI 想调用 Java?
│ │
↓ ↓
当前线程可用的 JNIEnv JavaVM / GetEnv
│
未 Attach?
│
↓
AttachCurrentThread
│
↓
当前线程可用的 JNIEnv
│
调 Java
│
如果是自己 Attach
↓
DetachCurrentThread
而:
std::thread::detach()
属于旁边另外一条线:
std::thread
↓
线程执行关系
↓
join:等待线程结束
detach:不再等待线程
它和:
JavaVM->DetachCurrentThread()
不要混。
十二、这一篇真正需要掌握什么?
看完这一篇,不需要背大量 JNI API。
只要你能自己解释下面五句话,这一篇就算真的理解了。
Java/Kotlin 调 JNI,只是代码跨语言边界,默认不会自动切线程。
JNIEnv*是当前线程使用 JNI 的入口,它和线程状态有关。
C++std::thread创建以后已经是一条真实 Linux 线程,Linux 从一开始就在管理它。
AttachCurrentThread()不是创建线程,而是让现有 Native Thread 接入 ART/VM,从而获得当前线程可使用的JNIEnv*。
std::thread::detach()和JavaVM->DetachCurrentThread()完全是两个层面的操作:前者属于 C++ 线程管理,后者属于 JNI/ART 线程接入关系。
这也是这篇文章真正想建立的第一个判断:
现在是哪条线程?这条线程是谁创建的?ART 认不认识它?
解决完"线程身份",下一篇再进入 JNI 多线程的第二条主线:
下一篇
《LocalRef 和 GlobalRef:JNI 对象生命周期到底怎么回事》
线程能正确调用 Java,只说明"人进门了"。
下一步还要解决:
你手里的那个 Java 对象引用,到底还能不能用?