ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JNI线程同步实战:从JNIEnv绑定到AttachCurrentThread与锁机制详解

JNI线程同步实战:从JNIEnv绑定到AttachCurrentThread与锁机制详解 JNI 开发的坑十有八九出在线程和同步上。你可能会遇到这种场景本地代码跑得好好的一放到后台线程就崩溃或者明明 Java 层已经加了 synchronizedNative 层还是在并发请求时拿到脏数据。我写了五六年的 C 和 Java 混合层几乎每一个复杂的 JNI 模块——不管是音视频采集、图像识别还是数据库同步软件的底层桥接——都要面对同一个问题Java 世界和 Native 世界各自的线程模型怎么对齐。这篇就聊聊 JNI 编程里的线程操作与同步把 JNIEnv 的线程绑定、AttachCurrentThread、MonitorEnter、全局引用这些硬骨头一次说清楚。适合已经写过简单 JNI 程序、想深入多线程场景的开发者哪怕你只在 CLion 里跑通第一个 HelloWorld也能顺着思路往下走。1. 线程问题的起点Java 与 Native 的线程生态差异1.1 JNIEnv 为什么不能跨线程用关键在于 JNIEnv 并不是一个普通的全局对象它是 JVM 为每个“已连接”的线程单独分配的线程局部数据结构。可以把它理解成一张写着线程身份的门禁卡A 线程的卡去刷 B 线程的门系统直接不认。JNI 规范里也明确规定JNIEnv 只能在其所属线程内使用跨线程传递并调用轻则拿到错误的数据重则直接触发致命信号。很多入门教程里的写法是在 JNI_OnLoad 里保存一个 JNIEnv* 到全局变量然后其他线程拿来就用。这种代码在单线程测试时通常没事因为线程还没切换一旦真正并发起来立刻原形毕露。正确的做法是只保存 JavaVM*需要 JNIEnv 时在当前线程重新获取。JavaVM 是整个进程唯一的虚拟机入口相当于公司前台的总机号码谁需要谁就能通过它找到当前线程自己的分机。提示全局保存 JNIEnv* 是 JNI 多线程开发里最常见的第一大坑不要在结构体或全局变量里长期持有它。1.2 GetEnv 返回三种状态怎么理解获取当前线程的 JNIEnv 要调用 JavaVM 的 GetEnv 方法。这个函数的返回值其实就是线程附着状态的诊断书返回值含义常见原因JNI_OK当前线程已附着env 可直接使用从 Java 调进来的本地方法内部JNI_EDETACHED当前线程没有附着到 JVM自己用 pthread_create 创建的线程JNI_EVERSIONJNI 版本不兼容请求的版本号高于 JVM 支持版本GetEnv 本身不会把线程“挂上户口”它只是检查。如果返回 JNI_EDETACHED你需要先调用 AttachCurrentThread 附着然后才能安全使用 env。这个检查是构建通用回调工具的关键。在代码里最常看到的是这样一段分派逻辑JNIEnv* env nullptr; int status g_vm-GetEnv(reinterpret_castvoid**(env), JNI_VERSION_1_6); if (status JNI_EDETACHED) { g_vm-AttachCurrentThread(env, nullptr); } else if (status ! JNI_OK) { // JNI_EVERSION 或其它错误 return nullptr; }注意 GetEnv 的参数类型在某些编译器下需要 reinterpret_cast直接传 JNIEnv** 可能报类型不匹配。这是初学者很容易被编译错误卡住的地方看起来是小问题实际每一层类型都对应 JVM 的稳定 ABI不能凭感觉乱写。1.3 什么时候才需要自己创建 Native 线程不是所有 JNI 场景都需要手动建线程。如果你只是从 Java 调用一个本地方法这个本地方法运行在调用它的 Java 线程上直接用传进来的 env 即可完全不需要 Attach。真正需要自己开线程的情况通常是这些轮询硬件设备状态、持续接收底层回调、批量计算任务放到独立线程、异步日志写盘等。自建 native 线程的好处是开销小于 Java 线程且能直接访问 pthread 相关能力坏处是线程生命周期、优先级、崩溃影响范围都需要自己管理。我的建议是能用 Java 线程池就优先用 Java 线程池实在需要底层线程时再走 pthread_create然后在线程函数里完成 Attach。线程的“户口”问题不是要不要面对而是早晚要面对。2. Attach 与 Detach给 Native 线程“上户口”2.1 AttachCurrentThread 的正确用法JavaVM 全局指针是可以在 JNI_OnLoad 里保存的它是整个进程层面唯一的。去线程函数里做附着时可以用它。完整流程是这样在 JNI_OnLoad 中保存 JavaVM 指针。在线程函数开始处调用 GetEnv 检查。若为 JNI_EDETACHED调用 AttachCurrentThread。在线程退出前调用 DetachCurrentThread。static JavaVM* g_vm nullptr; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { g_vm vm; return JNI_VERSION_1_6; } void* workerThread(void* args) { JNIEnv* env nullptr; JavaVMAttachArgs attachArgs; attachArgs.version JNI_VERSION_1_6; attachArgs.name const_castchar*(JNIWorker); attachArgs.group nullptr; if (g_vm-GetEnv(reinterpret_castvoid**(env), JNI_VERSION_1_6) ! JNI_OK) { if (g_vm-AttachCurrentThread(env, attachArgs) ! JNI_OK) { return nullptr; } } // 这里 env 已经可用可以安全调用 JNI 函数 g_vm-DetachCurrentThread(); return nullptr; }注意 JavaVMAttachArgs.name 可以给线程起名这个名字在 jstack 或调试器里能看到对排查问题特别有帮助。group 参数可以指定线程组通常传 nullptr。另外还有一个 AttachCurrentThreadAsDaemon 方法适合把底层线程挂成守护线程JVM 退出时不会因为没 Detach 而挂住进程。在移动端做常驻 native 服务时守护线程的方式更省心。2.2 DetachCurrentThread 的时机与坑有 attach 就必须有 detach。常见的错误是把 Detach 写在 pthread_create 的外层也就是由创建者去 detach 子线程这是不行的detach 必须发生在当前线程自己的上下文里。因为 JVM 内部记录的附着线程信息挂在当前线程上别的线程没法替它“销户”。另一个坑是线程被 Java 侧创建然后调用 native 方法在 native 方法里又开了一个 pthread 子线程子线程结束前需要 detach而原来的 native 方法线程是 JVM 已经附着的不应当 detach。所以每个上下文都要区分清楚。我自己在项目里见过不少人把这两种场景混在一起结果要么多 detach 导致崩溃要么漏 detach 导致线程泄漏。最稳妥的做法是使用 RAII 思想在线程函数入口处获取 env出口处统一释放。例如class ScopedJniEnv { public: ScopedJniEnv() : env_(nullptr), needsDetach_(false) { if (!g_vm) return; if (g_vm-GetEnv(reinterpret_castvoid**(env_), JNI_VERSION_1_6) JNI_OK) { return; } JavaVMAttachArgs args {JNI_VERSION_1_6, const_castchar*(ScopedThread), nullptr}; if (g_vm-AttachCurrentThread(env_, args) JNI_OK) { needsDetach_ true; } } ~ScopedJniEnv() { if (needsDetach_ g_vm) { g_vm-DetachCurrentThread(); } } JNIEnv* get() const { return env_; } JNIEnv* operator-() const { return env_; } private: JNIEnv* env_; bool needsDetach_; };这个封装能避免一长串函数里某个 return 漏掉 detach 的问题。多线程 JNI 的崩溃有相当一部分就是“少一次 detach”或者“多一次 detach”导致的。封装之后每个线程函数只要在栈上构造一个 ScopedJniEnv剩下的路径就不用手动管理了。2.3 线程局部数据与 TLS 的连带影响Attach 之后JVM 会把当前线程与一个内部 Thread 对象关联同时在线程局部存储里挂上 JNIEnv 指针。也就是说你对 Java 同步对象的操作、对全局引用的操作都会被记录到这个线程上下文中。这带来一个使用细节如果先在一个线程里附着后来又在一个 C 线程池里复用它例如通过 std::thread 重新执行的同一段代码千万不要理所当然地以为 env 还是附着时的那个。每次线程函数入口都要重新判断与获取。此外线程本地里积累的局部引用不会跨线程传递线程结束时随线程回收。这也是不能跨线程传 env 的底层原因。3. 同步机制从 Java synchronized 到 Native 锁3.1 用 JNI 的 MonitorEnter / MonitorExit 和 Java 锁互通JNI 提供了 MonitorEnter 和 MonitorExit 两个函数它们操作的就是 Java 对象的监视器锁效果等同于 synchronized 块。当一个 Java 线程持有某对象的 monitor而 native 线程也通过 MonitorEnter 尝试进入同一把锁时两边会真正进入竞争关系。JNIEXPORT void JNICALL Java_JniThreadDemo_nativeSync(JNIEnv* env, jobject thiz) { if (env-MonitorEnter(thiz) ! JNI_OK) { return; } // 这里受 Java 侧锁保护其它 Java synchronized(thiz) 代码块也会等待 if (env-MonitorExit(thiz) ! JNI_OK) { // 如果退出失败说明 monitor 状态异常 } }为什么需要这种跨语言锁典型场景是Java 层维护一个对象池native 层也要往里面写数据两边都要保证互斥。如果在 native 层只用 std::mutexJava 层的 synchronized 感知不到就会出现“native 认为自己在锁里Java 线程也认为自己进了锁”的双重访问问题。使用 MonitorEnter 要特别注意成对性进入成功之后无论中间是否提前 return都要调用 MonitorExit。所以更推荐把它也包一层 RAIIclass ScopedMonitor { public: ScopedMonitor(JNIEnv* env, jobject obj) : env_(env), obj_(obj) { if (env_-MonitorEnter(obj_) ! JNI_OK) obj_ nullptr; } ~ScopedMonitor() { if (obj_) env_-MonitorExit(obj_); } private: JNIEnv* env_; jobject obj_; };3.2 纯 Native 锁std::mutex、pthread_mutex、atomic不是所有临界区都需要跨 Java 可见。很多 native 内部的数据结构例如缓存队列、线程标志位、统计计数器可能完全不和 Java 打交道这时候用 C 的锁更轻量。std::mutex 在多数平台都映射到 pthread_mutex使用简单std::mutex g_cacheMutex; std::vectorstd::string g_cache; void updateCache(const std::string data) { std::lock_guardstd::mutex lock(g_cacheMutex); g_cache.push_back(data); }如果只是标记位或计数器std::atomic 比锁更合适。JNI 层同样适用这个原则。很多人喜欢把能 CAS 的地方也加锁结果在频繁回调路径上白白损失性能。那什么时候用 MonitorEnter什么时候用 std::mutex我的经验是对 Java 可见的共享对象用 Monitor纯 native 内部资源用 std::mutex简单计数用 atomic。混用的核心风险在于锁顺序这也是下一节的重点。3.3 跨层死锁实战分析跨语言死锁是 JNI 多线程里排查成本最高的故障。一个非常典型的结构是线程 A 是 native 线程持有 std::mutex接着调用 Java 同步方法想去等 Java 对象的 monitor线程 B 是 Java 线程持有同一个 Java monitor然后调用 native 方法native 方法里需要获取那把 std::mutex。两个线程形成环形等待任何一方都释放不了整个进程像被按了暂停键。jstack 只能看到 Java 线程 B 停在 native 方法上看不到线程 A 的 pthread 状态非常误导人。这时候要用 gdb 看 pthread 的栈确认线程 A 卡在哪把锁上。规避手段最有效的是固定锁顺序永远先拿 std::mutex 再拿 Java monitor或者反过来全项目统一不允许任何一处反着拿。其次是在 native 侧用 try_lock拿不到就先返回再通过 Java 回调重新调度。还可以给 JVM 加 -XX:PrintConcurrentLocks 或者用 jstack -l 看锁的持有关系。另外要警惕回调时机如果 native 线程已经拿着锁然后又回调一个 Java 方法而这个 Java 方法反过来又调用 native 方法加同一把锁那就是自己锁自己。这种问题在同步回调设计中很容易出现往往表现为偶发卡死。3.4 内存可见性与原子性JNI 层写一份内存Java 层另一线程读如果没有同步保护光靠 Java 或 C 的 volatile 是不足以保证跨层内存可见性的。最安全的方式是使用 Java 对象监视器或者原子操作让 JVM 统一处理内存屏障。例如 native 线程要更新 Java 层的一个状态字段不要直接去改写对象内存你也不可能直接改而是调用 Java 的 setter 方法setter 方法内部用 volatile 或加锁。这样 JVM 的内存模型才能覆盖到这次写入。反过来Java 层写入一个 intnative 层用 GetIntField 去读也需要同一把锁或字段是 volatile。没有同步机制的跨层共享本质上是数据竞争行为未定义。4. 实操在 CLion 中搭建 JNI 环境并实现线程安全回调4.1 环境准备CLion JDK 配置使用 CLion 开发 JNI 比在命令行里敲 gcc 舒服很多关键是解决头文件路径和库路径。先在系统里安装 JDK 8 或更高版本配置好 JAVA_HOME。Linux 下需要在 CMakeLists.txt 里引入 JNI 头文件cmake_minimum_required(VERSION 3.20) project(jnithread) set(CMAKE_CXX_STANDARD 11) set(JAVA_HOME $ENV{JAVA_HOME}) include_directories(${JAVA_HOME}/include ${JAVA_HOME}/include/linux) add_library(jnithread SHARED jni_thread_demo.cpp)Windows 平台把 linux 目录换成 win32macOS 换成 darwin。如果 CLion 提示找不到 jni.h优先检查 JAVA_HOME 是否被正确加载到环境变量里。很多人在这一步卡住其实不是 CMake 语法问题而是 JDK 安装目录没选对尤其 Windows 上 JDK 默认路径带空格CMake 解析时要留意。4.2 Java 侧定义与头文件生成先准备一个带 native 方法的 Java 类。Java 侧要提供回调方法和同步方法public class JniThreadDemo { static { System.loadLibrary(jnithread); } public native void startNativeThread(); public native void nativeSyncMethod(); public void onProgress(String message, int percentage) { System.out.println(message percentage %); } public synchronized void syncMethod() { System.out.println(sync method enters); } public static void main(String[] args) throws Exception { JniThreadDemo demo new JniThreadDemo(); demo.startNativeThread(); Thread.sleep(1000); } }在项目目录执行javac -h . JniThreadDemo.javajavac -h 会编译生成 .class 文件同时生成 JniThreadDemo.h里面声明的函数名是 Java_JniThreadDemo_startNativeThread 这样的标准名称。把它 include 进 C 文件就行。4.3 C 侧完整实现整个实现分四块保存 JavaVM、缓存全局引用和方法 ID、创建 pthread、在线程回调中安全调用 Java 方法。先看主体#include jni.h #include pthread.h #include JniThreadDemo.h static JavaVM* g_vm nullptr; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { g_vm vm; return JNI_VERSION_1_6; } static jobject g_obj nullptr; static jmethodID g_onProgress nullptr; static jmethodID g_syncMethod nullptr; void* workerThread(void* args) { JNIEnv* env nullptr; bool needDetach false; if (g_vm-GetEnv(reinterpret_castvoid**(env), JNI_VERSION_1_6) ! JNI_OK) { JavaVMAttachArgs attachArgs {JNI_VERSION_1_6, const_castchar*(JNIWorker), nullptr}; if (g_vm-AttachCurrentThread(env, attachArgs) ! JNI_OK) { return nullptr; } needDetach true; } if (g_obj ! nullptr g_onProgress ! nullptr) { jstring msg env-NewStringUTF(native thread running); env-CallVoidMethod(g_obj, g_onProgress, msg, 66); if (env-ExceptionCheck()) { env-ExceptionDescribe(); env-ExceptionClear(); } env-DeleteLocalRef(msg); } if (needDetach) { g_vm-DetachCurrentThread(); } return nullptr; } JNIEXPORT void JNICALL Java_JniThreadDemo_startNativeThread(JNIEnv* env, jobject thiz) { if (g_obj nullptr) { g_obj env-NewGlobalRef(thiz); jclass clazz env-GetObjectClass(thiz); g_onProgress env-GetMethodID(clazz, onProgress, (Ljava/lang/String;I)V); g_syncMethod env-GetMethodID(clazz, syncMethod, ()V); env-DeleteLocalRef(clazz); } pthread_t tid; pthread_create(tid, nullptr, workerThread, nullptr); pthread_detach(tid); } JNIEXPORT void JNICALL Java_JniThreadDemo_nativeSyncMethod(JNIEnv* env, jobject thiz) { if (env-MonitorEnter(thiz) ! JNI_OK) return; // 与 Java 的 synchronized(thiz) 互斥 env-MonitorExit(thiz); }这段代码里有一个关键点在 JNI_OnLoad 阶段只能保存 vm不能保存 env因为 OnLoad 运行时的 env 属于加载库的线程。而全局引用和方法 ID 是可以在多线程间共享的所以可以缓存。方法 ID 的查找最好只做一次因为 GetMethodID 每次调用都有额外开销而且 class 引用是局部引用用完要释放。DeleteLocalRef(clazz) 这步容易漏漏了虽然不一定立刻崩但在循环场景里会出现局部引用表溢出。4.4 编译与运行调试细节编译mkdir build cd build cmake .. make生成 libjnithread.so 后回到 Java 代码目录运行java -Djava.library.path./build JniThreadDemo运行后应该看到 native 线程打印的 Progress 输出同时 Java 主线程 sleep 1 秒后退出。如果输出没出现先检查 System.loadLibrary 是否加载成功再检查 AttachCurrentThread 的返回值。在 CLion 里调试多线程 native 代码时建议在 workerThread 的回调调用处打断点然后使用 Debug 模式启动 Java 程序通过添加 JVM 启动参数 -agentlib:jdwptransportdt_socket,servery,suspendy,address*:5005。CLion 的 Debugger 需要设置为混合调试模式。这样 Java 线程栈、native pthread 栈都能看到。5. 常见问题与排查技巧实录5.1 错误码、引用规则、同步选型速查表把常用结论整理成三张表遇到问题先查表往往比打电话问人快。GetEnv 状态返回值含义处理动作JNI_OK当前线程已附着直接用 envJNI_EDETACHED线程未附着AttachCurrentThread 后再用JNI_EVERSION版本不匹配检查 JNI_VERSION_1_6 与 JVM 实际版本引用类型类型生命周期跨线程注意点局部引用 LocalRef当前 native 方法返回后失效不能跨线程循环里要 DeleteLocalRef全局引用 GlobalRef手动 DeleteGlobalRef 前有效可以跨线程必须手动释放弱全局引用 WeakGlobalRef可能被 GC 回收可以跨线程使用前要 NewLocalRef 验证锁选择场景推荐方案说明Java 同步对象互斥MonitorEnter/Exit与 Java synchronized 互通native 内部数据保护std::mutex跨线程但不对 Java 可见计数器/标志位std::atomic避免频繁加锁5.2 现象一调用 Java 方法直接崩溃典型报错可能表现为直接 segmentation fault或者日志里出现 JNI DETECTED ERROR IN APPLICATION: use of invalid jobject。问题多半出在两处一是在没有附着的线程里直接使用了从别处拿到的 env二是回调时用了已经失效的局部引用。解决办法是把 jobject 提升为全局引用保存在线程函数入口重新获取 env。如果你把方法 ID 也缓存了反而没问题因为 jmethodID 不随类卸载失效。注意局部引用在下一次 native 方法调用返回时就会被 JVM 释放千万不要把它塞到自建的队列里跨线程消费。5.3 现象二死锁或无响应排查死锁时先确认是不是跨层锁顺序问题。用 jstack -l 打印 Java 线程栈重点看哪些线程处于 BLOCKED再看对应的 native 线程。Linux 上用 gdb attach 进进程执行 thread apply all bt找到停在 pthread_mutex_lock 或 monitor 等待的线程。如果看到多个线程互相等待对方持有资源死锁方向就清楚了。我的建议是给项目里的锁建一个登记表把每个锁的获取顺序写清楚。小团队项目可能觉得没必要但 JNI 层一旦混入 Java 锁和 C 锁只能靠规则约束。顺手说一句数据库同步软件、硬件同步、多相机采集同步这些业务场景里经常出现“上层同步一切正常、底层线程偶发锁死”的报告最后排查下来十有八九都是这个原因。5.4 现象三内存泄漏与线程泄漏未 detach 的 native 线程在 JVM 退出时可能卡住进程尤其是没用守护线程方式附着时。内存泄漏则集中在两类全局引用只 New 不 Delete以及局部引用在循环中不释放。排查工具上可以用 AddressSanitizer 做内存检测在 CMakeLists 里加 -fsanitizeaddress 编译参数Java 侧的 JVM 用 -Xcheck:jni 启动它会在每次 JNI 调用后检查引用使用规范。虽然性能会下降但定位问题特别有效。我在实际项目里用这套组合抓出过两个很隐蔽的全局引用泄漏都是只在长连接场景下累计发生普通压测根本看不出来。5.5 误区把业务同步当成线程同步在搜索“同步”这个词的时候很多人指的是业务数据同步数据库主从同步、多相机硬件同步、笔记同步这类场景。这些是更高层的系统设计问题解决的是“数据或事件在多个系统间保持一致”。而 JNI 的线程同步解决的是更底层的问题多个线程访问同一块内存或同一个 Java 对象时如何保证互斥与可见性。这两者有关联但不相同。哪怕你只是要把一个数据库同步工具的核心逻辑用 JNI 实现底层该加锁的地方还是得加锁。如果线程安全没做好上层再怎么设计也是空中楼阁。所以遇到此类需求时先把 JNI 线程模型理清楚再谈业务同步方案能省掉后面大量崩溃调试时间。最后聊一点自己的实际体会。JNI 多线程调试最忌讳“猜”不要因为代码看起来没问题就跳过工具验证。把 ScopedJniEnv、ScopedMonitor 这类封装沉淀成项目里的公共组件比每处手写 RAII 可靠得多。在 CLion 里开发时尽量把 native 代码编译成带调试信息的版本很多看似诡异的多线程崩溃只要能同时看到 Java 线程和 native 线程的完整调用栈答案往往一眼就能看出来。
返回列表