Java线程间通信实战:五大方案解决变量可见性与线程协作
2026/9/23 3:29:26 网站建设 项目流程

你有没有遇到过这种情况: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 改成 false;B 线程是业务执行线程,每秒都在读这个开关。如果没有合适的同步手段,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 等待期间不占 CPU,A 完成后主动通知 B”。

踩坑的地方不少。最典型的是锁对象不一致。A 线程 synchronized(lockA),B 线程 synchronized(lockB),两边都以为自己在做同样的事,实际上根本没有在同一个锁上通信,notify 永远叫不到 B,B 一直睡死。第二个典型问题是用 if 而不是 while 做条件判断。原因我在第三章详细说。

2.3 BlockingQueue:让队列成为消息通道

手动写 wait/notify 很容易出错,而且锁放得不好容易死锁。工程上我更推荐直接用并发容器,比如 BlockingQueue。它把生产者和消费者场景封装好了,底层其实就是在帮你做 wait/notify 的工作,但你不用自己碰锁。

public class QueueDemo { private final BlockingQueue<String> 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() { CompletableFuture<String> 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/notify,Java 的显式锁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 线程从配置中心收到新配置,把开关改成 false,B 线程处理请求时读开关,死活都是 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)用起来就差一个参数,线上省心不是一点半点。

线程间通信不是玄学,核心就是把可见性、原子性、有序性这三个问题盯住,然后选择合适的工具。你把这个逻辑理清了,再看各种并发方案,会发现它们其实是同一件事的不同表达方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询