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单生产者-单消费者缓冲区缓冲区容量为 1Producer 调用put()Consumer 调用take()。两者都基于buffer.isEmpty()/buffer.isFull()等待。此时notify()完全够用Producer 放入元素后只需唤醒一个 ConsumerConsumer 取出后只需唤醒一个 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而是实现一个极简但完整的BlockingStackT支持push(T item)栈满时阻塞等待pop()栈空时阻塞等待size()返回当前元素个数仅用于监控非线程安全读取。关键约束容量固定构造时指定所有方法必须线程安全阻塞行为必须符合wait/notify最佳实践不使用java.util.concurrent包内任何类纯 JDK 5 原生实现。接口定义如下为简洁省略泛型声明细节public class BlockingStackT { 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入栈后 notifypublic 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出栈后 notifypublic 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 { BlockingStackString 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在高并发下极大概率出现ArrayIndexOutOfBoundsExceptiontop超出范围验证虚假唤醒真实存在。4. 常见问题与避坑指南来自线上事故现场的 7 条血泪经验4.1 问题速查表快速定位 wait/notify 异常行为现象可能原因快速验证方法线程永远WAITING不响应notify()notify()调用时没有线程在该对象的 WaitSet 中或notify()与wait()使用了不同锁对象jstack pid查看线程状态确认WAITING on java.lang.Objectxxx的地址与notify()调用对象地址一致IllegalMonitorStateExceptionwait()/notify()未在synchronized块内调用检查方法体确认synchronized作用域覆盖了wait/notify调用程序偶尔抛ArrayIndexOutOfBoundsExceptionwait()外层用if而非while遭遇虚假唤醒在wait()后添加日志打印条件变量值观察是否在不满足时执行了后续逻辑CPU 占用率飙升线程频繁进出RUNNABLE/WAITINGnotifyAll()在高并发下滥用导致“惊群”用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 经验 2notify()后不要假设“被唤醒线程立刻执行”它仍需竞争锁这是一个经典误区。看这段伪代码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 经验 3wait(long timeout)的超时时间是“至少等待”不是“精确等待”wait(1000)表示“至少等待 1 秒但可能更长”。原因包括系统负载高线程调度延迟GC STWStop-The-World暂停notify()调用后被唤醒线程需等待锁释放再被调度执行。实测数据在一台 4 核 8G 的测试机上wait(1000)的平均实际等待时间为 1002~1015ms但在 Full GC 期间可达 2~3 秒。因此超时等待必须配合while循环重检条件不能当作“定时器”用。4.5 经验 4notifyAll()不是性能毒药而是安全底线很多文章鼓吹“notifyAll()性能差尽量用notify()”。这在单条件场景下成立但一旦业务逻辑稍复杂notifyAll()就是救命稻草。我曾参与一个金融清算系统重构原代码用notify()控制资金划转结果在压力测试中出现“资金卡单”——一笔付款通知只唤醒了一个清算线程但该线程因网络超时失败其他线程却一直沉睡。上线后改为notifyAll()问题消失。性能损耗在千分之一毫秒级远低于一次数据库 round-trip。4.6 经验 5wait()的中断处理必须传播中断状态// ❌ 错误吞掉中断 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()但通过AbstractQueuedSynchronizerAQS维护了多个独立的等待队列。这就像把一个大候车室Object WaitSet拆分成多个专用候车厅Condition每个厅只服务一类乘客特定条件。5.3 为什么synchronized没有被取代简单即正义尽管ReentrantLockCondition更灵活但synchronized从未过时。原因在于JVM 优化极致HotSpot 对synchronized做了大量优化偏向锁、轻量级锁、重量级锁升级在低竞争场景下性能优于ReentrantLock语法简洁synchronized是关键字编译器自动插入monitorenter/monitorexit字节码无需手动lock()/unlock()杜绝“忘记解锁”导致死锁内存语义明确synchronized块的进入和退出天然建立 Happens-Before 关系保证临界区内变量的可见性。我的建议是优先用synchronizedwait/notify解决 90% 的简单协作场景当需要多个条件队列、可中断等待、超时获取锁等高级特性时再切换到ReentrantLockCondition。不要为了“新技术”而放弃经过十年生产验证的成熟方案。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 为 trueinstance 必然已赋值 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()都是在和线程世界签订一份契约我承诺等待你承诺通知。契约精神才是并发稳定的真正基石。