java并发编程系列——waitnotify的正确使用姿势
核心概念:为什么需要 wait 和 notify?
在多线程环境中,线程之间有时需要协作,而不是完全独立地运行。一个典型的场景就是生产者-消费者模型:
- 生产者线程:负责生产数据(比如往一个列表里添加元素)。
- 消费者线程:负责消费数据(比如从同一个列表里取出元素)。
问题来了:
- 如果生产者速度太快,列表满了,生产者就应该停下来等待,直到消费者消费了数据,列表有了空位。
- 如果消费者速度太快,列表空了,消费者就应该停下来等待,直到生产者生产了新数据。
wait()和notify()/notifyAll()就是Java提供的用于实现这种线程间协作(或称条件等待)的底层机制。它们必须与synchronized关键字配合使用,因为它们操作的是对象的监视器锁。
一、使用方法详解
wait(), notify(), notifyAll() 都是 java.lang.Object 类中的 final 方法,这意味着任何Java对象都可以作为锁,并拥有这些方法。
1. wait()
当一个线程调用某个对象的 wait() 方法时,会发生以下几件事:
- 释放锁:当前线程会立即释放它持有的该对象的监视器锁。这一点至关重要,否则其他线程就无法进入同步块来唤醒它。
- 进入等待状态:当前线程会进入该对象的等待集中,处于阻塞状态,不会消耗CPU资源。
- 等待被唤醒:线程会一直等待,直到以下四种情况之一发生:
- 其他线程调用了同一个对象的
notify()或notifyAll()方法。 - 其他线程调用了该线程的
interrupt()方法,导致线程中断并抛出InterruptedException。 - 如果调用的是带超时参数的
wait(long timeout),超过了指定的毫秒数。 - 发生了所谓的“虚假唤醒”,即线程在没有被任何线程显式通知的情况下自己醒来了。
重要:线程被唤醒后,它不会立即执行。它需要重新尝试获取之前释放的监视器锁。成功获取锁后,它会从wait()方法调用的地方继续向下执行。
- 其他线程调用了同一个对象的
2. notify()
当一个线程调用某个对象的 notify() 方法时:
- 它会从该对象的等待集中随机唤醒一个正在等待的线程。
- 被唤醒的线程进入就绪状态,开始与其他线程竞争该对象的监视器锁。
- 注意:调用
notify()的线程不会立即释放锁。它会在当前synchronized块执行完毕后,才会释放锁。因此,被唤醒的线程需要等待锁被释放后才能继续执行。
3. notifyAll()
当一个线程调用某个对象的 notifyAll() 方法时:
- 它会唤醒该对象等待集中所有正在等待的线程。
- 所有被唤醒的线程都进入就绪状态,共同竞争这个对象的监视器锁。
- 最终只有一个线程能成功获取锁并继续执行,其他线程则重新进入等待状态。
二、经典示例:生产者-消费者模型
下面是一个使用 wait/notifyAll 实现的简单生产者-消费者模型。
import java.util.LinkedList;
import java.util.Queue;
public class ProducerConsumerExample {// 共享资源,作为锁对象private static final Queue<String> buffer = new LinkedList<>();private static final int CAPACITY = 5;public static void main(String[] args) {// 创建生产者和消费者线程Thread producerThread = new Thread(new Producer(), "Producer-Thread");Thread consumerThread = new Thread(new Consumer(), "Consumer-Thread");producerThread.start();consumerThread.start();}// 生产者任务static class Producer implements Runnable {@Overridepublic void run() {int item = 0;while (true) {try {produce(item++);Thread.sleep(1000); // 模拟生产耗时} catch (InterruptedException e) {Thread.currentThread().interrupt();}}}private void produce(int item) throws InterruptedException {// 必须在 synchronized 块内synchronized (buffer) {// 【最佳实践1】使用 while 循环检查条件while (buffer.size() == CAPACITY) {System.out.println("缓冲区已满,生产者等待...");buffer.wait(); // 缓冲区满,等待}// 生产数据buffer.add("Item-" + item);System.out.println(Thread.currentThread().getName() + " 生产了: Item-" + item + ", 缓冲区大小: " + buffer.size());// 【最佳实践2】状态改变后,通知所有等待的线程buffer.notifyAll(); }}}// 消费者任务static class Consumer implements Runnable {@Overridepublic void run() {while (true) {try {consume();Thread.sleep(1500); // 模拟消费耗时} catch (InterruptedException e) {Thread.currentThread().interrupt();}}}private void consume() throws InterruptedException {// 必须在 synchronized 块内synchronized (buffer) {// 【最佳实践1】使用 while 循环检查条件while (buffer.isEmpty()) {System.out.println("缓冲区为空,消费者等待...");buffer.wait(); // 缓冲区空,等待}// 消费数据String item = buffer.poll();System.out.println(Thread.currentThread().getName() + " 消费了: " + item + ", 缓冲区大小: " + buffer.size());// 【最佳实践2】状态改变后,通知所有等待的线程buffer.notifyAll();}}}
}
三、最佳实践与常见陷阱
这是 wait/notify 的精髓所在,遵循这些规则可以避免绝大多数并发问题。
1. 【黄金法则】永远在 while 循环里调用 wait()
错误做法:
synchronized (lock) {if (condition_is_not_met) { // 错误!应该用 whilelock.wait();}// do something
}
正确做法:
synchronized (lock) {while (condition_is_not_met) { // 正确!lock.wait();}// do something
}
为什么必须用 while?
- 防止虚假唤醒:JVM允许线程在没有收到
notify的情况下从wait()中醒来。如果是if判断,线程会错误地继续执行,而此时条件可能并不满足。while循环会让线程被唤醒后重新检查条件。 - 处理多个等待线程:假设一个生产者
notifyAll(),唤醒了另一个生产者和一个消费者。如果被唤醒的是生产者,它发现缓冲区仍然是满的(因为消费者还没来得及消费),while循环会让它再次进入wait()状态。如果是if,它就会错误地尝试向满的缓冲区添加数据。
2. 必须在 synchronized 块或方法中调用
调用 wait(), notify(), notifyAll() 的线程必须持有该对象的监视器锁。否则,会抛出 IllegalMonitorStateException 运行时异常。
// 错误示例
public void badMethod() {lock.notify(); // 抛出 IllegalMonitorStateException
}
// 正确示例
public void goodMethod() {synchronized (lock) {lock.notify(); // 正确}
}
3. 优先使用 notifyAll(),而非 notify()
notify() 只随机唤醒一个线程,这很危险。继续上面的生产者-消费者例子:
- 缓冲区满了,一个生产者在等待。
- 消费者消费了一个数据后,调用
notify()。 - 不幸的是,
notify()可能会唤醒另一个正在等待的生产者,而不是那个消费者。被唤醒的生产者发现缓冲区还是满的,于是再次wait()。而真正的消费者却因为没有被通知而继续等待,可能导致死锁。
notifyAll()唤醒所有线程,虽然看起来开销大一点,但它更安全。所有被唤醒的线程都会通过while循环重新检查自己的运行条件,确保只有真正符合条件的线程才能继续执行。在99%的场景下,notifyAll()都是比notify()更好的选择。
4. 在状态改变之后才调用 notify()
通知的目的是告诉其他线程“状态变了,快来看看你是否可以继续了”。所以,必须先完成状态的修改(比如往 buffer 里添加或删除元素),然后再调用 notifyAll()。
synchronized (buffer) {// 1. 先修改状态buffer.add(item);// 2. 再通知其他线程buffer.notifyAll();
}
四、现代Java的替代方案
wait()/notify() 是Java提供的底层同步机制,虽然强大,但使用起来非常容易出错。从Java 5开始,java.util.concurrent 包提供了更高级、更安全、更易于使用的并发工具,强烈推荐在新代码中使用。在后续系列中我会做出讲解。
| 场景 | wait/notify 实现 | 现代替代方案 | 优势 |
|---|---|---|---|
| 生产者-消费者 | synchronized + wait/notifyAll | BlockingQueue (如 ArrayBlockingQueue, LinkedBlockingQueue) | 代码极简,无需手动处理锁和等待,内置线程安全。 |
| 多条件等待 | 多个 synchronized 块 + notifyAll (逻辑复杂) | Lock + Condition | 一个锁可以关联多个Condition,实现精确唤醒特定类型的线程,避免了notifyAll带来的无效竞争。 |
| 资源计数 | synchronized + 计数器 + wait/notifyAll | Semaphore | 语义清晰,API简单,用于控制同时访问特定资源的线程数量。 |
| 线程等待汇合 | join() 或 wait/notifyAll | CountDownLatch | 一次性同步工具,一个或多个线程等待其他一组线程完成操作后再执行。 |
| 循环栅栏 | 复杂的 wait/notifyAll 逻辑 | CyclicBarrier | 让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,所有被阻塞的线程才会继续执行。可重用。 |
Lock 和 Condition 示例: |
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
// 使用 Lock 和 Condition 的生产者消费者
public class ProducerConsumerWithLock {private final Queue<String> buffer = new LinkedList<>();private final int CAPACITY = 5;private final Lock lock = new ReentrantLock();// 为生产者和消费者分别创建条件private final Condition notFull = lock.newCondition();private final Condition notEmpty = lock.newCondition();// ... 生产者和消费者的逻辑 ...public void produce(String item) throws InterruptedException {lock.lock();try {while (buffer.size() == CAPACITY) {notFull.await(); // 在 notFull 条件上等待}buffer.add(item);System.out.println("Produced: " + item);notEmpty.signal(); // 唤醒一个在 notEmpty 条件上等待的消费者} finally {lock.unlock();}}public String consume() throws InterruptedException {lock.lock();try {while (buffer.isEmpty()) {notEmpty.await(); // 在 notEmpty 条件上等待}String item = buffer.poll();System.out.println("Consumed: " + item);notFull.signal(); // 唤醒一个在 notFull 条件上等待的生产者return item;} finally {lock.unlock();}}
}
在这个例子中,signal() 精确地唤醒了对方类型的线程,效率比 notifyAll() 更高。
总结
| 特性 | wait() / notify() | java.util.concurrent 工具 |
|---|---|---|
| 易用性 | 低,规则复杂,极易出错 | 高,API设计符合直觉,封装了底层细节 |
| 安全性 | 低,需开发者严格遵守最佳实践 | 高,由并发大师设计,经过充分测试 |
| 灵活性 | 低,一个锁只有一个等待集 | 高,如Lock可绑定多个Condition |
| 性能 | 在简单场景下开销小 | 在复杂场景下性能更好,减少了不必要的线程唤醒和竞争 |
| 最终建议: |
- 学习
wait/notify:理解它对于掌握Java并发底层原理至关重要,尤其是在阅读和维护一些老旧的库或框架代码时。 - 使用
java.util.concurrent:在编写新的并发代码时,优先选择java.util.concurrent包下的高级工具。它们能让你的代码更健壮、更简洁、更易于维护。
