三个方法,三条硬性前提
Object 上的 wait、notify、notifyAll 是 Java 最早的线程协作机制,比 java.util.concurrent 早了十年。它们简单,但几乎每一条都有容易踩的坑。
三条前提必须同时满足:
- 调用线程必须持有该对象的监视器,否则抛
IllegalMonitorStateException; wait会释放监视器并进入等待集,被唤醒后要重新竞争监视器才能返回;notify不释放监视器,它只是把等待集里的一个线程移动到锁的竞争队列,被唤醒的线程仍然要等调用方退出同步块。
第 1 条是语法约束,第 2、3 条是并发语义。三者合起来才能解释 wait 循环为什么必须写成 while 而不是 if。
不在监视器里调用会发生什么
wait、notify、notifyAll 都声明了 throws IllegalMonitorStateException,但它是运行时异常,编译器不强制检查。忘了加 synchronized 的代码能编译通过,运行时才炸:
Object lock = new Object();
try {
lock.wait();
} catch (IllegalMonitorStateException e) {
System.out.println("wait -> " + e);
}
try {
lock.notify();
} catch (IllegalMonitorStateException e) {
System.out.println("notify -> " + e);
}
本机 JDK 25 的输出:
wait -> java.lang.IllegalMonitorStateException: current thread is not owner
notify -> java.lang.IllegalMonitorStateException: current thread is not owner
异常消息是 current thread is not owner——「当前线程不是所有者」。这句话点明了本质:监视器有归属,wait/notify 是所有者才能做的操作。
一个容易忽略的细节:锁的对象必须是同一个。下面这段代码编译通过、运行时也不报错,但永远不会生效:
private final Object lock = new Object();
public void wrong() {
synchronized (new Object()) { // 每次都是新对象
notify(); // 这里 notify 的是新对象,不是 lock
}
}
这段代码不会抛异常,因为 notify() 是在「新对象」的监视器里调的,确实持有它的锁。问题在于等待的线程等的是 lock,不是这个新对象,所以永远不会被唤醒。这类 bug 不会报错,只会表现为「程序卡住」。
wait 释放锁:一组时间线
光说「wait 释放锁」不够直观,用时间戳测量:
static final Object LOCK = new Object();
static final long T0 = System.currentTimeMillis();
static long at() { return System.currentTimeMillis() - T0; }
// 线程 A:持有锁后 wait 200ms
synchronized (LOCK) {
System.out.println("A 进入同步块 t=" + at() + "ms");
LOCK.wait(200);
System.out.println("A 从 wait 返回 t=" + at() + "ms(已重新持有锁)");
}
// 主线程等待 50ms 后,线程 B 尝试进入同一个同步块
本机输出:
A 进入同步块 t=4ms
B 进入同步块 t=60ms
A 从 wait 返回 t=212ms(已重新持有锁)
三条信息:
- A 在 4ms 拿到锁;
- B 在 60ms 进入同一个同步块——如果
wait不释放锁,B 必须等到 A 的wait(200)超时后才能进入,实际 B 只等了 50 多毫秒,说明锁确实被释放了; - A 在 212ms 返回,即 200ms 超时后重新拿到了锁。
第 3 条也是关键:wait 返回时线程重新持有监视器。这一点在 5.2 节讲 while 循环时会用到——被唤醒的线程不是从 wait 那一行继续执行,而是先从等待集移到锁竞争队列,抢到锁后才返回。
notify 不释放锁:另一组时间线
notify 的语义常被误解为「叫醒对方,对方立刻执行」。实际是「叫醒对方,对方排队等锁」。
// B:先进入等待
synchronized (LOCK) {
System.out.println("B 开始等待 t=" + at() + "ms");
LOCK.wait();
System.out.println("B 从 wait 返回 t=" + at() + "ms");
}
// A:notify 之后再持锁 100ms
synchronized (LOCK) {
System.out.println("A 调用 notify t=" + at() + "ms");
LOCK.notify();
Thread.sleep(100); // 临界区还没结束
System.out.println("A 即将退出同步块 t=" + at() + "ms");
}
本机输出:
B 开始等待 t=5ms
A 调用 notify t=42ms
A 即将退出同步块 t=142ms
B 从 wait 返回 t=143ms
notify 发生在 42ms,B 却在 143ms 才返回——比 notify 晚了 101ms,正好是 A 在临界区里 sleep 的时间。B 返回的时刻紧跟在 A 退出同步块(142ms)之后,两者只差 1ms。
结论:notify 只是把目标线程从等待集移到锁的竞争队列,真正的返回要等锁可用。
这个语义有一个直接后果:在 synchronized 块里调用 notify 之后继续做耗时操作,会把所有被唤醒的线程一起拖住。如果那段时间里还改动了共享状态,被唤醒的线程看到的是「已经改完」的版本——这可能正是你要的,也可能不是。
一个能跑的生产者消费者
把三条前提拼起来,就是最小可用的生产者消费者。用一个定长数组做环形缓冲,两个指针加一个计数:
public final class BoundedBuffer<T> {
private final Object[] items;
private int head;
private int tail;
private int count;
public BoundedBuffer(int capacity) {
this.items = new Object[capacity];
}
public synchronized void put(T item) throws InterruptedException {
while (count == items.length) {
wait(); // 满了,等消费者取走
}
items[tail] = item;
tail = (tail + 1) % items.length;
count++;
notifyAll(); // 有新元素,唤醒所有等待者
}
@SuppressWarnings("unchecked")
public synchronized T take() throws InterruptedException {
while (count == 0) {
wait(); // 空了,等生产者放入
}
T item = (T) items[head];
items[head] = null; // 帮助 GC
head = (head + 1) % items.length;
count--;
notifyAll(); // 有空位,唤醒所有等待者
return item;
}
}
四个设计点:
- 所有方法都是
synchronized,wait/notifyAll都在监视器内调用,满足前提 1。 while而不是if,原因见下一节。notifyAll而不是notify,原因见下下节。items[head] = null,环形缓冲取走后要清引用,否则数组会一直持有已消费对象。
这个类和 java.util.concurrent.ArrayBlockingQueue 的语义接近,但少了中断处理、超时、公平性。5.3 节会做完整对比。
为什么必须是 while
假设把 while 写成 if:
public synchronized T take() throws InterruptedException {
if (count == 0) {
wait(); // 只判断一次
}
// 醒来后直接取
}
两种情况下会坏:
情况一:虚假唤醒(spurious wakeup)。 JVM 规范明确允许 wait 在没有 notify 的情况下返回。这不是 bug,是为了让 JVM 在底层实现上有自由度(比如某些平台的中断处理)。所以「从 wait 返回」只意味着「有可能条件成立了」,不意味着「条件一定成立」。
情况二:条件被别的线程抢走了。 两个消费者都在等,生产者放入一个元素后 notifyAll。两个消费者都被唤醒,都要抢锁;第一个抢到、取走元素、退出;第二个抢到锁时 count 已经是 0,如果它用 if,就会对一个空缓冲取元素——items[head] 是 null,或者指针错位。
while 的作用是醒来后重新检查条件,不成立就继续等:
while (count == 0) {
wait();
}
这也是「用条件谓词而不是布尔标志」的写法。条件谓词是「缓冲区非空」,它由 count 这个共享状态决定;状态是共享的,所以任何线程被唤醒后都必须重新求值。
5.2 节会用一段能稳定复现的代码把这两种情况都演示一遍。
notify 还是 notifyAll
notify 只唤醒等待集里的一个线程,选哪个由 JVM 决定,你无法控制。
在 BoundedBuffer 这种「所有等待者等的是同一个条件」的场景里,notify 看起来够用:放入一个元素唤醒一个消费者,取走一个元素唤醒一个生产者。但有两个问题:
- 条件分组时一定错。 如果等待者等的是不同条件(比如消费者 0 只要偶数、消费者 1 只要奇数),
notify可能唤醒一个条件不满足的线程,它检查后继续等,而这次唤醒的机会就被浪费了。其它条件已满足的线程还在睡。 - 实现不保证公平。 同一个线程可能被连续唤醒多次,其它线程饿死。
notifyAll 唤醒全部等待者,被唤醒的线程各自重新检查条件,不满足的继续等。代价是「惊群」:一次唤醒 N 个线程,N-1 个白跑一趟。在现代 JVM 上这个代价通常可以接受,因为线程被唤醒后只是抢一次锁、检查一次条件、再睡回去。
选择规则很简单:
| 场景 | 选择 |
|---|---|
| 所有等待者等同一个条件 | notifyAll(安全默认) |
| 等待者条件不同 | 必须 notifyAll |
| 性能敏感且确认只有一个等待者 | 可以考虑 notify,但要写清理由 |
用 Condition 替代 | 每个条件一个 Condition,各自 signal |
最后一行是 java.util.concurrent 的解法:ReentrantLock.newCondition() 可以创建多个条件队列,生产者只 signal 消费者、消费者只 signal 生产者,不需要 notifyAll 的惊群。卷二第 2 章会讲 ConditionObject 的实现。
关于 notify 是否真的会「唤醒错误的线程」,我尝试构造了一个两个消费者、条件不同的场景(一个只要偶数、一个只要奇数,两次 notify 之间留 20ms 让被唤醒的线程重新评估),在本机 JDK 25 上跑了 100 轮,没有复现出超时。这说明在当前的 HotSpot 实现里,唤醒顺序恰好让简单场景不失败。但这不能作为「notify 安全」的依据——唤醒顺序是实现细节,换个 JVM、换个 JDK 版本、换个等待集大小都可能不同。
被唤醒之后
从 wait 返回到真正执行下一行代码,中间发生了什么:
- 线程在等待集里,不参与调度;
- 被
notify/notifyAll唤醒,或在超时/中断时自己醒来; - 线程从等待集移到该监视器的竞争队列;
- 重新竞争监视器,抢到才返回;
- 返回到
wait调用的下一行,此时synchronized块内的状态可能已经被别人改过。
第 3–4 步是 wait 与 sleep 最大的区别。sleep 不释放锁、也不需要重新抢锁;wait 释放锁、返回时要重新抢。所以 wait 的返回延迟不仅取决于谁调用了 notify,还取决于当前有多少线程在抢这把锁。
这也解释了一个常见现象:notifyAll 之后,被唤醒的线程不是同时开始执行的,而是排队抢锁,一个一个来。
在 libraryd-conc 里落地
借阅服务里有一个场景适合用 wait/notify:等待库存补充。当某本书库存为 0 时,借阅请求可以等待而不是立刻失败——只要有人归还,就可以继续。
public final class WaitingLoanService implements LoanService {
private final BookRepository books;
private final LoanRepository loans;
@Override
public Reservation reserve(String isbn, String memberId) throws InterruptedException {
synchronized (this) {
while (true) {
Book book = books.findByIsbn(isbn)
.orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, isbn));
if (book.stock() > 0) {
books.save(new Book(book.isbn(), book.title(), book.stock() - 1));
Reservation r = new Reservation(
UUID.randomUUID().toString(), isbn, memberId,
Instant.now().plusSeconds(86400));
loans.save(new Loan(r.loanId(), isbn, memberId, r.dueAt()));
return r;
}
wait(); // 库存为 0,等归还
}
}
}
@Override
public synchronized boolean returnBook(String loanId) {
boolean ok = loans.findById(loanId).map(loan -> {
loans.save(loan);
return true;
}).orElse(false);
if (ok) {
notifyAll(); // 可能有库存在等
}
return ok;
}
}
这段代码能工作,但我不推荐在真实服务里这么写,理由有三条:
- 锁的粒度是整个服务实例,所有 ISBN 的借阅都串行;
- 等待没有超时,库存长期为 0 时请求会无限挂起,占着连接;
wait要求调用线程持有监视器,而这里的监视器是this,意味着服务对象被外部持有引用时可能被误锁。
更好的做法是用 ReentrantLock + 多个 Condition(每个 ISBN 一个条件队列),或者干脆用异步通知 + 超时。这里保留这个例子,是因为它把 wait/notifyAll 的四个要点(持锁、循环、重新检查、唤醒后重新抢锁)都摆在了明面上。
小结
wait/notify/notifyAll必须在持有该对象监视器时调用,否则抛IllegalMonitorStateException: current thread is not owner。wait释放监视器(实测 B 线程在 A 等待期间进入了同一同步块),返回时重新持有;notify不释放(实测 B 的返回时刻等于 A 退出同步块的时刻)。- 条件判断必须写成
while,因为存在虚假唤醒,也因为条件可能被其它线程抢走。 - 默认用
notifyAll;notify的唤醒对象不可控,在条件分组的场景里会丢信号。 - 需要多条件精确唤醒时,用
ReentrantLock的多个Condition。
下一节专门讲虚假唤醒:为什么 wait 会无缘无故返回,while 循环怎么把它挡住,以及丢失唤醒和虚假唤醒是两件不同的事。
阅读导航:上一节 4.3 锁消除、锁粗化与偏向锁的移除 | 下一节 5.2 虚假唤醒与条件谓词循环
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。