线程的等待唤醒机制,很多Java开发者一开始是面试背八股,等到真正写生产者消费者或者任务队列的时候,才发现代码老出问题——要么线程卡死,要么数据重复消费,要么直接抛运行时异常。说白了,wait和notify这套东西,代码量不大,但里面的门道非常多,埋伏着不少坑,我今天就用实际经验把这套机制从头到尾讲清楚,包括它解决了什么问题、正确写法长什么样、错误场景会踩什么雷,以及排查时的具体手段。
这篇文章适合刚开始接触Java并发编程、对synchronized有一定了解但还没怎么用过wait/notify的读者,也适合写过相关代码但遇到过莫名其妙bug的人。理解这套机制之后,你再去看BlockingQueue、Condition、甚至ReentrantLock这些高级并发工具,会顺畅很多,因为它们的底层思想都绕不开“等待-通知”这四个字。
1. 为什么需要等待唤醒机制:线程协作比互斥更难
1.1 线程之间的关系不只是“抢资源”
很多人对多线程的第一印象是互斥:多个线程同时访问同一个变量,需要加锁防止数据错乱。但真实业务里线程之间更多是协作关系,一个线程做完某一件事,另一个线程才能继续推进。
举一个非常典型的场景:生产者往一个容器里放数据,消费者从容器里取数据。生产者放完之后,消费者才能取;容器空的时候,消费者必须等着。这里线程之间不是互斥,而是存在明确的前后依赖,用技术黑话讲叫“线程间协作”。
这种协作如果用互斥锁硬解,会很别扭。互斥锁能保证同一时间只有一个线程操作容器,但它无法表达“容器已满,你先等等”“容器已空,你再等等”这类带条件的状态传递。这正是等待唤醒机制存在的价值所在:它让线程可以在不满足条件时主动让出CPU并挂起,等条件具备了再被重新激活。
1.2 先别急着wait:来看看忙等和轮询的代价
在理解wait/notify之前,先看反面写法。很多初学者第一版代码往往是这样的:
// 业务线程 while (true) { if (isTaskReady()) { doTask(); break; } Thread.sleep(100); }看起来逻辑没毛病:每隔100毫秒检查一次任务是否就绪,就绪就干活。但这种轮询写法在真实场景里问题很大。
CPU被白白空转浪费。sleep只是降低了检查频率,并没有改变“循环+条件判断”的忙等本质,线程始终处于可运行状态。响应性差也是问题,如果任务在第一次sleep后立即就绪,业务线程最坏要等100毫秒才能反应过来,100毫秒对高并发业务来说太久了。代码可读性更差,条件一多,到处都是这个循环,逻辑散落在各个地方。
有人会说我用sleep(5)行不行?行,但CPU占用更猛,几乎就是纯忙等。轮询本质上是用时间换实时性,实时性需求越高,轮询越得不偿失。
等待唤醒机制给出的方案更优雅:条件没满足时,线程进入等待状态,不占CPU;条件满足的那一刻,由别的线程主动通知它醒来。这个思路就像外卖小哥到了门口按门铃,而不是让你每隔几秒跑去门口看一次。
1.3 锁对象上的监视器:wait/notify为什么属于Object
有个值得注意的设计细节:wait、notify、notifyAll这三个方法都定义在Object类上,而不是Thread类上。很多初学者不理解,其实这个设计恰恰说明它是围绕“锁对象”运作的。
Java里每个对象都隐式关联一个监视器锁,也就是大家常说的monitor。当代码进入synchronized块时,线程就持有了这个锁对象。在这个锁对象上调用wait,意思是:当前持有这把锁的线程,把锁释放掉,进入等待集合,等有人在这同一把锁上调用notify或notifyAll,再重新参与锁竞争。
关键点在于“同一把锁”。等待和唤醒是绑定在同一个对象上的,线程协作的单位不是线程本身,而是“持有同一把锁的一组线程”。这也是为什么wait/notify必须放在Object里——协作的参与者共享的是同一个对象锁,锁对象本身就是协作的联络点。
每个对象内部其实有两个隐含区域:一个是等待集,存放调用wait后挂起的线程;一个是锁池,存放等待重新获取锁的线程。理解这两个区域,再看后面的代码就一目了然。
2. 核心API与正确打开方式
2.1 方法签名和语义:wait多少毫秒才合适
先过一遍这几个方法的基本语义,只讲实际行动中会用到的部分。
wait():当前线程释放锁并无限期等待,直到被notify、notifyAll或interrupt唤醒。
wait(long timeout):等待指定的毫秒数,超时后自动醒来,无需别人通知。醒来后同样要重新竞争锁。
wait(long timeout, int nanos):毫秒+纳秒的精度补充,但实际操作系统能否做到纳秒级精度很难保证,一般用不到。
notify():随机唤醒一个在同一个锁对象上等待的线程。注意是随机,不是按等待顺序。
notifyAll():唤醒所有在同一个锁对象上等待的线程,被唤醒的线程要重新抢锁,抢到的才能继续。
这里有一个最容易被忽略的点:被唤醒的线程从wait返回后,并不代表锁已经拿到了,它要先重新参与锁竞争,拿到锁才能继续执行。而且就算拿到了锁,也不能假设当初等待的条件一定成立。后面讲while循环时我会细说,这里先记住结论:wait返回后的第一件事是重新检查条件。
2.2 什么叫“释放锁并进入等待”,三步走要看清
用文字描述wait的完整生命周期,比看任何API文档都直观:
第一步,线程进入synchronized块,持有了对象锁。
第二步,检查条件不满足,调用lock.wait()。这一步做了两件事:把锁释放掉,线程进入该锁对象的等待集。
第三步,其他线程拿到同一把锁,修改了条件,调用lock.notifyAll()。等待集中被唤醒的线程进入锁池,重新和其他线程竞争锁。抢到锁后,wait方法返回,代码从wait调用的下一行继续执行。
这里必须强调,锁释放是wait的一个核心特性。如果不释放锁,其他线程根本进不来修改条件,那就永远没有机会notify,整个程序逻辑彻底卡死。这也是wait和sleep最本质的区别:sleep持有锁睡觉,wait是松开锁等着被叫醒。
2.3 为什么必须写在synchronized块里:IllegalMonitorStateException只是表象
如果直接在非同步块里调用wait,运行时会抛IllegalMonitorStateException。异常信息很直白,当前线程不是该对象监视器的持有者。很多人记住了这条规则,但不知道为什么要有这个限制,我需要把真正的原因讲透,因为这个原因解释了并发编程中一个非常重要的概念:lost wake-up,唤醒丢失。
想象一个没有同步保护的场景:
// 线程A if (!ready) { lock.wait(); // 检查完条件,还没执行wait } // 线程B ready = true; lock.notify(); // 此时A还没进入wait,notify白调如果“检查条件”和“调用wait”不是原子操作,线程A检查完!ready后,还没来得及进入wait,线程B刚好把ready改成了true并调用了notify。此时A还没有真正进入等待状态,这个notify信号就对A造成了永久性丢失。A随后进入wait,再也等不到下一次notify,线程永久挂起。
把“检查条件”和“进入等待”用synchronized包成一个原子操作,就能堵住这个时间窗。所以synchronized不只是满足语法要求,它的本质是把判断逻辑和挂起逻辑合并为一个不可分割的临界区,防止唤醒信号在缝隙中溜走。
2.4 wait和sleep到底差在哪:一张表看清本质
很多人写过Thread.sleep(),也写过lock.wait(),但没仔细想过它们的区别,这里直接列成表格方便对照:
| 维度 | wait | sleep |
|---|---|---|
| 归属 | Object实例方法 | Thread静态方法 |
| 执行前提 | 必须持有该对象锁 | 不要求持有锁 |
| 是否释放锁 | 释放当前对象锁 | 不释放任何锁 |
| 自动唤醒 | 超时后唤醒 | 超时后继续执行 |
| 主动唤醒 | notify/notifyAll | 无 |
| 设计目的 | 线程间协作,条件等待 | 单纯让当前线程暂停 |
举一个实际踩过的坑。某个项目的定时任务里,我在同步块中用了sleep来代替wait,结果另一个需要访问同一把锁的线程全部堵死。原因很简单,持有锁的线程睡眠期间不释放锁,其他线程光是卡在锁入口就动不了。后来改成wait,线程睡眠期间锁是释放的,其他线程可以正常推进。这个教训让我理解了“sleep是在占用锁资源,wait是在释放锁资源”的区别。
3. 生产者消费者:从一段代码到完整可运行Demo
3.1 场景设定:用最简模型说透机制
理论讲再多,不如直接看一段可运行的代码。我设计一个最简模型:一个生产者线程,一个消费者线程,共享一个“状态位”表示产品是否已生产。产品只有0和1两种状态,本质上是一个容量为1的缓冲位。
生产者的逻辑:如果已经有产品了,就等消费者消费完;否则生产一个,通知消费者。
消费者的逻辑:如果还没有产品,就等生产者生产;否则消费掉,通知生产者。
两个线程通过同一个lock对象进行协作。这个模型虽然简单,但把等待唤醒机制的每一个关键点都暴露出来了,非常有利于理解。
3.2 完整代码:每个细节都能对上前面讲的原理
下面是完整可运行的示例代码,我建议你把它从头到尾敲一遍,不要直接复制,因为手敲的过程会迫使你去思考每个方法的作用。
public class ProduceConsumeDemo { // 共享锁对象:生产者和消费者都依赖这把锁进行协作 private final Object lock = new Object(); // 产品数量,初始为0 private int productCount = 0; // 标记当前产品是否已生产(容量为1) private boolean hasProduct = false; /** * 生产者:产出产品 */ public void produce() throws InterruptedException { synchronized (lock) { // 已有产品,生产者要等待消费者消费 while (hasProduct) { lock.wait(); } productCount++; hasProduct = true; System.out.println("生产者产出第" + productCount + "个产品"); // 生产完成,唤醒可能在等待的消费者 lock.notifyAll(); } } /** * 消费者:消费产品 */ public void consume() throws InterruptedException { synchronized (lock) { // 没有产品,消费者要等待生产者生产 while (!hasProduct) { lock.wait(); } System.out.println("消费者消费了第" + productCount + "个产品"); hasProduct = false; // 消费完成,唤醒可能在等待的生产者 lock.notifyAll(); } } public static void main(String[] args) throws InterruptedException { ProduceConsumeDemo demo = new ProduceConsumeDemo(); Thread producerThread = new Thread("生产者线程") { @Override public void run() { try { for (int i = 0; i < 5; i++) { demo.produce(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }; Thread consumerThread = new Thread("消费者线程") { @Override public void run() { try { for (int i = 0; i < 5; i++) { demo.consume(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }; producerThread.start(); consumerThread.start(); producerThread.join(); consumerThread.join(); System.out.println("全部执行完成"); } }运行结果会稳定输出:生产者产出第1个产品、消费者消费了第1个产品,再到第2个,如此交替一直到第5个。这个交替顺序正是等待唤醒机制的结果。
3.3 为什么唤醒后要重新检查条件:while vs if
代码里两处wait都写在了while循环中,没有直接跟if。我见过不少初学者觉得if就够了,因为条件刚不满足才wait的啊。但这不是一个习惯问题,是一个正确性问题。
先明确一个概念:虚假唤醒。官方文档明确指出,wait方法即使没有被notify、interrupt或超时触发,也存在极小的概率自己醒来。虽然这种情况很少,但规范层面你必须容忍。如果用的是if,被唤醒后直接往下走,不重新检查条件,一旦发生虚假唤醒,逻辑就会在错误的状态下继续执行。
更重要的是,实际业务里大多是多个消费者、多个生产者,signal丢失只是问题的一半,系统唤醒的时候你是无法控制的。用一个具体场景演示:
两个消费者线程都在等待产品,生产者生产了一个产品后调用notifyAll,两个消费者同时被唤醒。如果用的是if,它们两个都会通过条件检查直接进入消费逻辑,但产品只有一个,第二个消费者就会在没产品的情况下继续消费,造成数据错误。如果用的是while,第二个消费者会被唤醒后重新检查!hasProduct,发现产品已被第一个消费者拿走,就继续回到等待状态,不会执行消费逻辑。
这就是为什么规范里明确指出wait要始终放在循环中。这不是锦上添花的建议,而是wait和条件判断之间必须保持的一种契约:wait返回只是说明“可能”有变化,真正判断条件是否满足,必须在每次返回后重新进行。
3.4 为什么是notifyAll而不是notify:随机唤醒的隐患
示例代码里用的是notifyAll。我见过有同学改成notify,单生产者单消费者场景下也能跑,但一到多线程就出问题。
notify的语义是随机唤醒一个线程。问题在于,它不保证唤醒的是“最应该被唤醒”的那个线程。假如缓冲区满了,有多个生产者线程在等待,此时消费者消费了一个产品,调用了notify,唤醒了其中一个生产者,这看起来正常,但如果等待集合里恰好还有其他类型条件的线程,或者唤醒的那个线程重新检查条件后发现自己不是最合适的,它可能再次进入等待,导致原本应该被唤醒的线程没有被唤醒。
如果用它,想象一个场景:两个生产者在等待空白位,两个消费者在等待产品。此时一个消费者完成消费,理论上只需要唤醒一个生产者即可。但notify可能会唤醒另一个消费者,消费者醒来发现没有产品,再次进入等待。这样白白浪费了一次唤醒机会,在需求紧迫的场景会造成不必要的延迟。
notifyAll的做法简单粗暴:把所有等待线程全部唤醒,让它们自己去重新检查条件。代价是可能有一批线程醒来后发现条件还是不满足,又回到等待状态,产生一些无谓的上下文切换。但在大多数应用场景下,正确性远比这点性能开销重要。所以我的实践建议是:除非你能百分之百确定等待集合中的线程都只等待同一个条件,否则统一用notifyAll。
3.5 从最简模型到真实业务:容量和锁的扩展思路
刚才这个例子容量是1,真实业务不可能这么简单。如果要把容量扩展为N,大体思路是把hasProduct布尔值改成ArrayList、Queue这类容器,并在生产时判断容器是否已满,消费时判断容器是否为空:
private final Queue<Integer> queue = new LinkedList<>(); private final int capacity = 10;生产者判断queue.size() == capacity时wait,消费者判断queue.isEmpty()时wait。代码框架和刚才完全一致,只是把布尔条件换成更复杂的条件判断,本质上没有变化。
真实项目里我其实不太推荐直接手写wait/notify来做这种队列,BlockingQueue的实现比手写可靠得多,尤其是ArrayBlockingQueue,它内部用了两个Condition分别管理“队列空”和“队列满”,正是对等待唤醒机制的工程化封装。手写一遍能让你懂原理,业务代码里直接用它更稳。
4. 实战中踩过的坑与排查方法
4.1 五个最常见的错误场景速查表
我把实际开发中反复出现的wait/notify问题整理成一个速查表,遇到类似症状可以直接对号入座:
| 错误场景 | 典型症状 | 根本原因 | 正确姿势 |
|---|---|---|---|
| 在synchronized外调用wait/notify | 运行时抛IllegalMonitorStateException | 线程未持有对象锁 | 把调用放进同步块 |
| 用if代替while判断条件 | 多消费者时数据重复消费、越界 | 唤醒后未重新检查条件 | 用while循环检查条件 |
| wait/notify的锁对象不一致 | 线程一直WAITING不工作 | 等待和唤醒不在同一把锁上 | 保证所有线程使用同一个锁对象 |
| 只调用notify,等待线程多 | 偶发线程卡死、任务堆积 | notify只唤醒一个线程 | 条件复杂时使用notifyAll |
| 忘记在catch里处理InterruptedException | 线程中断后状态错乱 | 中断标志被吞掉 | 重新设置中断标志或退出 |
第一行错误最好查,异常信息直接告诉你少了锁。第三行错误最隐蔽,两个线程各用各的锁对象,代码都能跑,但就是互相等不到,线上排查时jstack一看,全是WAITING状态,就是找不到原因。
4.2 线程一直卡住怎么查:jstack是救星
如果程序跑着跑着没响应了,第一步就是用jstack看线程状态。我一般这样操作:
jps -l # 找到Java进程id jstack -l <pid> # 打印线程堆栈关键看线程状态。处于WAITING状态的线程,堆栈会明确显示它停在了某个对象的wait方法上:
"消费者线程" #12 prio=5 os_prio=0 tid=0x... java.lang.Object.wait(Native Method) - waiting on <0x000000071b5793d8> (a java.lang.Object)看到waiting on后面的对象地址,再去对比代码里锁对象的类型和地址,基本能定位是哪个锁出了问题。
还有一种情况是线程状态为BLOCKED,它不是wait等待条件,而是在锁池里等锁。这两个状态的区别非常关键:BLOCKED通常是锁竞争太激烈或持锁线程卡死在临界区;WAITING则是线程主动让出了锁,在等待被唤醒。排查的时候别搞混,否则方向就反了。
4.3 超时等待与中断处理的正确姿势
如果担心notify永远等不到,可以用带超时的wait。实际场景里,我在等待外部任务结果时经常用到:
synchronized (lock) { while (!taskFinished) { // 最多等5秒,超时自动往下走 lock.wait(5000); if (!taskFinished) { // 处理超时逻辑 break; } } }超时醒来后一定要重新检查条件,因为醒来可能有两种原因:一是超时确实到了,二是notify在超时前被调用了。用while重新检查条件后,再决定是继续等还是超时处理逻辑,这样才能确保语义正确。
关于InterruptedException,它是个检查异常,编译器逼你处理。比较稳妥的写法是捕获后在catch里重新设置中断标志:
catch (InterruptedException e) { // 恢复中断状态,让上层逻辑感知到中断请求 Thread.currentThread().interrupt(); return; }不要忽略它。中断是其他线程发给你的协作信号,不做处理的话信号就丢了,调用方无法感知这次中断。
4.4 内存可见性:notify之前的修改别人能看到吗
最后补一个偏底层但很重要的点。JMM的happens-before规则里有这样一条:对一个对象监视器的解锁,happens-before于后续对这个监视器的加锁。扩展到wait/notify场景,线程在调用notify之前的写入,对从wait中被唤醒的线程是可以看到的。
也就是说,你在同步块里先修改了共享变量,再调用notify,唤醒的线程拿到锁后一定能看到你修改的结果。这相当于wait/notify自动提供了一层内存屏障,保证数据及时可见。这也是为什么那句“在同步块里改条件变量然后通知”被反复强调,它不只是代码风格问题,而是内存可见性层面的保障。
反过来,如果在同步块外面修改共享变量,再调notify,那可见性就没法保证,甚至会出现被唤醒线程看不到最新值的情况。这种bug极难排查,因为大多数时候都能跑对,直到某次硬件或调度让内存可见性出问题,才会以极其诡异的方式暴露。
写在最后的一点个人经验
等待唤醒机制这套东西,看起来是纯八股,但写多线程代码的时候,它是很多并发工具类的底层地基。我后来去翻Condition和ReentrantLock源码时,才意识到当初手写生产者和消费者踩过的坑,在那些成熟工具类里都是怎么被巧妙规避的。所以我还是建议每个Java开发者都动手写一遍这个Demo,用单线程把流程走通,再加多线程模拟并发,遇到卡住就用jstack看状态,把每一个状态、每一次唤醒都搞明白,这样以后再碰线程协作,就不会再踩那些莫名其妙的坑了。