《Java 并发编程·线程与内存模型》5.2 虚假唤醒与条件谓词循环

wait 可能无缘无故返回,也可能醒来发现条件已被别人抢走,还可能信号早于等待而永久丢失。用两组实测对照说明:同一个双消费者场景里 if 版 100 轮全部取到 null,while 版 0 轮出错;而信号先发时裸 wait 等了 205ms 才超时。

从 wait 返回不等于条件成立

5.1 节讲完了 wait/notify 的三条前提,这一节讲一个更隐蔽的问题:线程从 wait 返回时,条件未必成立。

Object.wait 的 javadoc 里有一句常被忽略的话:

A thread can also wake up without being notified, interrupted, or timing out, a so-called spurious wakeup. While this will rarely occur in practice, applications must guard against it by testing for the condition that should have caused the thread to be awakened, and continuing to wait if the condition is not satisfied.

这段话说了三件事:

  1. 线程可能在没有 notify、没有中断、没有超时的情况下自己醒来;
  2. 这在实践中很少发生;
  3. 但应用必须防御——被唤醒后重新测试条件,不满足就继续等。

问题是:很多讲虚假唤醒的文章只讲第 1 点,读者以为「只要不碰到 JVM 的怪行为就没事」。实际上 while 循环要挡的不止虚假唤醒,还有两个更常见的场景。

三种「醒来」和三种「条件不成立」

先把概念分清。wait 返回的原因有四种:

返回原因触发方式是否需要重新检查条件
被 notify/notifyAll 唤醒其它线程调用是
超时wait(timeout) 到期是
中断其它线程 interrupt是(抛异常)
虚假唤醒无明确原因是

而「条件不成立」的原因有三种:

情况机制能否稳定复现
虚假唤醒JVM/OS 层无因返回不能,属于规范允许但难以受控触发
条件被抢走多个等待者竞争同一个条件能,100% 复现
信号丢失notify 发生在 wait 之前能,取决于写法

第二、三种才是日常代码里真正会遇到的问题。它们都能用同一个写法解决:条件谓词 + while 循环。

虚假唤醒:规范允许,但构造不出来

需要诚实说明:我没能在本机稳定构造出虚假唤醒。

HotSpot 在 POSIX 系统上把 Object.wait 映射到底层的条件变量,理论上信号(signal)可以打断 pthread_cond_wait 并让它返回。但要在 Java 层受控触发它,没有公开手段。

所以本文不会给「虚假唤醒的复现代码」——那样的代码只能是假的。规范的立场是「可能发生,必须防御」,这就够了:防御的成本是一个 while,代价可以忽略。

下面两个实验展示的是可以稳定复现的部分。

实验一:条件被抢走

场景:一个队列,两个消费者,生产者只放入一个元素后 notifyAll。

if 版本的消费者:

synchronized (LOCK) {
    if (Q.isEmpty()) {
        LOCK.wait(50);          // 只判断一次
    }
    results[id] = Q.poll();     // 醒来后直接取
}

完整实验用两个线程各跑一次,notifyAll 后两个消费者都被唤醒:

static final Object LOCK = new Object();
static final Deque<String> Q = new ArrayDeque<>();
static final AtomicInteger READY = new AtomicInteger();

static int runRound(boolean useWhile) throws Exception {
    Q.clear();
    READY.set(0);
    Object[] results = new Object[2];
    // 启动两个消费者,READY 计数保证它们都进入同步块
    // ...
    while (READY.get() < 2) Thread.onSpinWait();
    synchronized (LOCK) {
        Q.add("book-1");
        LOCK.notifyAll();       // 唤醒两个,但只有一个元素
    }
    // 统计有多少个消费者取到 null
}

本机 JDK 25 上各跑 100 轮:

if   版本:100/100 轮有消费者取到 null
while版本:0/100 轮有消费者取到 null

if 版本 100% 出错,原因很清楚:

  1. 两个消费者都在 wait;
  2. 生产者放入一个元素,notifyAll 唤醒两个;
  3. 消费者 A 先抢到锁,poll() 取走唯一元素;
  4. 消费者 B 抢到锁,它不会重新检查队列是否为空,直接 poll()——拿到 null。

while 版本的行为:

while (Q.isEmpty()) {
    long rem = deadline - System.currentTimeMillis();
    if (rem <= 0) { results[id] = "TIMEOUT"; return; }
    LOCK.wait(rem);
}

消费者 B 醒来、拿到锁、检查发现队列仍为空、继续等,直到超时退出。它没有取到 null,也没有破坏数据。

这个实验说明:while 的首要作用不是防虚假唤醒,而是防「醒来时条件已被别人消费掉」。这个场景在有多于一个等待者时是必然发生的,不是偶发。

实验二:信号先于等待

第二个场景更隐蔽。生产者先发出信号,消费者后进入等待:

// 生产者:先设置条件并通知,此时没有任何线程在等
synchronized (LOCK) {
    ready = true;
    LOCK.notifyAll();        // 没有等待者,信号被丢弃
}

// 消费者 A:裸 wait,不检查条件
synchronized (LOCK) {
    LOCK.wait(200);          // 等待一个已经发生过的信号
}

// 消费者 B:条件谓词循环
synchronized (LOCK) {
    while (!ready) {
        LOCK.wait(200);
    }
}

本机输出:

生产者:ready=true, notifyAll()
B 条件谓词 : 等待 0ms 后返回(无需等待)
A 裸 wait   : 等待 205ms 后超时返回(信号丢失)

A 等了 205ms 才因为超时返回——它等的是一个永远不会再来的信号。B 立即返回,因为它先检查了条件,发现 ready 已经是 true,根本不需要 wait。

这就是条件谓词的第二个作用:把「已经发生过的信号」从共享状态里补回来。信号本身是不可存储的,notify 只影响当时正在等待的线程;但条件是存在共享变量里的,后到的线程可以通过读变量知道「事情已经发生了」。

A 和 B 的差别不在 wait 的用法,而在是否先检查条件。检查条件的写法天然免疫信号丢失。

条件谓词,不是布尔标志

「条件谓词」(condition predicate)和「布尔标志」的区别值得单独说清。

布尔标志是一个变量,比如 boolean ready。它容易被误用成「等这个变量变成 true」:

private boolean ready;

public void awaitReady() throws InterruptedException {
    synchronized (this) {
        if (!ready) {
            wait();       // 等 ready 变 true
        }
    }
}

问题在于 ready 只是一个变量,它描述不了「队列非空」「库存大于零」「任务全部完成」这类由多个字段共同决定的状态。

条件谓词是一个表达式,由共享状态算出来:

// 队列非空
!queue.isEmpty()

// 缓冲区未满
count < capacity

// 库存充足
book.stock() > 0

// 任务全部完成
completed.get() == total.get()

写法上,条件谓词必须满足两条:

  • 只由受同一把锁保护的共享状态计算,否则检查和等待之间状态可能变化;
  • 每次被唤醒后重新求值,因为中间可能有别的线程修改了状态。

用条件谓词写 wait 循环的标准形态:

synchronized (lock) {
    while (!predicate()) {
        lock.wait();
    }
    // 到这里 predicate() 为 true,且持有锁
}

三个位置都不能省:

位置作用
synchronizedwait 的前提;也保证条件检查和状态修改互斥
while醒来后重新求值;防虚假唤醒、防条件被抢走
predicate()用共享状态表达「可以继续了」

超时与中断的处理

wait(timeout) 返回后,同样必须重新检查条件——超时只是「一段时间过去了」,不代表条件成立。

public Reservation reserve(String isbn) throws InterruptedException {
    long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
    synchronized (stockLock) {
        while (stockOf(isbn) == 0) {
            long remain = deadline - System.nanoTime();
            if (remain <= 0) {
                throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
            }
            TimeUnit.NANOSECONDS.timedWait(stockLock, remain);
        }
        return doReserve(isbn);
    }
}

这段代码里有三个容易写错的地方:

  • 用 System.nanoTime() 而不是 currentTimeMillis():nanoTime 单调,不受系统时钟调整影响;
  • 每次循环重新计算剩余时间:不能把 wait(5000) 放在循环里,否则每被虚假唤醒一次就重置超时;
  • 超时后抛业务异常,而不是返回 null:调用方需要区分「没库存」和「等待超时」。

中断的处理要注意:wait 抛出 InterruptedException 时,中断标志已经被清除。如果需要向上传播,直接 throws 即可;如果捕获了要自己处理,别忘了恢复标志:

try {
    lock.wait();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();   // 恢复中断状态
    throw new CancellationException();
}

TimeUnit.NANOSECONDS.timedWait 是 Object.wait(long, int) 的封装,好处是单位明确,不用自己算毫秒和纳秒。

常见错误写法

写法问题后果
if (!cond) wait();只检查一次醒来后条件可能已不成立,取到 null 或越界
wait(); 不做任何检查信号先发时永久丢失无超时时死等
while (!cond) wait(5000);每次唤醒重置超时总等待时间无上界
用 currentTimeMillis 算超时受系统时钟回拨影响超时可能提前或推迟
条件里读不受锁保护的字段检查和等待不一致丢失更新或忙等
notify 唤醒后不重新检查同 if同 if
捕获 InterruptedException 后不恢复标志上层无法感知中断取消失效

最后一条在 5.1 的例子 BoundedBuffer 里是直接 throws 的,这是最简单的正确做法。捕获后不恢复标志又不上抛,等于吞掉了取消请求——第 6 章讲中断时会展开。

在 libraryd-conc 里落地

5.1 节的 WaitingLoanService 用了裸 wait(),没有超时也没有条件谓词。按这一节的结论改写:

public final class WaitingLoanService implements LoanService {
    private final BookRepository books;
    private final LoanRepository loans;
    private final Object lock = new Object();

    @Override
    public Reservation reserve(String isbn, String memberId, Duration maxWait)
            throws InterruptedException {
        long deadline = System.nanoTime() + maxWait.toNanos();
        synchronized (lock) {
            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;
                }
                long remain = deadline - System.nanoTime();
                if (remain <= 0) {
                    throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn + " 等待 " + maxWait + " 后仍无库存");
                }
                TimeUnit.NANOSECONDS.timedWait(lock, remain);
            }
        }
    }

    @Override
    public boolean returnBook(String loanId) {
        boolean ok;
        synchronized (lock) {
            ok = loans.findById(loanId).map(loan -> {
                loans.save(loan);
                return true;
            }).orElse(false);
            if (ok) {
                lock.notifyAll();
            }
        }
        return ok;
    }
}

改动有四处:

  • 私有 lock 对象替代 this,锁不外泄;
  • while (true) + 显式超时判断,不用 while (!condition),因为条件里要抛「书不存在」的异常,用 if 分支更清楚;
  • timedWait 用 nanoTime 计算剩余时间;
  • notifyAll 放在锁内、returnBook 的状态修改之后。

这版仍不适合高并发生产环境——所有 ISBN 共用一把锁。真正的做法是每本书一个条件队列(用 ReentrantLock + Condition),或者用异步回调。这里保留是为了让 wait 的几个要点集中出现。

小结

  • 从 wait 返回不代表条件成立。虚假唤醒、条件被抢走、超时、中断都会让线程在没有满足条件时醒来。
  • 虚假唤醒在规范里被明确允许,但无法受控复现;while 循环要防的主要是「条件被其它等待者抢走」,这个场景在多等待者时 100% 发生(实测 if 版 100/100 失败)。
  • 信号不可存储,条件可以。用条件谓词而不是裸 wait,后到的线程能通过共享状态知道「事情已经发生过」(实测裸 wait 等了 205ms,条件谓词 0ms 返回)。
  • 条件谓词必须由受同一把锁保护的共享状态计算,并且每次醒来都重新求值。
  • 超时用 nanoTime 算剩余时间,timedWait 放循环里;捕获 InterruptedException 后要恢复标志或上抛。

下一节用这些规则手写一个完整的 BoundedBuffer,再和 ArrayBlockingQueue 做一次正面对比。

阅读导航:上一节 5.1 wait/notify 的正确姿势 | 下一节 5.3 手写有界缓冲区

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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