《Java 并发编程·线程与内存模型》5.1 wait/notify 的正确姿势

wait 会释放监视器,notify 不会——两组实测时间线把这件事讲清楚,也说明不在同步块内调用为什么会抛 IllegalMonitorStateException。再给一个能跑的生产者消费者最小实现,讲清条件判断为什么必须写成 while、notify 与 notifyAll 该怎么选、被唤醒之后还要重新抢锁。

三个方法,三条硬性前提

Object 上的 wait、notify、notifyAll 是 Java 最早的线程协作机制,比 java.util.concurrent 早了十年。它们简单,但几乎每一条都有容易踩的坑。

三条前提必须同时满足:

  1. 调用线程必须持有该对象的监视器,否则抛 IllegalMonitorStateException;
  2. wait 会释放监视器并进入等待集,被唤醒后要重新竞争监视器才能返回;
  3. 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 返回到真正执行下一行代码,中间发生了什么:

  1. 线程在等待集里,不参与调度;
  2. 被 notify/notifyAll 唤醒,或在超时/中断时自己醒来;
  3. 线程从等待集移到该监视器的竞争队列;
  4. 重新竞争监视器,抢到才返回;
  5. 返回到 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 虚假唤醒与条件谓词循环

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收