做了几年Java开发,如果你还没在线程等待唤醒机制上栽过跟头,大概率是写并发代码写得还不够多。我印象很深的一次,是在某个模拟项目的多线程模块里,有人用Object的wait配合notify做任务队列,单线程测试跑得好好的,一上多消费者就开始随机卡死,jstack一看,所有线程全部挂在wait上,谁也不唤醒谁。Java里线程的等待唤醒机制,核心就三件套:wait、notify、notifyAll,但真要到项目里不出问题,光背结论是不够的。这篇文章就把这组API掰开揉碎讲清楚:为什么wait必须在synchronized里、wait与notify释放锁的时机差异、虚假唤醒怎么防、notify和notifyAll怎么选,以及从wait/notify到LockSupport、Condition的演进逻辑。适合正在学并发编程的初学者,也适合写过不少线程代码但没深究过底层语义的开发者。
1. wait为什么必须在synchronized里:monitor锁与IllegalMonitorStateException
1.1 线程协作为什么需要等待唤醒
并发编程有两类需求:互斥与协作。synchronized解决的是互斥问题,保证同一时刻只有一个线程进入临界区,但它解决不了“线程A要等线程B做完某件事才能继续”的协作问题。比如生产者线程要等消费者把缓冲区腾空才能继续放数据,消费者要等生产者放进来才能取——这两类线程需要的是“你好了叫我一声”,而不是互相干等。
有一种解决方案是自旋,让线程空转检查某个标志位。但这种做法在等待时间不确定时非常浪费CPU,而且需要额外小心内存可见性。另一种方案是sleep固定时间,但等待方根本不知道对方什么时候准备好,睡短了浪费,睡长了延迟。
wait/notify就是Java为此提供的基础设施:Object.wait()让当前线程放弃CPU、进入等待状态,直到另一个线程调用notify/notifyAll把它唤醒。这里有个很多人一开始会困惑的点:wait/notify不是Thread上的方法,而是Object上的方法。原因是它操作的对象是每个Java对象都关联的monitor监视器,而不是线程本身。Thread.sleep这类才是真正属于线程的暂停操作。
1.2 monitor到底是什么:每个对象自带的一把锁
monitor是JVM为每个对象维护的一个内部锁结构。在HotSpot虚拟机里,对象头(mark word)记录了锁的当前状态,包括无锁、偏向锁、轻量级锁、重量级锁。当竞争激烈升级到重量级锁时,mark word会指向一个ObjectMonitor对象,这个对象内部维护了三个关键内容:_owner(当前持有锁的线程)、_entryList(等待获取锁的线程队列)、_waitSet(调用过wait的线程集合)。
synchronized代码块编译后实际上是monitorenter和monitorexit两条指令,本质上是去抢某个对象的monitor。wait在调用时必须确认当前线程已经持有这个对象的monitor,然后释放monitor,把当前线程放进_waitSet,线程状态变成WAITING。
这就解释了为什么wait必须在synchronized里:你都没有持有monitor,凭什么释放它?就像一个没拿钥匙的人对保安说“我交钥匙进场”,制度上根本不成立。所以JVM直接抛IllegalMonitorStateException。
1.3 不在同步块里调用wait会发生什么
Object lock = new Object(); lock.wait(); // 运行时抛出 IllegalMonitorStateException上面这段代码编译能通过,运行时才抛异常,因为JVM在运行时检查当前线程有没有持有lock的monitor。注意两个容易搞混的细节:
- 在
synchronized方法内部调用this.wait()是合法的,因为方法级别锁的就是this。 - 在
synchronized方法里对另一个对象obj.wait()不合法,因为你只持有this的monitor,并不持有obj的monitor。
所以wait和notify必须用在同一个锁对象上,这是整套机制的基础。很多线上死锁问题,追根究底都是“锁对象不是同一个”这种低级错误。
2. wait释放锁,notify却不释放:理解WaitSet与EntryList的走向
2.1 wait的释放是立即的,notify的释放是延迟的
wait释放锁,这几乎是所有教程都会提的结论。但很多人以为notify之后,被唤醒的线程马上就能跑起来,这是理解上的一个常见误区。实际情况是:notify线程调用notify时,它自己仍然握着锁,必须等当前synchronized块执行完,锁才真正释放。
打个比方:wait像“去候车室等通知”,线程把座位让出来,进候车室;notify像广播“车到了”,被叫到的人从候车室往检票口走,但检票口一次只放一个人进去,而且广播的人自己得先出站,别人才进得来。
为什么这样设计?因为synchronized块内是原子的,notify只是发出一个信号。如果notify之后立刻交出控制权,当前线程后面的代码就可能和唤醒线程并发执行同一块临界区,破坏互斥,这是绝对不允许的。
2.2 用一个实验验证notify后的持锁行为
不信的话可以跑一下这个Demo,把时间戳打出来,比背结论直观得多:
public class NotifyHoldsLock { static final Object lock = new Object(); public static void main(String[] args) throws Exception { Thread waiter = new Thread(() -> { synchronized (lock) { try { System.out.println("waiter: about to wait, time = " + System.currentTimeMillis()); lock.wait(); System.out.println("waiter: resumed, time = " + System.currentTimeMillis()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }); waiter.start(); Thread.sleep(200); // 确保 waiter 先进入 wait synchronized (lock) { System.out.println("notifier: holding lock, time = " + System.currentTimeMillis()); lock.notify(); System.out.println("notifier: notified, still holding lock, going to sleep"); Thread.sleep(2000); System.out.println("notifier: exiting synchronized block, time = " + System.currentTimeMillis()); } } }运行结果里,“waiter: resumed”要等到“exiting synchronized block”之后才打印,两者时间差大约2秒。这个实验直接证明了:被notify唤醒不等于立刻执行,唤醒的只是“排队资格”,真正跑起来还要等锁。
2.3 被唤醒线程为什么不能立刻抢到锁
再往细看HotSpot的行为流程:
- 线程A调用
lock.wait():释放monitor,A进入lock的_waitSet,状态WAITING。 - 线程B进入
synchronized(lock):抢到monitor,成为_owner。 - B调用
lock.notify():从_waitSet里选一个线程(notifyAll则是全部),把它移到_entryList,状态变为BLOCKED。 - 注意B此时仍是
_owner,被唤醒的线程只是去排队等锁。 - B退出
synchronized块后,_owner变空,_entryList里的线程重新竞争锁,被唤醒的线程抢到锁后,wait()才真正返回。
理解这条链路对排查问题非常有用。jstack里如果看到多个线程WAITING,但没有任何线程在持锁或准备notify,基本就是丢失唤醒。如果看到一堆线程BLOCKED在某把锁上,那不是等待唤醒的问题,而是notifyAll把所有等待线程都叫醒了,大家在排队抢锁,属于惊群效应。
3. 虚假唤醒与notify/notifyAll:生产消费里最容易翻车的点
3.1 虚假唤醒不是玄学,是底层实现本来就有的行为
“虚假唤醒”听起来像黑魔法,其实没有那么神秘。两个来源:一是JVM规范允许线程在没有收到notify/notifyAll的情况下被唤醒,某些操作系统在特定条件下确实会这么干;二是即便线程被正常唤醒,它等待的条件也可能已经被其他线程抢先消费了。
比如有一个boolean ready标志,线程A在等它变成true:
synchronized (lock) { if (!ready) { lock.wait(); } // 假设 ready 一定为 true,开始干活 }A被唤醒后,B线程抢先获得锁,把ready改成false并消费了这个事件。A拿到锁后从wait()返回,但没有重新检查ready,直接进入“条件已满足”的逻辑,数据就错了。这不是wait的bug,而是条件变量编程本身就要求:等待方必须对条件做循环检查,醒来之后重新确认一次。
3.2 while循环是唯一正确写法
正确的范式是:
synchronized (lock) { while (!condition) { lock.wait(); } // condition 成立之后的逻辑 }wait的官方文档明确要求:它应该永远在循环里调用。原因只有一句话:只有重新检查条件,才能同时防住虚假唤醒和条件被其他线程抢先消费两种情况。这是Java并发编程里少有的“没有商量余地”的规则。
用if写成一次性判断,在单生产者单消费者场景下碰巧能跑,一旦线程数量增加,问题立刻浮现,而且表现为间歇性死锁或偶发数据错乱,非常难查。我见过有同事在这上面调了两天,最后还是靠jstack发现两个线程都挂在wait上,才意识到是if和while的区别。
3.3 notify还是notifyAll:经典死锁推演
场景:两个消费者线程C1、C2,一个生产者线程P,共享容量为1的缓冲区。
- 缓冲区空,C1、C2都
wait。 - P生产了一个元素,调
notify(),随机唤醒一个线程。 - 假设唤醒C1,C1消费元素,一切正常。
- 但如果P生产完,缓冲区满,P自己也
wait。C1消费后缓冲区空,C1调notify()——此刻应该唤醒生产者P,而不是消费者。如果C1盲目的notify()不小心唤醒C2,C2发现缓冲区空继续wait,而P还在wait,于是所有线程全部WAITING,死锁。
问题就出在notify是随机选一个线程。选中条件不满足的线程,通知机会就被浪费了。线程一多,浪费概率线性上升,最终可能全部线程都在干等。
notifyAll把所有人都叫醒,让大家各自检查条件,问题不会出现,代价是带来锁竞争(惊群效应)。但在绝大多数自定义队列场景里,notifyAll是更稳妥的选择。什么时候能用notify?所有等待线程都在等同一个条件,唤醒任意一个都可以,且醒后不需要重新检查条件——现实中很少有这么理想的状态,新手阶段优先notifyAll。
3.4 notify的调用时机也很关键
还有一个常被忽略的点:notify必须在条件真正满足之后、还在持锁时调用。
synchronized (lock) { data = produce(); // 修改完共享状态后再 notify lock.notifyAll(); }如果先notify再改状态,被唤醒的线程抢到锁后看到的还是旧状态,检查while仍然不满足,又回去wait,等于白唤醒一场。记住一个原则:先改共享状态,再发通知。
4. LockSupport.park/unpark:一张许可证打破synchronized的限制
4.1 permit机制:一张许可证的哲学
LockSupport是JUC底层的工具,提供了park和unpark两个静态方法。它不依赖synchronized,也不操作对象monitor,而是基于permit机制。
permit可以理解成一张“待使用的放行许可证”:
park():检查有没有许可证,有就消费掉继续执行;没有就挂起,等待许可证。unpark(thread):给指定线程发一张许可证。如果线程已经在park挂起,收到许可证立刻恢复;如果线程还没park,许可证先存着,等它park时直接使用。
许可证不会累积,最多一张。所以连续两次unpark也只会有一张许可证生效,但反过来,这个机制保证了通知不会丢失——这是wait/notify做不到的。
4.2 unpark先于park:丢不掉的通知
wait/notify有个经典缺陷:如果线程还没调用wait,另一个线程先调了notify,这个通知就丢了,之后等待者会永远挂起。LockSupport的设计没有这个问题:unpark先执行,permit被记下来,线程之后无论什么时候park,立刻就能通过。
这个特性在“异步转同步”场景里非常有用。比如主线程发起一个异步任务,回调线程完成任务后unpark主线程。哪怕主线程还没来得及park,回调先发生了,主线程后面park也一样会被放行,不会卡死。
4.3 park/unpark与wait/notify的几组关键差异
| 维度 | wait/notify | LockSupport.park/unpark |
|---|---|---|
| 是否要求持有锁 | 必须持有对象的monitor | 不要求 |
| 是否释放已持有锁 | wait会释放monitor | park不会释放任何锁 |
| 通知是否会丢失 | notify先于wait调用会丢失 | unpark先于park会被记住,不丢失 |
| 能否指定唤醒线程 | notify随机选一个,notifyAll全部 | unpark可精确指定某个线程 |
| 响应中断 | wait抛InterruptedException | park被中断时直接返回,不抛异常 |
| 典型应用 | 条件队列协作 | AQS、锁实现底层 |
park被中断时不抛InterruptedException,而是直接返回,这跟wait差别很大,容易踩坑。处理时需要调用Thread.interrupted()检查中断标志,区分是正常唤醒还是被中断唤醒。
4.4 park不释放锁:和其他锁混用时要小心
wait在等待期间会释放monitor,方便别的线程进入临界区;park则完全不会。如果你在synchronized块里调用park,当前线程挂起的同时锁还攥在自己手里,其他线程进不来临界区,也就没法unpark,直接死锁。
所以LockSupport通常用在不需要传统锁保护的地方,比如AQS内部。AQS里用park挂起排队线程,但队列状态靠CAS和volatile维护,park/unpark本身不依赖synchronized。这点我在初学阶段一直没注意,直到自己写的代码在某次压测里卡死,才明白“某工具好用”和“某工具能无脑混用”是两回事。
5. 从零手写一个阻塞队列:把等待唤醒机制落在代码里
5.1 用synchronized + wait/notify实现阻塞队列
把前面的知识点串起来,写一个容量为N的阻塞队列:
import java.util.LinkedList; import java.util.Queue; public class MyBlockingQueue<T> { private final Object lock = new Object(); private final Queue<T> queue = new LinkedList<>(); private final int capacity; public MyBlockingQueue(int capacity) { this.capacity = capacity; } public void put(T item) throws InterruptedException { synchronized (lock) { while (queue.size() == capacity) { lock.wait(); } queue.offer(item); lock.notifyAll(); } } public T take() throws InterruptedException { synchronized (lock) { while (queue.isEmpty()) { lock.wait(); } T item = queue.poll(); lock.notifyAll(); return item; } } }这个实现有几个细节值得反复强调:
- 两个方法都用
while不用if,防虚假唤醒和条件竞态。 - 入队和出队都
notifyAll。为什么不用notify?因为这把锁上有两类等待者:满而等待的生产者、空而等待的消费者。notify随机唤醒可能唤醒错类型,造成死锁。 put和take竞争同一把锁,队列操作天然原子,不需要额外同步。
5.2 用ReentrantLock + Condition实现同样功能
只靠一把锁加notifyAll的问题是:唤醒类型不精确,永远是把所有线程叫起来再筛选。Condition就是为解决这个痛点设计的:
import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class ConditionBlockingQueue<T> { private final ReentrantLock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); private final Queue<T> queue = new LinkedList<>(); private final int capacity; public ConditionBlockingQueue(int capacity) { this.capacity = capacity; } public void put(T item) throws InterruptedException { lock.lock(); try { while (queue.size() == capacity) { notFull.await(); } queue.offer(item); notEmpty.signal(); } finally { lock.unlock(); } } public T take() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } T item = queue.poll(); notFull.signal(); return item; } finally { lock.unlock(); } } }两个Condition把“满而等待的生产者”和“空而等待的消费者”分到两个等待队列里,signal只会唤醒条件真正可能满足的一方,精准且不惊群。这也是JDK源码里ArrayBlockingQueue的实际设计思路。
5.3 几种方案的选型建议
实际开发里不需要自己写阻塞队列,直接用ArrayBlockingQueue、LinkedBlockingQueue就好,但手写一遍能帮你把等待唤醒机制彻底学透。选型时可以参考这个逻辑:
- 简单的条件等待,所有等待者都在等同一件事:
synchronized + wait/notifyAll足够,代码最直接。 - 多条件、多类型等待者:优先
ReentrantLock + Condition,避免无效唤醒和惊群。 - 需要精确唤醒某个线程,或者做异步转同步:
LockSupport更合适。 - 需要超时等待防止死等:三种方案里
wait(long)、await(long, TimeUnit)、parkNanos都支持,但具体到场景,Condition的超时接口更好用,因为它可以配合循环条件精确控时。
5.4 排查等待唤醒问题的调试手段
最后分享一个实用的定位方法。怀疑线程被永久挂起时,先用jstack看线程栈:
- 线程状态
WAITING且栈顶是Object.wait():说明在等notify,往上查谁负责唤醒它,那个线程此刻在干什么。 - 线程状态
WAITING且栈顶是LockSupport.park():说明被park挂起,找谁持有它的引用并且应该调用unpark。 - 线程状态
BLOCKED:这不是等唤醒,是等着抢锁,属于锁竞争。
如果所有线程都WAITING,而没有任何线程在执行notify或unpark,基本就是丢失唤醒或者唤醒条件未满足。对照while循环里的条件变量,看是不是有人改完状态没发通知,或者发通知前又因为其他条件把自己挂起了。还有三个细节容易忽略:确认wait和notify操作的是同一个锁对象,有人建了两个Object当锁,A在lock1上wait,B在lock2上notify,当然叫不醒;确认拿到的是同一个实例而非复制,比如用字符串或Integer当锁,装箱拆箱过程中可能换掉了对象;确认notify线程在发通知后没有因为别的条件又把自己挂起。这三个坑我都踩过,每次都靠上面的排查思路收尾。把线程dump和条件循环对照着看,等待唤醒的问题基本都能在三分钟内定位。