(第一篇)JNI 多线程到底难在哪:从 Linux Thread 到 JNIEnv

第一次接触 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 对象引用,到底还能不能用?

相关推荐
逗脑IDE4 小时前
零基础学ESP32:蓝牙控制舵机——手机一点,舵机就转!
android·智能手机
清霜之辰4 小时前
骁龙 8397 舱驾融合平台端侧大模型部署:7B 模型本地推理与 64GB 内存账本
android
烽火聊员4 小时前
Android屏幕卡顿traces.txt拉取查看
android·java·android studio
只睡四小时6 小时前
JS 手写 D-pad 空间导航:电视端焦点引擎实战
android·开发语言·前端·javascript·ecmascript·hls·空间导航
恋猫de小郭6 小时前
Android 原生的 Compose A2UI 也来了,你还抱着 XML 养老吗?
android·前端·flutter
程序媛_6 小时前
【JMeter】准备token文件
android·jmeter
维克兜率天6 小时前
【维克】弹性策略:用“乖离率“捕捉超跌反弹
android·开发语言·python·深度学习·kotlin·量化
古法安卓7 小时前
Android-Ext4文件系统问题排查
android·java·android studio
传奇开心果编程9 小时前
【用案例学Material 3 Expressive】第1课:从一封邮件开始,感受安卓设计新语言的表现力
android·学习·ui·kotlin·android jetpack