多线程共享状态怎么处理:Mutex、Atomic、串行化到底怎么选)
前面三篇我们已经把 JNI 多线程里最容易混淆的东西拆开了。第一篇解决的是当前是哪条线程 ART 认不认识 ↓ JavaVM JNIEnv Attach / Detach第二篇解决的是Java 对象引用还能不能继续使用 ↓ LocalRef GlobalRef第三篇又进一步发现Binder Thread 1 ─┐ Binder Thread 2 ─┼── 同一个 Service Binder Thread 3 ─┘即使每条线程身份都正确、JNIEnv都正确、GlobalRef也正确程序仍然可能出问题。因为这时候面对的是第三个问题多条线程会不会同时操作同一份共享状态这已经不再是 JNI 独有的问题而是最普通、最核心的多线程并发问题。文档里把这一层概括成四个关键词Race Condition Mutex Atomic 串行化 / 消息队列其中Mutex 通过临界区保护共享状态Atomic 适合简单状态和计数串行化则把状态修改集中到同一工作线程执行。JNI多线程_从线程本质到真正理解透这一篇就专门把这三种处理方式放在一起讲透。一、先别急着加锁多线程真正的问题是什么假设Thread A Thread B Thread C都在工作。这本身完全没有问题。比如Thread A → 处理图片 A Thread B → 处理图片 B Thread C → 下载文件 C如果它们之间互不影响各干各的那根本不需要加锁。真正的问题出现在Thread A ─┐ Thread B ─┼── sharedState Thread C ─┘也就是多条线程同时访问同一份共享状态。例如int count; boolean running; MapString, User users; User currentUser;如果只有读Thread A → 读 Thread B → 读很多情况下也没事。最容易出问题的是Thread A → 写 Thread B → 写或者Thread A → 读 Thread B → 写于是就进入Race Condition竞态条件。二、什么叫 Race Condition先看int count 10;然后两个线程同时count;我们人眼看到的是count一行。但概念上可以拆成1. 读取 count 2. 加 1 3. 写回 count于是可能出现初始 count 10 Thread A 读取 10 Thread B 读取 10 Thread A 计算 11 写回 11 Thread B 计算 11 写回 11最后count 11但正常应该是count 12这就是竞态。也就是说最终结果依赖于多个线程不可控的执行先后顺序。文档里对 Race Condition 的定义也是这个意思结果取决于多个线程“谁先谁后”而这个顺序不可控。JNI多线程_从线程本质到真正理解透三、解决并发问题其实有三种完全不同的思路先把三种方案放一起方案一Mutex → 大家都可以访问 → 但一次只让一个线程进入关键区域方案二Atomic → 对一个简单变量 → 保证某个操作具有原子性方案三串行化 → 大家都不要直接修改共享状态 → 把任务排队 → 交给一条线程顺序处理这三种方式的区别非常重要。四、Mutex大家都能来但一次只能进一个先看最传统的方案。Cint count 0; std::mutex mutex; void add() { std::lock_guardstd::mutex lock(mutex); count; }两条线程Thread A → add() Thread B → add()运行过程可能是Thread A ↓ 拿到 mutex ↓ count ↓ 释放 mutexMeanwhileThread B ↓ 想拿 mutex ↓ 拿不到 ↓ 等待等 A 释放以后Thread B ↓ 拿到 mutex ↓ count ↓ 释放所以 Mutex 的核心思想就是共享数据还是允许多线程访问只是访问关键区域时一次只允许一条线程进入。这个“关键区域”通常叫Critical Section 临界区五、Java 里的 synchronized本质上也是这一类思想比如private final Object lock new Object(); public void update() { synchronized (lock) { // 修改共享状态 } }可以先粗略理解成进入 synchronized ↓ 尝试拿锁 ↓ 拿到了 ↓ 执行临界区 ↓ 退出 ↓ 释放锁所以std::mutex synchronized Lock虽然 API 不同核心思想都是把一段代码保护起来让它不要被多条线程同时执行。六、Mutex 保护的其实不是“变量”而是一段逻辑这一点很重要。假设int balance 1000;业务是if (balance 100) { balance - 100; recordTransaction(); updateUser(); }真正需要保护的并不只是balance - 100;而是整个业务过程检查余额 ↓ 扣钱 ↓ 记录交易 ↓ 更新用户状态因为你真正要求的是这一整段业务在逻辑上不能被其他线程插进来破坏状态一致性。所以可能需要synchronized (lock) { if (balance 100) { balance - 100; recordTransaction(); updateUser(); } }这里 Mutex 的价值就体现出来了。七、Atomic一个简单状态没必要把整个房间锁起来再看另外一种情况。假设只是boolean running;多条线程可能同时修改Thread A → running true Thread B → running false或者一个计数器int count;只需要count;这种时候为一个非常简单的变量到处synchronized有时候会显得比较重。于是就有AtomicBoolean AtomicInteger AtomicLong例如AtomicInteger count new AtomicInteger(0); count.incrementAndGet();或者AtomicBoolean running new AtomicBoolean(false); running.set(true);八、Atomic 到底“原子”在哪里原子这个词可以先粗略理解成从其他线程视角看这个操作不会暴露一个“做到一半”的中间状态。例如count.incrementAndGet();你不需要自己写读取 加一 写回然后担心另外一条线程插进来。Atomic 给你提供的是一个不可拆开的原子更新操作文档里对 Atomic 的描述就是适合简单状态位 / 计数器让读写不可被拆开观察。JNI多线程_从线程本质到真正理解透所以特别适合开关 状态位 计数器 简单引用九、Atomic 最大的坑一个变量原子不代表整个业务原子这一点特别重要。还是这段代码if (balance 100) { balance - 100; recordTransaction(); updateUser(); }有人可能会想AtomicInteger balance;那不就解决了吗不一定。因为即使balance本身是 Atomic你整个逻辑仍然包含读取 判断 修改 记录交易 更新用户它是多个操作组成的一整个业务过程。Atomic 只能保证某些具体原子操作。它不会自动把if (...) ... recordTransaction() ... updateUser()全部粘成一个不可分割的整体。这就是 Atomic 最重要的边界。所以Atomic 擅长解决“一个简单状态怎么原子更新”不擅长自动解决“一整个复杂业务流程怎么保持一致”。十、什么时候适合 Atomic可以先记几个典型场景。例如AtomicBoolean running;控制任务是否运行或者AtomicInteger retryCount;记录重试次数或者AtomicInteger activeTaskCount;记录当前任务数量这些都很自然。因为状态非常简单一个变量 一次原子操作十一、什么时候应该更倾向 Mutex如果你发现逻辑开始变成检查状态 A 修改状态 B 更新 List 更新 Map 通知 callback例如if (!running) { running true; taskList.push_back(task); currentTask task; notifyStateChanged(); }这里就不再是一个变量的问题了。而是多份共享状态之间必须保持一致。这时候Mutex就很自然。因为你需要保护的是一整个临界区而不是某一个 int / bool十二、但是锁多了以后又会出现新的问题假设一个复杂 Service 里currentUser currentTask taskList deviceMap connectionState callbackList全部都是共享状态。于是你开始synchronized (userLock) { }这里一把锁。又synchronized (taskLock) { }另一把。又synchronized (deviceLock) { }第三把。慢慢代码会变成这段拿 A 锁 那段拿 B 锁 还有地方同时拿 A B 另外一条线程先拿 B 再拿 A这时候复杂度迅速上升。甚至可能出现Thread A 拿着 Lock A 等待 Lock B Thread B 拿着 Lock B 等待 Lock A两边谁也走不了。这就是Deadlock 死锁所以锁不是“越多越安全。”锁多了反而可能让系统更难理解。十三、于是出现第三种思路串行化这是最容易被忽视但实际上很重要的一种办法。这里说的串行化不是 Java 里的Serializable也不是对象序列化。这里的串行化是多个线程可以同时提交任务但所有真正修改共享状态的操作都交给同一条线程按顺序执行。十四、先看一个 Binder 场景假设Binder Thread 1 Binder Thread 2 Binder Thread 3都可能修改MapString, User users;最直接方案是Mutex即Binder Thread 1 ─┐ Binder Thread 2 ─┼── lock → users Binder Thread 3 ─┘但串行化换了一个思路。不是“大家抢锁以后自己修改。”而是Binder Thread 1 ─┐ Binder Thread 2 ─┼── Queue Binder Thread 3 ─┘ ↓ Worker Thread ↓ users所有 Binder Thread只负责提交任务。真正修改users的永远只有Worker Thread十五、最简单的实现SingleThreadExecutorJavaExecutorService executor Executors.newSingleThreadExecutor();然后Override public void updateUser(User user) { executor.execute(() - { users.put( user.getId(), user ); }); }即使Binder Thread 1 Binder Thread 2 Binder Thread 3同时调用updateUser()它们真正干的只是往队列里塞任务可能形成Queue [A][B][C]然后Single Worker Thread ↓ 执行 A ↓ 执行 B ↓ 执行 C于是users不会被三条 Binder Thread 同时修改。十六、这就是串行化最大的不同Mutex 的思想很多线程 ↓ 都可以修改共享数据 ↓ 但是修改前先抢锁而串行化很多线程 ↓ 都不能直接改 ↓ 只能提交任务 ↓ 唯一 Worker Thread 修改所以可以这样对比Mutex Thread A ─┐ Thread B ─┼── Lock ── State Thread C ─┘而串行化 Thread A ─┐ Thread B ─┼── Queue ── Worker ── State Thread C ─┘区别非常大。十七、Android 其实早就大量使用串行化例如Handler Looper MessageQueue多个线程Thread A Thread B Thread C都可以handler.post(...)形成MessageQueue [A][B][C]然后Looper ↓ 绑定的一条线程 ↓ 一个一个处理这就是非常典型的串行执行模型。所以以前我们常说Handler 用来做线程通信这个没错。但从并发控制角度再看一遍Handler / Looper / MessageQueue 也可以用来把共享状态的修改集中到同一线程。这就是“串行化”的价值。十八、为什么串行化有时候比加锁更舒服假设有当前连接状态 当前设备 当前任务 任务队列 callback 错误状态这些状态彼此之间还有关系。如果让Thread A Thread B Thread C都能直接修改就需要大量Lock然后你一直要考虑谁先拿锁 谁后拿锁 哪个锁保护哪个字段 锁的范围多大 有没有死锁而串行化直接规定只有 StateThread 可以修改状态。于是Binder Thread Network Thread Native Thread Callback Thread ↓ 全部 post ↓ StateThread ↓ 修改所有状态此时很多问题天然消失了。因为永远只有一个线程写文档里说“串行化 / 消息队列”可以从架构上减少共享写就是这个意思。JNI多线程_从线程本质到真正理解透十九、串行化是不是就完全不用锁不能简单这么说。如果你能严格保证某份状态 永远只允许一个 Worker Thread 访问那这份状态内部很多操作确实不需要额外加锁。但是如果Worker Thread 写 同时 Binder Thread 还直接读那么跨线程共享仍然存在。所以关键不是“我用了 SingleThreadExecutor所以绝对不需要锁。”而是你是否真的把这份状态的所有相关访问都收拢到了同一串行执行上下文中。架构规则比 API 名字更重要。二十、Mutex、Atomic、串行化到底怎么选终于可以放到一起了。第一种Atomic适合一个简单变量 简单原子操作例如running retryCount taskCount典型AtomicBoolean AtomicInteger核心这个变量的一次操作不能被拆开。第二种Mutex / synchronized适合多个操作 组成一个临界区或者多份状态 需要一起保持一致例如if (balance 100) { balance - 100; recordTransaction(); }核心这一整段逻辑一次只能一个线程执行。第三种串行化适合共享状态复杂 状态变化很多 多条线程来源复杂 锁开始越来越多这时候可以考虑Queue Single Worker Thread核心不是保护并发修改而是从架构上减少并发修改。二十一、用三个比喻记住它们Mutex像一间只有一把钥匙的房间Thread A 拿钥匙 进去 出来 还钥匙 Thread B 才能进去所以大家都能操作只是一次一个。Atomic像一个机器按钮按一下 → 完成一次完整动作中间过程不会被别人拆开。所以适合小而简单的状态变化。串行化像银行窗口很多人 ↓ 拿号 ↓ 排队 ↓ 一个窗口 ↓ 一个一个处理所以大家都不直接进后台改数据而是把任务交给一个窗口。二十二、再回到 Binder就彻底清楚了假设Binder Thread 1 Binder Thread 2 Binder Thread 3同时进入Service先问有没有共享状态没有各做各的那不用处理。有sharedState再判断。如果只是running可能AtomicBoolean就够。如果是一段检查 修改 更新组合逻辑Mutex / synchronized更合适。如果整个 Service任务状态 连接状态 设备状态 用户状态 callback全部互相有关那么SingleThreadExecutor HandlerThread 消息队列这种串行化设计可能反而更简单。二十三、再看 JNI 场景也是一样比如static jobject gCallback;两条线程Thread A → CallVoidMethod(gCallback) Thread B → DeleteGlobalRef(gCallback)这里Attach 正确 GlobalRef 正确都不够。因为gCallback是共享状态。这时候可能需要mutex或者干脆规定所有 callback 操作 都交给同一条线程也就是串行化所以文档才会强调JNI 线程身份正确并不等于共享业务数据并发安全。JNI多线程_从线程本质到真正理解透二十四、不要形成“多线程 一定加锁”的思维这是这一篇我觉得最值得建立的一个习惯。看到多个线程不要立刻synchronized而是先问有没有共享状态如果没有不用处理有的话再问共享状态复杂吗如果只是一个简单变量看看 Atomic 是否够。如果一段完整逻辑考虑 Mutex。如果整个业务状态非常复杂考虑是否应该直接串行化这比“哪里报并发问题就往哪里加锁”更健康。二十五、最后形成一个选择模型以后看到多线程共享状态可以按这个顺序想多线程 ↓ 有没有共享状态 ↓ 没有 → 不需要同步 有 ↓ 只是一个简单状态/计数 ↓ 是 → Atomic 不是 ↓ 是一小段需要整体一致的临界区 ↓ 是 → Mutex / synchronized 不是 ↓ 大量状态彼此关联、 多线程来源复杂、 锁开始越来越多 ↓ 考虑串行化 → Queue Single Worker Thread这个判断模型比记 API 更重要。二十六、这一篇最后只记六句话第一句多线程本身不是问题多线程同时访问共享状态才是问题。第二句Mutex 的思路是允许多线程访问但一次只允许一个线程进入临界区。第三句Atomic 适合简单状态、计数器和单次原子更新不等于可以自动保护一整段复杂业务逻辑。第四句串行化不是 Java Serializable而是把并发任务排队交给同一条线程顺序执行。第五句Mutex 是“保护并发”串行化更像是“减少甚至消灭并发写”。第六句JNIEnv、Attach、GlobalRef 全部正确也不代表共享业务状态已经线程安全。你的 JNI 文档最后其实就是要求同时检查这三层线程是谁 对象活多久 共享状态会不会竞争JNI多线程_从线程本质到真正理解透到这里前四篇其实已经分别把这三个问题全部拆开了。下一篇《把 Binder、JNI、ART 和 Linux Thread 一次串起来》最后一篇不再引入太多新概念。而是用一个完整场景Client ↓ AIDL ↓ Binder ↓ Binder Thread ↓ Java Service ↓ JNI ↓ C ↓ std::thread ↓ Attach ↓ JNIEnv ↓ GlobalRef ↓ 回调 Java然后一路问现在是哪条线程 ART 认不认识 要不要 Attach 这个 jobject 是 LocalRef 还是 GlobalRef 有没有共享状态 该用 Atomic、Mutex 还是串行化把前四篇真正连成一个完整的 Android Native 线程运行模型。