news 2026/10/17 5:51:43

Java线程等待唤醒机制:wait/notify实战与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java线程等待唤醒机制:wait/notify实战与避坑指南

线程的等待唤醒机制,很多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(),但没仔细想过它们的区别,这里直接列成表格方便对照:

维度waitsleep
归属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看状态,把每一个状态、每一次唤醒都搞明白,这样以后再碰线程协作,就不会再踩那些莫名其妙的坑了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 5:51:35

Cortex-M与Cortex-A运行模式及寄存器组深度对比

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 5:51:32

Linux C++二维码识别实战:Zxing-cpp库高效解码指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 5:51:10

六个经典CNN模型:从AlexNet到MobileNet的进化与PyTorch实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 5:50:23

数字人源码包实战:从跑通到口型同步调参避坑指南

简介&#xff1a;这份数字人源码下载包面向对虚拟角色生成、人机交互感兴趣的开发者与研究人员&#xff0c;尤其适合具备一定前端或小程序开发基础、希望从源码层面理解数字人实现机制的学习者。资源包共129个文件&#xff0c;压缩后约687KB&#xff0c;以JavaScript逻辑脚本、…

作者头像 李华
网站建设 2026/10/17 5:49:25

Vue面试核心原理与高频题拆解:响应式、虚拟DOM、diff与组件通信

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华