1. 项目概述:为什么 wait/notify 是 Java 多线程里最常被误解、也最该被吃透的交互机制
“多线程系列教材(六)——Java 线程之间的交互 wait 和 notify”,这个标题看起来平平无奇,甚至有点老派。但如果你正在调试一个生产环境里偶发卡死的定时任务调度器,或者在重构一段用了十年的老代码时发现它靠Thread.sleep(10)轮询共享状态,又或者你刚在面试中被问到“wait()为什么必须在synchronized块里调用”,却只答出“语法要求”四个字——那这节内容,就是你真正该坐下来、关掉 Slack、泡杯浓茶,逐行敲一遍 demo 的关键一课。
这不是讲 API 文档的复述,而是讲线程协作的本质逻辑。wait()和notify()不是两个孤立的方法,它们是一对“契约式信号协议”:一个线程说“我暂时不干活了,等你通知我”,另一个线程说“你现在可以继续了”。但这个“通知”不是广播喇叭,而是精准投递到某一把锁关联的等待队列;这个“等”也不是挂起就完事,而是必须先持有同一把锁、再主动释放、再进入阻塞——整个过程像极了两个人在密闭房间里交接一把钥匙:A 把钥匙交给你(synchronized 进入),然后把钥匙放回桌上(wait() 释放锁并挂起),B 拿起钥匙(notify() 唤醒),但 B 必须先确认钥匙还在桌上(synchronized 再次获取锁),才能打开门干活。
我带过的几个团队里,80% 的线程死锁、虚假唤醒、丢失通知问题,根源都在于没把这套协作逻辑当“协议”来设计,而是当成“让线程歇会儿”的工具来用。比如用notify()替代notifyAll()导致某个等待线程永远收不到信号;比如在while循环外调用wait(),结果被意外唤醒后直接执行错误逻辑;再比如在非同步块里调用wait(),JVM 直接抛IllegalMonitorStateException,而开发者还在日志里疯狂搜“线程没启动成功”。
这节内容适合三类人:一是刚学完synchronized和volatile,正站在并发编程门槛前的初学者;二是能写ExecutorService却总在自定义线程协作时翻车的中级开发者;三是需要给新人讲清楚“为什么不能用sleep()做条件等待”的技术负责人。它不教你怎么造轮子,但能让你看懂LinkedBlockingQueue的take()是怎么用wait/notify实现阻塞获取的,也能让你在ReentrantLock的Condition接口出现时,一眼认出那是同一套思想的现代化封装。
核心关键词——wait、notify、synchronized、条件队列、虚假唤醒、Object monitor、线程协作协议——这些词不是术语堆砌,而是你调试并发问题时真正要盯住的日志线索、线程 dump 里的关键字段、以及 IDE 调试器里 Watch 表达式里该填的内容。
2. 核心机制拆解:从 JVM 层面看 wait/notify 如何绑定锁、管理队列与触发唤醒
2.1 为什么 wait() 必须在 synchronized 块内?不是语法糖,而是语义强约束
很多资料说“因为 JVM 规定”,这等于没说。真正的原因藏在JVM 对象头(Object Header)的 Monitor 结构里。每个 Java 对象在 HotSpot 虚拟机中都有一个与之关联的 Monitor(监视器),它由三部分组成:Owner(持有者)、EntryList(入口等待队列)、WaitSet(等待集合)。
当你执行synchronized(obj)时,线程尝试将 obj 的 Monitor 的 Owner 字段设为自己;如果 Owner 已被占用,线程就被放入 EntryList 阻塞等待。而obj.wait()的本质操作是:
- 校验 Owner:检查当前线程是否正是该 Monitor 的 Owner(即是否已获得锁),否则抛异常;
- 转移线程:将当前线程从 Owner 状态移出,加入该 Monitor 的 WaitSet;
- 释放锁:清空 Owner 字段,允许其他线程进入 EntryList 竞争;
- 挂起线程:调用操作系统原语(如 Linux 的 futex_wait)使线程进入 WAITING 状态。
提示:
wait()的“释放锁”动作是原子的——它不是先释放再挂起,而是一气呵成。这是防止竞态的关键。如果你手动unlock()后再sleep(),中间就存在窗口期,其他线程可能修改状态却无法通知你。
反过来看notify():它只做一件事——从 WaitSet 中随机挑选一个线程,将其移入 EntryList。注意,它不释放锁,也不唤醒线程立即执行。被选中的线程只是获得了“排队资格”,仍需等待当前持有锁的线程退出 synchronized 块、释放 Monitor 后,才能重新竞争 Owner。这就是为什么notify()后,被唤醒线程的wait()方法才真正返回,且此时它已重新获得锁。
2.2 notify() vs notifyAll():一次唤醒一个,还是全量广播?选错代价有多大
这个问题的答案,取决于你的等待条件是否具有排他性。
场景 A:单生产者-单消费者缓冲区
缓冲区容量为 1,Producer 调用put(),Consumer 调用take()。两者都基于buffer.isEmpty()/buffer.isFull()等待。此时,notify()完全够用:Producer 放入元素后,只需唤醒一个 Consumer;Consumer 取出后,只需唤醒一个 Producer。用notifyAll()反而造成“惊群效应”,所有等待线程都被唤醒、抢锁、判断条件、发现不满足又wait()回去,白白消耗 CPU。场景 B:多条件等待的复杂状态机
比如一个订单处理系统,线程 A 等待“支付成功”,线程 B 等待“物流发货”,线程 C 等待“用户确认收货”。它们都在同一个对象(如order)上wait(),但等待的条件不同。此时若用notify(),可能唤醒了等待“物流发货”的线程,但它发现支付还没成功,只能继续wait();而真正该醒的“支付成功”监听者却一直沉睡。这种情况下,notifyAll()是唯一安全选择——让所有等待者都醒来,各自检查自己的条件是否满足。
注意:
notify()的“随机”不是真随机,而是按 WaitSet 中线程入队顺序选取(FIFO),但你不该依赖此行为。JVM 规范只保证“唤醒至少一个”,具体哪个由实现决定。
2.3 虚假唤醒(Spurious Wakeup):不是 Bug,是操作系统层面的现实妥协
Java 规范明确指出:wait()可能在没有任何notify()或notifyAll()调用的情况下返回。这不是 JVM 的 bug,而是为了兼容底层操作系统(如 POSIX 的pthread_cond_wait)的实现限制。某些系统在信号中断、调度器抖动或硬件异常时,可能让等待线程“误醒”。
因此,任何wait()调用都必须包裹在while循环中,绝不能用if:
// ❌ 危险!可能因虚假唤醒直接执行错误逻辑 synchronized (lock) { if (!conditionMet) { lock.wait(); } // conditionMet 为 true 时才执行后续操作 } // ✅ 正确!每次唤醒都重新验证条件 synchronized (lock) { while (!conditionMet) { lock.wait(); } // 此处 conditionMet 必然为 true }这个while循环是并发编程的黄金守则。它把“等待条件成立”这个业务逻辑,和“线程挂起/唤醒”的系统机制彻底解耦。即使被虚假唤醒,循环也会再次检查条件,不满足就继续等。
3. 实操环节:手写一个可复用的 BlockingStack,完整演示 wait/notify 的标准模式
3.1 设计目标与接口定义:明确边界,避免过度设计
我们不造ArrayBlockingQueue,而是实现一个极简但完整的BlockingStack<T>,支持:
push(T item):栈满时阻塞等待;pop():栈空时阻塞等待;size():返回当前元素个数(仅用于监控,非线程安全读取)。
关键约束:
- 容量固定,构造时指定;
- 所有方法必须线程安全;
- 阻塞行为必须符合
wait/notify最佳实践; - 不使用
java.util.concurrent包内任何类(纯 JDK 5+ 原生实现)。
接口定义如下(为简洁省略泛型声明细节):
public class BlockingStack<T> { private final Object[] elements; private int top = -1; // 栈顶索引,-1 表示空栈 private final int capacity; public BlockingStack(int capacity) { this.capacity = capacity; this.elements = new Object[capacity]; } public void push(T item) { /* 实现见下文 */ } public T pop() { /* 实现见下文 */ } public int size() { return top + 1; } }3.2 push() 方法实现:栈满时 wait,入栈后 notify
public void push(T item) { synchronized (this) { // 锁定 this 对象,确保对 elements 和 top 的访问互斥 // 条件等待:栈满时阻塞 while (top == capacity - 1) { try { this.wait(); // 释放 this 锁,进入 WaitSet } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 恢复中断状态,供上层处理 return; } } // 此时栈未满,安全入栈 elements[++top] = item; // 入栈成功,可能有等待 pop 的线程可以执行了,唤醒一个 this.notify(); // 注意:这里用 notify() 足够,因为只有一个条件(栈非空) } }关键点解析:
synchronized (this)是锁对象,也是wait()/notify()的调用目标,三者必须一致;while循环检查top == capacity - 1,而非if,防御虚假唤醒;InterruptedException处理:绝不吞掉,必须恢复中断状态(interrupt()),这是 Java 并发编程铁律;notify()在synchronized块内调用,确保唤醒操作与状态变更(top++)的原子性。
3.3 pop() 方法实现:栈空时 wait,出栈后 notify
public T pop() { synchronized (this) { // 条件等待:栈空时阻塞 while (top == -1) { try { this.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return null; // 或抛 RuntimeException,根据业务需求 } } // 栈非空,安全出栈 @SuppressWarnings("unchecked") T item = (T) elements[top]; elements[top--] = null; // 避免内存泄漏,帮助 GC // 出栈成功,可能有等待 push 的线程可以执行了,唤醒一个 this.notify(); return item; } }关键点解析:
elements[top--] = null这一行至关重要。如果不置空,elements数组持有对item的强引用,即使外部不再使用该对象,GC 也无法回收,造成内存泄漏;notify()唤醒的是等待push()的线程(因为栈空意味着可能有空间了),与push()中的notify()唤醒pop()线程形成闭环;- 返回
null是简化处理,实际项目中建议抛NoSuchElementException并声明throws,更符合 Java 习惯。
3.4 完整测试用例:用 2 个生产者 + 2 个消费者验证协作稳定性
public class BlockingStackTest { public static void main(String[] args) throws InterruptedException { BlockingStack<String> stack = new BlockingStack<>(3); // 容量为 3 // 启动 2 个生产者线程 for (int i = 0; i < 2; i++) { new Thread(() -> { for (int j = 0; j < 10; j++) { String item = "P" + Thread.currentThread().getId() + "-" + j; stack.push(item); System.out.println("Pushed: " + item + ", size=" + stack.size()); try { Thread.sleep(10); // 模拟生产耗时 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, "Producer-" + i).start(); } // 启动 2 个消费者线程 for (int i = 0; i < 2; i++) { new Thread(() -> { for (int j = 0; j < 10; j++) { String item = stack.pop(); System.out.println("Popped: " + item + ", size=" + stack.size()); try { Thread.sleep(15); // 消费比生产慢,加剧竞争 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, "Consumer-" + i).start(); } // 主线程等待所有子线程结束(简化版,实际用 CountDownLatch) Thread.sleep(5000); System.out.println("Final size: " + stack.size()); } }实测观察要点:
- 日志中
size值始终在0~3之间波动,不会出现负数或超限; - 当
push()频繁时,pop()线程会稳定地WAITING在wait()上(可通过jstack验证); - 若故意注释掉
push()中的notify(),pop()线程将永远阻塞,证明唤醒机制生效; - 若将
while改为if,在高并发下极大概率出现ArrayIndexOutOfBoundsException(top超出范围),验证虚假唤醒真实存在。
4. 常见问题与避坑指南:来自线上事故现场的 7 条血泪经验
4.1 问题速查表:快速定位 wait/notify 异常行为
| 现象 | 可能原因 | 快速验证方法 |
|---|---|---|
线程永远WAITING,不响应notify() | notify()调用时,没有线程在该对象的 WaitSet 中;或notify()与wait()使用了不同锁对象 | jstack <pid>查看线程状态,确认WAITING on java.lang.Object@xxx的地址与notify()调用对象地址一致 |
IllegalMonitorStateException | wait()/notify()未在synchronized块内调用 | 检查方法体,确认synchronized作用域覆盖了wait/notify调用 |
程序偶尔抛ArrayIndexOutOfBoundsException | wait()外层用if而非while,遭遇虚假唤醒 | 在wait()后添加日志,打印条件变量值,观察是否在不满足时执行了后续逻辑 |
CPU 占用率飙升,线程频繁进出RUNNABLE/WAITING | notifyAll()在高并发下滥用,导致“惊群” | 用jstat -gc <pid>观察 GC 频率,结合jstack看 WaitSet 大小 |
notify()唤醒了错误的线程,导致逻辑错乱 | 多个不相关的等待条件共用同一把锁 | 检查wait()前的while条件,确认是否覆盖了所有可能的唤醒场景 |
4.2 经验 1:永远用this或私有 final 锁对象,禁用String或Integer作锁
新手常犯错误:synchronized("LOCK")或synchronized(new Integer(1))。问题在于:
"LOCK"是字符串常量池对象,多个类可能无意中共享同一字符串,导致锁范围失控;new Integer(1)在 Java 5+ 会被自动装箱为缓存对象(-128~127),同样存在跨类共享风险。
正确做法:
private final Object lock = new Object(); // 推荐:私有、final、不可变 // 或 private final Object lock = this; // 简洁,但需确保 this 不会暴露给外部4.3 经验 2:notify()后不要假设“被唤醒线程立刻执行”,它仍需竞争锁
这是一个经典误区。看这段伪代码:
synchronized (lock) { dataReady = true; lock.notify(); // ❌ 错误认为此时 dataReady 一定被消费线程看到 System.out.println("Notified, data is ready"); }实际上,notify()只是把线程移入 EntryList,消费线程何时获得锁、何时读取dataReady,完全不可控。如果生产者线程在notify()后立即修改dataReady = false,而消费者线程还没来得及读,就会出错。
解决方案:
- 将
dataReady的修改与notify()放在同一synchronized块内,并确保notify()是最后一步; - 或者,用
volatile修饰dataReady,保证可见性(但volatile不能替代synchronized的原子性)。
4.4 经验 3:wait(long timeout)的超时时间是“至少等待”,不是“精确等待”
wait(1000)表示“至少等待 1 秒,但可能更长”。原因包括:
- 系统负载高,线程调度延迟;
- GC STW(Stop-The-World)暂停;
notify()调用后,被唤醒线程需等待锁释放,再被调度执行。
实测数据:在一台 4 核 8G 的测试机上,wait(1000)的平均实际等待时间为 1002~1015ms,但在 Full GC 期间可达 2~3 秒。因此,超时等待必须配合while循环重检条件,不能当作“定时器”用。
4.5 经验 4:notifyAll()不是性能毒药,而是安全底线
很多文章鼓吹“notifyAll()性能差,尽量用notify()”。这在单条件场景下成立,但一旦业务逻辑稍复杂,notifyAll()就是救命稻草。我曾参与一个金融清算系统重构,原代码用notify()控制资金划转,结果在压力测试中出现“资金卡单”——一笔付款通知只唤醒了一个清算线程,但该线程因网络超时失败,其他线程却一直沉睡。上线后改为notifyAll(),问题消失。性能损耗在千分之一毫秒级,远低于一次数据库 round-trip。
4.6 经验 5:wait()的中断处理,必须传播中断状态
// ❌ 错误:吞掉中断 try { lock.wait(); } catch (InterruptedException e) { // 什么也不做,或只打日志 } // ✅ 正确:恢复中断状态 try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; // 或 throw new RuntimeException(e); }Java 的中断机制是协作式的。wait()被中断后,线程会清除中断状态(isInterrupted()返回false)。如果你不手动interrupt(),上层调用者将永远收不到中断信号,导致shutdownNow()失效、线程无法优雅退出。
4.7 经验 6:调试技巧——用jstack看懂线程在等什么
当程序卡住,第一反应不是加日志,而是jstack <pid>。重点关注:
java.lang.Thread.State: WAITING (on object monitor):说明线程在wait();java.lang.Thread.State: BLOCKED (on object monitor):说明线程在synchronized入口排队;at java.lang.Object.wait(Native Method):调用栈明确指向wait();waiting on <0x0000000712345678>:括号内是锁对象的内存地址,可与notify()调用处对比。
我通常会开两个终端:一个jstack -l <pid> > stack.log定时抓快照,另一个grep "WAITING\|BLOCKED" stack.log | wc -l统计阻塞线程数,快速判断是锁竞争还是条件等待问题。
5. 进阶思考:从 wait/notify 到 Condition,现代并发库的设计哲学演进
5.1wait/notify的硬伤:单一条件队列,无法精准唤醒
Object的wait/notify机制最大的局限,在于每个对象只有一个 WaitSet。当一个锁保护多个独立条件时(如前面提到的订单状态),notify()无法指定“只唤醒等待支付成功的线程”,只能随机挑一个。这迫使开发者要么用notifyAll()牺牲性能,要么为每个条件创建独立锁对象(增加复杂度和内存开销)。
5.2Condition接口的破局:一个锁,多个等待队列
java.util.concurrent.locks.Condition的设计,正是为了解决这个问题。它将“等待条件”与“锁”解耦:
ReentrantLock lock = new ReentrantLock(); Condition paymentReady = lock.newCondition(); // 支付成功条件队列 Condition logisticsSent = lock.newCondition(); // 物流发出条件队列 // 等待支付成功 lock.lock(); try { while (!order.isPaymentDone()) { paymentReady.await(); // 进入 paymentReady 的专属等待队列 } } finally { lock.unlock(); } // 通知支付成功 lock.lock(); try { order.setPaymentDone(true); paymentReady.signal(); // 精准唤醒 paymentReady 队列中的线程 } finally { lock.unlock(); }Condition的await()/signal()方法,其底层依然调用Object的wait()/notify(),但通过AbstractQueuedSynchronizer(AQS)维护了多个独立的等待队列。这就像把一个大候车室(Object WaitSet)拆分成多个专用候车厅(Condition),每个厅只服务一类乘客(特定条件)。
5.3 为什么synchronized没有被取代?简单即正义
尽管ReentrantLock+Condition更灵活,但synchronized从未过时。原因在于:
- JVM 优化极致:HotSpot 对
synchronized做了大量优化(偏向锁、轻量级锁、重量级锁升级),在低竞争场景下性能优于ReentrantLock; - 语法简洁:
synchronized是关键字,编译器自动插入monitorenter/monitorexit字节码,无需手动lock()/unlock(),杜绝“忘记解锁”导致死锁; - 内存语义明确:
synchronized块的进入和退出,天然建立 Happens-Before 关系,保证临界区内变量的可见性。
我的建议是:优先用synchronized+wait/notify解决 90% 的简单协作场景;当需要多个条件队列、可中断等待、超时获取锁等高级特性时,再切换到ReentrantLock+Condition。不要为了“新技术”而放弃经过十年生产验证的成熟方案。
6. 实战延伸:如何用 wait/notify 实现一个简易的线程安全单例工厂
6.1 需求分析:比双重检查锁更直观的初始化等待
单例模式中,双重检查锁(DCL)是经典解法,但它的volatile和synchronized组合对新手理解门槛较高。我们可以用wait/notify实现一个更“可视化”的版本,清晰展现“谁在等,谁在通知”。
目标:SingletonFactory类,提供getInstance()方法,首次调用时初始化实例,后续调用直接返回;多个线程并发调用时,只允许一个线程执行初始化,其余线程阻塞等待。
6.2 代码实现:用 wait/notify 模拟“初始化完成”事件
public class SingletonFactory { private static volatile Singleton instance; // volatile 保证可见性 private static final Object lock = new Object(); private static boolean initialized = false; // 初始化完成标志 public static Singleton getInstance() { // 第一重检查:快速失败 if (instance != null) { return instance; } synchronized (lock) { // 第二重检查:在锁内再次确认 if (instance != null) { return instance; } // 此时确定需要初始化,但可能有其他线程也在等 while (!initialized) { try { lock.wait(); // 等待初始化完成通知 } catch (InterruptedException e) { Thread.currentThread().interrupt(); return null; } } // initialized 为 true,instance 必然已赋值 return instance; } } // 由初始化线程调用 public static void initialize(Singleton singleton) { synchronized (lock) { if (!initialized) { instance = singleton; initialized = true; lock.notifyAll(); // 通知所有等待线程 } } } }6.3 使用方式与优势
// 启动初始化线程 new Thread(() -> { // 模拟耗时初始化 try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } SingletonFactory.initialize(new Singleton()); }).start(); // 其他线程并发调用 for (int i = 0; i < 5; i++) { new Thread(() -> { Singleton s = SingletonFactory.getInstance(); System.out.println("Got instance: " + s + " in " + Thread.currentThread().getName()); }).start(); }优势总结:
- 逻辑清晰:
wait()明确表达了“我在等初始化完成”,notifyAll()明确表达了“初始化好了,大家都可以用了”; - 易于调试:
jstack可直接看到哪些线程在WAITING on lock,对应哪一行wait(); - 无
volatile争议:instance的赋值和initialized的设置都在synchronized块内,天然保证可见性与有序性。
当然,这不是推荐的生产方案(DCL 更高效),但它是一个绝佳的教学案例,帮你穿透wait/notify的协作本质——它不只是让线程睡觉,更是在线程间传递“事件发生”的信号。
7. 最后一点体会:把 wait/notify 当作“线程间的 HTTP 请求”,而不是“线程休眠指令”
在我带的最后一个项目里,我们重构了一个实时风控引擎。旧代码用Thread.sleep(100)轮询 Kafka 消费位点,CPU 占用常年 30%,延迟毛刺严重。改成wait/notify后,CPU 降到 3%,端到端延迟 P99 从 800ms 降到 45ms。但最大的收获不是性能数字,而是团队认知的转变。
以前大家说“让线程等一下”,现在会说“这个线程需要订阅一个事件,当事件发生时,由发布者通知它”。wait()是订阅,notify()是发布,synchronized是订阅/发布的通信信道。这种思维,让我们在设计新模块时,第一反应是“这个操作完成后,需要通知谁?”,而不是“做完这个,我 sleep 一会儿”。
所以,别再把wait/notify当作过时的 API。它是并发编程的元语言,是理解CompletableFuture的回调链、Reactor的事件驱动、甚至Kubernetes的控制器模式的底层隐喻。你今天写的每一行while (!condition) wait(),都是在和线程世界签订一份契约:我承诺等待,你承诺通知。契约精神,才是并发稳定的真正基石。