ARTICLE DETAIL

资讯详情

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

Java线程间通信实战:五大方案解决变量可见性与线程协作

Java线程间通信实战:五大方案解决变量可见性与线程协作 你有没有遇到过这种情况A 线程改了一个变量B 线程却像没看见一样要么死循环要么永远卡在 wait 上。我当年第一次遇到时第一反应是翻代码逻辑查了一遍又一遍怎么看都没问题就是感知不到变化。后来才明白这根本不是代码逻辑错了而是线程间通信没做对。「线程间通信」这个词网上资料一搜一大把但大多停留在“volatile 保证可见性”、“wait/notify 用来等待唤醒”这种表面解释。真到了生产环境你会发现各种边界问题信号丢失、虚假唤醒、锁对象不一致、忙等打满 CPU。这篇文章我把这些年踩过的坑、用过的套路整理出来从原理讲到代码再讲到线上排障争取帮你把这层窗户纸捅破。如果你是刚接触多线程的开发者或者有两年左右经验但总在并发问题上犯晕这篇文章应该是对胃口的。1. 先搞清楚问题本身B感知A的变化到底难在哪1.1 多线程环境下的三个“看不见”要理解 B 为什么感知不到 A 的变化得先理解默认情况下线程之间有多“隔离”。先抛开教科书说法用个不太严谨但好理解的比喻每个线程就像车间里的一个工人自己手边有一块白板工作内存大家共用一面墙上的公告栏主内存。工人平时只看自己的白板不看公告栏。A 在公告栏写了新通知B 的白板上还是旧内容自然感知不到。这背后对应的是硬件的缓存机制。CPU 为了提速把数据先放到寄存器或高速缓存里不会每次都访问主内存。线程 A 改了一个变量可能还没来得及把结果同步到主内存线程 B 就已经读了旧值。即使 A 同步了B 的工作内存也未必会失效刷新。这就是并发三要素中的可见性问题。第二个“看不见”是指令重排。CPU 和编译期为了优化性能会调整指令执行顺序只要单线程语义不变就行。可一旦放到多线程里A 线程代码顺序上先执行 step1、再执行 step2在 B 的视角里可能是 step2 先发生了。没有同步机制B 看到的状态可能是一个“中间状态”。第三个是原子性缺失。比如i看起来是一行代码实际是“读、改、写”三步。A 和 B 同时执行i最后 i 可能只加了 1而不是 2。这不是感知不到变化而是变化本身就错了。很多人以为线程间通信只是“让对方知道”其实首先要保证“传过去的值是准的”。这三个问题不是独立的一个完整的线程间通信方案要同时解决让变化对 B 可见、让操作顺序符合预期、让复合操作不被并发破坏。后面要讲的每一种方案本质都是在这三件事上做取舍。1.2 现实场景配置更新、缓存刷新、任务调度这个问题在业务代码里太常见了。我举三个真实场景。第一个是配置更新。A 线程接收配置中心推送把某个开关变量从 true 改成 falseB 线程是业务执行线程每秒都在读这个开关。如果没有合适的同步手段B 线程可能永远读到 true开关关了像没关一样功能一直不生效等到业务方反馈才排查出问题。第二个是缓存刷新。A 线程定时从数据库拉最新价格写到一个共享缓存对象里B 线程处理用户请求从缓存里拿价格去报价。结果缓存更新了B 还是旧价格导致用户看到的价格和库存对不上客诉一堆。第三个是任务调度。A 线程收到新任务往队列里放B 线程是工作线程等任务来了就取出执行。任务放进去之后 B 半天没反应队列很长但 B 就是不动。这三个场景看着差异很大本质是同一件事一个线程产生了新的状态另一个线程需要感知到这个状态并在合适的时机做出反应。解决方案也围绕“我怎么让 B 知道”展开。2. 五种让B感知变化的方案我实际用过的套路2.1 volatile最轻量的标志位通知先说最简单的一种。如果 A 只是修改一个布尔开关B 只需要读取没有复合操作那 volatile 就够了。public class VolatileFlag { private volatile boolean running true; // A线程修改状态 public void stop() { running false; } // B线程感知状态 public void worker() { while (running) { // 干活 } System.out.println(worker stop); } }volatile 的原理很简单写 volatile 变量时会强制把当前线程工作内存中的值刷回主内存读 volatile 变量时会强制从主内存重新拉取值。同时JMM 的 happens-before 规则保证对 volatile 变量的写操作在读操作之前且写之前的所有普通写操作也一并可见。就是说A 在写 running 之前修改的其他普通变量B 在读到 running 为 false 之后也能看到。但 volatile 有两个短板。第一不保证原子性。多个线程同时执行running !running这种操作仍然会出错因为它不是一步完成的。第二它解决不了“等待”场景。B 在 while 循环里不停读CPU 空转得厉害纯粹是忙等。所以 volatile 适合一写多读的状态标志不适合条件等待。2.2 wait/notify经典的等待通知机制如果 B 不想空转希望“没有变化时休眠变化了再被叫醒”wait/notify 就是为此设计的。public class WaitNotifyDemo { private final Object lock new Object(); private boolean ready false; // A线程数据准备好后通知 public void setReady() { synchronized (lock) { ready true; lock.notifyAll(); } } // B线程等待条件满足 public void waitForReady() { synchronized (lock) { while (!ready) { try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } // 条件满足继续执行 System.out.println(ready now); } } }关键点在于wait 调用前必须持有锁调用后会立即释放锁线程进入该锁对象的等待集notify 会随机唤醒一个等待线程被唤醒的线程需要重新竞争锁拿到锁之后才从 wait 返回。这就实现了“B 等待期间不占 CPUA 完成后主动通知 B”。踩坑的地方不少。最典型的是锁对象不一致。A 线程 synchronized(lockA)B 线程 synchronized(lockB)两边都以为自己在做同样的事实际上根本没有在同一个锁上通信notify 永远叫不到 BB 一直睡死。第二个典型问题是用 if 而不是 while 做条件判断。原因我在第三章详细说。2.3 BlockingQueue让队列成为消息通道手动写 wait/notify 很容易出错而且锁放得不好容易死锁。工程上我更推荐直接用并发容器比如 BlockingQueue。它把生产者和消费者场景封装好了底层其实就是在帮你做 wait/notify 的工作但你不用自己碰锁。public class QueueDemo { private final BlockingQueueString queue new LinkedBlockingQueue(100); // A线程发送消息 public void send(String message) { queue.put(message); } // B线程接收消息 public void receive() { while (true) { try { String message queue.take(); // 没有数据时阻塞 System.out.println(B收到 message); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } }take 在没有数据时会阻塞等待put 在队列满时也会阻塞这些都由容器内部处理。A 和 B 完全解耦B 不需要关心 A 是怎么产生的消息A 也不需要关心 B 什么时候消费。队列还能天然承担缓冲和削峰的作用突发消息多的时候排队处理不会直接把下游打崩。BlockingQueue 的缺点是引入了一个中间数据结构有内存开销另外如果消费速度长期跟不上生产速度队列会越积越长需要关注堆积量监控。不过绝大多数“一个线程给另一个线程传数据”的场景它都是最不容易出错的方案。2.4 Atomic类与CAS状态机感知如果需要保证“只有一个线程能拿到更新”这种原子性比如多个线程同时尝试抢占一个状态那就用 Atomic 类。public class AtomicFlagDemo { private final AtomicBoolean flag new AtomicBoolean(false); // A线程尝试抢占更新 public boolean tryUpdate() { return flag.compareAndSet(false, true); } // B线程感知状态变化 public boolean isUpdated() { return flag.get(); } }compareAndSet(false, true)的意思是只有当前值是 false 时才把它改成 true并且整个比较和修改是原子的。换到业务场景里多个工作线程都在等一个任务只有 CAS 成功的那个线程去执行其他线程放弃这就避免了重复执行。Atomic 内部通过 CAS比较并交换指令实现性能比加锁好得多并且因为内部元素本身带有 volatile 语义读线程总是能读到最新值。不过要注意如果多个线程频繁 CAS 失败自旋CPU 消耗会明显升高。所以 Atomic 类适合“竞争不激烈”的轻量状态管理不适合高并发下的复杂抢锁。2.5 Future/CompletableFuture一次性结果传递前面几种都偏向“持续状态同步”还有一种场景是“A 算出一个结果要把这个结果交给 B”。这种一次性结果传递最顺手的是 CompletableFuture。public class FutureDemo { public void asyncQuery() { CompletableFutureString future CompletableFuture.supplyAsync(() - { // A线程执行耗时查询 return queryFromDB(); }); // B线程回调线程拿到结果后处理 future.thenAccept(result - System.out.println(B收到结果 result)); } }CompletableFuture 的推荐用法是链式回调thenAccept 会在结果可用时自动执行不需要 B 线程一直阻塞等待。它的名字虽然带 Future但比传统 Future.get() 好用的地方在于不需要主动阻塞拿结果而是声明式地描述“结果到了之后做什么”。它还支持 thenCombine、thenCompose 组合多个异步结果适合把一个任务拆成多个阶段并行处理。不过它更适合“一次性目标任务”如果 A 要频繁给 B 发多个消息还是队列更合适。future 就好比订了个外卖等外卖到了会给你打电话但你不能指望外卖小哥一天给你送一百次饭还每条都通知你。3. 实操中 wait/notify 那些隐藏的坑3.1 为什么必须在同步块中调用 wait/notify很多新手第一次写 wait/notify 会被一个异常教育IllegalMonitorStateException。原因很直接wait/notify 依赖对象监视器Monitor调用前必须持有目标对象的锁。提示lock.wait()必须在synchronized(lock)代码块内调用否则 JVM 直接抛异常这是语法层面强制的要求。为什么这么设计核心原因是防止条件判断和等待之间产生竞态。假设 wait 不需要锁B 线程先判断ready false准备等待这时 A 线程把 ready 改成 true并执行 notify。如果 B 还没进入 wait 状态notify 就丢失了B 再进入 wait 时永远等不到下一次 notify。把 wait 放进同步块里条件判断和 wait 之间是原子的notify 必须先等 B 释放锁才能执行从机制上避免了“先通知后睡觉”的丢失问题。用哪把锁也有讲究。wait/notify 必须作用在同一个锁对象上。很多生产事故就是这里栽的A 同步方法用了synchronized(this)B 同步块用了synchronized(lock)两者看起来都在做等待通知实际各锁各的A 的通知根本传不到 B。3.2 虚假唤醒为什么必须用 while 而不是 if教科书和 Java 官方文档都强调wait()一般要放在循环里检查条件而不是用 if 判断一次。很多人不理解觉得醒来的时候条件肯定已经满足了为什么还要重新查一遍原因有两个。第一是虚假唤醒。JVM 规范允许 wait 在没有 notify、没有中断、没有超时的情况下被唤醒。这不是 bug是规范给 JVM 实现留的灵活空间目的是允许某些平台采用更高效的原语。也就是说B 从 wait 返回时条件不一定已经满足。你用 if 判断醒来直接往下执行条件不满足就出错。第二是 notifyAll 的唤醒竞争。notifyAll()会唤醒所有等待线程但它们会依次获得锁。线程 1 抢到锁后把条件改回 false线程 2 再抢到锁时如果没有 while 重新检查就会在条件不满足的情况下继续往下执行造成逻辑错误。正确写法就是前面示例里的synchronized (lock) { while (!ready) { lock.wait(); } }每次被唤醒都重新检查条件不满足就继续等待。这省不了多少代码但能挡掉一类很难复现的线上事故。3.3 notify 和 notifyAll 怎么选多条件等待怎么办notify 只唤醒一个等待线程notifyAll 唤醒所有。选哪个看似简单实际上里面有门道。如果只有一个等待条件所有线程被唤醒后都在等同一个条件用 notify 效率更高不会引起不必要的竞争。但如果有两个不同的条件比如生产者线程等队列不满消费者线程等队列不空两个条件都挂在同一个锁对象上notify 就可能唤醒一个“不满足自己条件”的线程它醒来后 while 循环发现条件还是不满足只能再次 wait把本来该唤醒的线程挤在后面极端情况下会造成线程饿死。所以只有一个等待条件时用 notify有多个条件或不确定时用 notifyAll 更安全。多条件的场景别硬用 wait/notifyJava 的显式锁ReentrantLock可以创建多个 Condition通过condition.signal()定向唤醒语义更清晰。信号丢失问题也需要兜底。就算正确用了 notifyAll也不能百分百保证 B 一次都不错过通知。我给自己的代码立了个规矩所有 wait 都带超时时间而不是永远等下去。long timeout 3_000; long start System.nanoTime(); synchronized (lock) { while (!ready) { lock.wait(timeout / 2); if (System.nanoTime() - start timeout) { break; // 兜底退出避免永久阻塞 } } }不要小看这个超时它相当于给线程通信加了一道保险丝。就算某次通知因为异常或疏忽丢了B 最多多等一段时间不会永远卡死。4. 不同场景怎么选型一张表看清该用哪种方案方案这么多真到写代码时怎么选我习惯先回答四个问题传的是状态还是消息是一次性还是持续性的有没有数据要传能不能容忍忙等下面是我自己常用的对比表方案最适合的场景优点明显缺点volatile状态开关一写多读简单、无锁、性能高不保证原子性忙等wait/notify条件满足后继续执行等待时不占 CPU语义细粒度容易丢通知需小心写BlockingQueue生产者-消费者跨线程传数据封装完善不易出错自带缓冲有队列内存开销需关注堆积Atomic类/CAS状态抢占原子更新轻量支持原子比较竞争激烈时 CPU 消耗高CompletableFuture一次性异步结果传递声明式回调组合能力强不适合高频重复消息选型时我通常这么决策只有标志位变化、B 可以忙等用 volatile。B 需要等待条件满足才继续又不想空转用 wait/notify 或 ReentrantLock Condition。能不用原生的尽量不用容易踩坑。两边有数据传递优先 BlockingQueue这是工程里最通用的“消息通道”。状态竞争靠 Atomic 类或者并发工具如 Semaphore。一次性异步任务用 CompletableFuture。实际项目里这几种方案经常组合使用。我做过一个配置热更新功能配置中心推送线程更新一个 volatile 开关同时把新的配置数据丢进 BlockingQueue业务线程从队列里取配置、读开关来决定是否应用。volatile 负责“要不要换”队列负责“换什么数据”各司其职。5. 排障实战B没有感知到变化时我一般这样查5.1 常见问题速查表如果你也遇到“B 感知不到 A 的变化”别慌按下面这张表排查大部分问题几分钟就能定位。现象最可能原因检查思路volatile 变量读不到新值变量没加 volatile 或没加同步加 volatile或者改用 Atomic 类B 一直阻塞在 wait锁对象不一致 / notify 没执行 / 条件判断用 if检查 synchronized 锁对象确认 notify 是否被调用while 重查条件两个线程状态不同步普通变量没有使用并发保护给变量加 volatile或不变量用 final服务偶尔行为诡异指令重排 未正确同步用 synchronized、volatile或者显式锁建立 happens-before 关系CPU 飙高B 忙等while 循环空转没加等待改用 wait/notify或加一个短 sleep拿到队列数据后重复处理消费逻辑没有做幂等消费方加去重或用 CAS 抢状态5.2 一次真实排障记录我说一个自己遇到过的案例。某次公司内部后台有个“功能开关”不生效A 线程从配置中心收到新配置把开关改成 falseB 线程处理请求时读开关死活都是 true。一开始我以为配置中心推送有问题翻推送日志发现 A 线程确实执行了赋值操作又以为是 B 线程缓存了配置对象就在打印日志里把对象地址打出来发现两个线程拿到的确实是同一个对象。后来才意识到问题可能出在可见性上。翻代码那个开关字段没有任何 volatile 或锁保护就是个普通 boolean。我给字段加了 volatile 之后功能立即正常了。这个案例其实很典型对象确实是同一个但一个线程改了另一个线程看到的是自己工作内存里的旧副本。判断多线程问题不能只盯着“是不是同一个对象”更要看有没有正确建立可见性关系。后来我在排查这种问题时第一件事就是看共享变量的类型定义有没有 volatile、是不是 final、访问有没有进同步块基本能排除一大半问题。如果还定位不了就用jstack看线程状态。WAITING 状态的线程往往卡在 wait 上结合栈信息能看到它在哪个对象上等待RUNNABLE 状态但 CPU 很高的线程多半在忙等循环里。有了线程状态和锁对象信息再对照代码找问题就清晰多了。6. 最后几个建议文章写到这里核心内容基本都覆盖了。最后分享几个我这几年实践下来比较受用的习惯。第一能用并发容器和并发工具类就尽量别手写 wait/notify。JDK 的 BlockingQueue、Semaphore、CountDownLatch 已经覆盖了绝大多数场景它们经过充分测试比自己写的锁稳妥得多。手写 wait/notify 就像自己造轮子造好了确实灵活造不好就是定时炸弹。第二定义共享变量之前先问自己一个问题这个变量会被几个线程读写读写之间需要什么样的顺序保证想清楚了再选方案。很多并发问题都是因为一上来就写代码没有想清楚变量的角色。第三给所有阻塞等待加超时兜底。结构上可以设计为“快速失败”也不要把业务线程吊在一个可能永远醒不过来的 wait 上。wait(timeout)、poll(timeout)用起来就差一个参数线上省心不是一点半点。线程间通信不是玄学核心就是把可见性、原子性、有序性这三个问题盯住然后选择合适的工具。你把这个逻辑理清了再看各种并发方案会发现它们其实是同一件事的不同表达方式。
返回列表