一、为什么不能直接"傻等"
假设一个线程要等某个条件满足才能继续干活——比如等一个缓冲区里有数据了才能取。最朴素的写法是写一个循环,不停地检查这个条件:
while (!condition) {
// 什么都不做,继续检查
}
这种写法叫忙等(busy-wait),问题很明显:这个线程会占着一个 CPU 核心不停空转、反复检查,哪怕条件在很长一段时间内根本不会发生变化,也照样把 CPU 资源实打实地烧掉,其他真正需要计算资源的线程反而被挤占。
我们真正想要的是:让这个线程在条件不满足时彻底让出 CPU、进入休眠,等到条件真的可能发生变化的那一刻,被明确地"叫醒",而不是自己去反复猜测。这就是等待通知机制要解决的问题——wait/notify 是 Java 里最早、最基础的一套实现。

二、wait/notify 的基本运作方式
wait()、notify()、notifyAll() 是定义在 Object 上的方法(不是 Thread 上),这意味着任何对象都可以充当一把"等待通知"的枢纽。它们的运作规则是:
wait():让当前线程释放它持有的这个对象的锁,并进入这个对象的"等待集合",线程状态变为等待,直到被唤醒。notify():从这个对象的等待集合中唤醒一个线程(具体唤醒哪个不保证,由 JVM 实现决定)。notifyAll():唤醒等待集合中的所有线程。
被唤醒的线程并不会立刻继续往下执行——它只是从"等待状态"变回"可运行状态",重新去竞争这个对象的锁,只有真正抢到锁之后,才会从 wait() 调用处继续往下走。

三、为什么 wait/notify 必须写在 synchronized 里面
wait()、notify()、notifyAll() 有一条硬性规则:调用它们的线程必须已经持有这个对象的锁(也就是必须在 synchronized(obj) 块内、且锁的对象正是 obj),否则会直接抛出 IllegalMonitorStateException。这条规则不是随意设置的,而是为了堵住一个非常经典的竞态漏洞:
假设"检查条件"和"进入等待"这两步之间没有锁保护:线程A检查条件为 false,正准备调用 wait() 进入等待集合的那一瞬间,线程B刚好把条件改成了 true 并调用了 notify()——但因为线程A此时还没真正进入等待集合,这次 notify() 就相当于对着一个空的等待集合喊了一声,白白浪费,信号直接丢失。等线程A终于执行完 wait() 真正进入等待集合后,已经没有人会再通知它了,这个线程就永远地卡住了。
把"检查条件"和"进入等待"这两步都放进同一把锁保护的临界区里,就能保证:只要线程A还没有调用 wait() 释放锁,线程B就没办法进入同一把锁保护的临界区去修改条件和发送通知,两者之间不会出现"信号提前发出、等待者还没就位"的时间差。

四、虚假唤醒:被叫醒了,但条件其实没满足
即使严格遵守了"锁保护"这条规则,还有另一个陷阱:一个线程被唤醒,不代表它等待的条件真的已经满足了。这种现象叫虚假唤醒(spurious wakeup),常见的诱因包括:
notifyAll()唤醒了等待集合里所有的线程,但可能只有一个线程能真正让条件保持满足(比如"缓冲区里有一条数据"这种条件,被唤醒的多个线程会重新竞争,谁先抢到锁谁就把这条数据取走,其他线程再检查时条件已经又变回不满足了)。- 某些操作系统和 JVM 实现允许在没有任何
notify调用的情况下,线程也可能从wait()中自发返回(这是 Java 语言规范明确允许的行为,不算 bug)。
正因为虚假唤醒是被规范允许、且实际会发生的现象,wait()绝不能写在 if 判断里,必须写在 while 循环里,被唤醒后重新回到循环开头再检查一次条件,条件不满足就继续 wait():
synchronized (lock) {
while (!condition) {
lock.wait();
}
// 条件确认满足,才能继续往下执行
}
这就是使用 wait/notify 的标准范式——用 while 而不是 if,是所有正确使用 wait/notify 的代码里唯一不能省略的细节。

五、Condition:更精细的等待通知
wait/notify 有一个明显的局限:一个对象只有一个等待集合,不管有多少种不同的等待原因,所有等待的线程都混在同一个集合里。如果用 notify() 只想叫醒"等着数据变空"的那批线程,却可能把"等着数据变满"的线程误唤醒——所以很多场景下不得不用 notifyAll() 把所有线程都叫起来,让它们自己重新竞争、自己判断该不该继续等,造成大量无意义的唤醒和锁竞争。
Condition 就是为了解决这个问题而设计的,它必须配合 Lock(通常是 ReentrantLock)使用,通过 lock.newCondition() 创建。它的方法和 wait/notify 一一对应:await() 对应 wait(),signal() 对应 notify(),signalAll() 对应 notifyAll()。关键区别在于:一把锁可以对应多个 Condition,把不同的等待原因彻底分开——比如一个阻塞队列可以有 notEmpty 和 notFull 两个 Condition,生产者往队列里放数据后只需要 notEmpty.signal(),精确唤醒"因为队列空而等待"的消费者,完全不会误唤醒那些因为"队列满"而等待的生产者。这正是上一篇提到的 ReentrantLock 配合多 Condition 的核心价值。
Condition 同样存在虚假唤醒问题,await() 也必须写在 while 循环里,标准范式和 wait/notify 完全一致:
lock.lock();
try {
while (!condition) {
conditionObj.await();
}
// 条件确认满足
} finally {
lock.unlock();
}

六、面试追问链
从"为什么不能直接忙等检查条件"出发,逐层追问:wait/notify 为什么必须在 synchronized 块内调用、不加锁会出现什么具体的竞态漏洞、什么是虚假唤醒、为什么必须用 while 而不是 if 重新检查条件、notify 和 notifyAll 该怎么选择、以及 Condition 相比 wait/notify 在多等待队列场景下具体解决了什么问题,这条链基本能覆盖面试官对等待通知模型的核心考察点。

延伸阅读
java.lang.Object中wait()/notify()/notifyAll()的 JavaDoc,尤其是关于虚假唤醒的官方说明java.util.concurrent.locks.AbstractQueuedSynchronizer.ConditionObject源码,理解 Condition 是如何维护独立等待队列的- 下一篇预告:ThreadLocal 原理——ThreadLocalMap、弱引用、内存泄漏与上下文传递
问答(不计入阅读时长)
Q1:为什么 wait/notify 定义在 Object 上而不是 Thread 上?
A:因为等待通知机制的核心是"围绕一份共享数据的状态变化来协调线程",而不是"线程本身"。任何一个对象都可能被用作多个线程之间协调的媒介(比如一个队列对象、一个缓冲区对象),把 wait/notify 挂在 Object 上,意味着任意对象都能天然拥有自己的一个等待集合,配合 synchronized(obj) 就能围绕这个对象组织等待通知逻辑。如果挂在 Thread 上,语义会变成"唤醒某个具体线程",但实际业务场景里我们几乎从不关心"是哪个具体线程被唤醒",只关心"条件满足了,该唤醒等这个条件的线程",Object 提供的抽象正好匹配这种需求。
Q2:一个线程调用 wait() 之后,除了被 notify 唤醒,还有其他方式能让它从 wait() 返回吗?
A:有两种:一是被中断(另一个线程调用了这个等待线程的 interrupt()),这时 wait() 会抛出 InterruptedException 而返回,需要在代码里妥善处理这个异常;二是使用带超时参数的 wait(long timeout),超时时间到了即使没有被 notify 也会自动返回。这两种情况都要求代码里的条件检查逻辑同样要用 while 循环处理——超时返回或中断返回时,条件依然可能没有满足,不能想当然地认为"wait 返回了就说明条件一定满足了"。
Q3:既然 notifyAll 会造成"无效唤醒和重新竞争锁"的开销,是不是应该尽量都用 notify 代替 notifyAll?
A:不是绝对的,要看等待集合里的线程是不是"同质的"。如果等待集合里所有线程等的都是同一种条件(比如都在等"队列里有数据"),用 notify 只唤醒一个是安全的,被唤醒的线程消费掉数据后,其余线程继续等待即可。但如果等待集合里混杂着等不同条件的线程(比如既有等"队列非空"的,也有等"队列非满"的),用 notify 唤醒的那"一个"完全可能是等错条件的线程,它醒来发现自己等的条件根本没变化,只能重新 wait,而真正该被唤醒的线程却还在傻等——这种情况下必须用 notifyAll,或者更好的做法是像上一篇提到的那样,直接换成 Condition,用不同的 Condition 实例分开管理不同的等待原因,从根源上避免这种混杂。
Q4:wait() 释放的是"当前线程持有的所有锁"还是"只释放 wait() 所在这把锁"?
A:只释放调用 wait() 时所在的这一把锁(也就是 synchronized(obj) 里 obj 对应的那把锁)。如果当前线程在调用 wait() 之前,还嵌套持有其他锁(比如在外层还有一个 synchronized(otherLock)),那些其他锁不会被释放,线程进入等待状态之后依然占着这些锁不放。这是一个容易被忽视的细节:如果在多层嵌套锁的情况下调用 wait(),需要仔细评估这种"只释放一把锁、其余锁继续持有"的行为会不会引发死锁风险。
Q5:Condition 的 await() 和 wait() 一样会被中断打断吗?
A:默认情况下 await() 和 wait() 一样会响应中断,抛出 InterruptedException。不过 Condition 还额外提供了一个 awaitUninterruptibly() 方法,调用它会让线程进入等待状态但完全忽略中断信号,不会因为被中断而提前返回,这是 wait/notify 体系里没有对应能力的地方——如果业务场景要求"这段等待逻辑无论如何都不应该被中断打断",Condition 能直接满足,而用 wait/notify 只能自己在 catch 块里手动处理中断状态然后决定要不要继续等待,写起来更繁琐。
Q6:如果在调用 wait() 之前没有获取任何锁,直接调用会发生什么?
A:会抛出 IllegalMonitorStateException,这是 JVM 在运行时强制检查的——wait()(以及 notify()、notifyAll())要求当前线程必须是这个对象锁的持有者,这条检查是在方法内部做的,不满足条件直接抛异常终止,不会有任何"部分执行"的中间状态。这也是为什么前面反复强调的"必须在 synchronized 块内调用"不是一条建议,而是一条会被运行时强制校验的硬性规则。
Q7:为什么虚假唤醒在 Java 语言规范里是被允许的,JVM 不能直接把它实现成"绝对不会虚假唤醒"吗?
A:理论上可以做到更严格的保证,但代价是牺牲性能和实现的可移植性。Java 的 wait/notify 底层往往依赖操作系统提供的线程同步原语(比如 POSIX 的条件变量),而很多操作系统的条件变量实现本身就允许虚假唤醒(这是为了在某些底层实现细节上留出优化空间,比如合并唤醒操作、减少系统调用次数)。Java 语言规范选择"允许虚假唤醒但要求调用方自己用循环检查条件",本质上是把这部分复杂度交给了应用层,换来的是 JVM 在不同平台上都能高效地实现这套机制,而不必为了消除虚假唤醒去引入额外的、可能拖慢性能的保证机制。
Q8:Condition 的 signal() 只唤醒一个线程,那这个"一个"是不是也和 notify() 一样不保证唤醒顺序?
A:在标准的 ReentrantLock.newCondition() 实现(基于 AQS 的 ConditionObject)里,signal() 唤醒的是等待队列中排在最前面的那个节点对应的线程,这个等待队列本身是按照调用 await() 的先后顺序排列的(类似先进先出),所以 signalAll/signal 的唤醒顺序其实是相对确定和公平的,这一点跟 wait/notify 明确"不保证唤醒哪一个"是不同的。但要注意,"唤醒顺序确定"不代表"这个线程一定会先拿到锁并先执行"——唤醒之后仍然需要重新竞争锁,如果锁本身是非公平的,具体执行顺序依然可能被打乱。