杰瑞科技汇

Java多线程中notify为何要配合wait使用?

生产者-消费者模型

理解 notify() 的最佳方式就是通过 生产者-消费者 模型。

Java多线程中notify为何要配合wait使用?-图1
(图片来源网络,侵删)

想象一个场景:

  • 生产者:不断生产产品,并把产品放入一个有限的仓库
  • 消费者:不断从仓库中取出产品进行消费。

这个“有限的仓库”就是线程间协作的关键,如果仓库满了,生产者就必须等待,直到消费者取走产品,仓库有空间了才能继续生产,如果仓库空了,消费者就必须等待,直到生产者放入产品,仓库有货了才能继续消费。

wait()notify() 就是用来实现这种等待和通知机制的。


wait()notify() 的基本用法

wait()notify() 都不是定义在 Thread 类中,而是定义在 Object 类里,这是因为任何对象都可以作为锁(监视器),而等待和通知操作必须与锁关联。

Java多线程中notify为何要配合wait使用?-图2
(图片来源网络,侵删)

wait() 方法

当一个线程调用某个对象的 wait() 方法时,它会做以下几件事:

  1. 释放当前持有的该对象的锁
  2. 进入该对象的等待队列,并进入阻塞状态。
  3. 等待其他线程来唤醒它

注意wait() 必须在同步代码块同步方法中调用,否则会抛出 IllegalMonitorStateException

notify() 方法

当一个线程调用某个对象的 notify() 方法时,它会做以下几件事:

  1. 随机唤醒在该对象等待队列中的一个线程。
  2. 被唤醒的线程会尝试重新获取该对象的锁。
  3. 一旦锁被成功获取,被唤醒的线程就会从 wait() 调用的地方继续执行。

注意

  • notify() 不会释放锁,它只是发出一个“唤醒”信号,锁是在同步代码块/方法执行完毕后,或者被持有锁的线程主动 wait()release 时才释放。
  • 被唤醒的线程不会立即获得锁,它需要和所有其他正在竞争该锁的线程(包括新来的线程)一起去竞争。

代码示例:一个简单的生产者-消费者

下面是一个使用 wait()notify() 实现的生产者-消费者模型,仓库最多只能存储 1 个产品。

// 仓库类,作为共享资源
class Warehouse {
    private int product = 0; // 0表示仓库为空,1表示仓库有货
    // 消费者调用
    public synchronized void consume() {
        // 如果仓库为空,消费者等待
        if (product == 0) {
            try {
                System.out.println("消费者:仓库为空,我等待...");
                wait(); // 释放锁,并进入等待队列
                System.out.println("消费者:被唤醒了,开始消费!");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 消费产品
        System.out.println("消费者:消费产品,仓库清空。");
        product = 0;
        // 唤醒可能在等待的生产者
        System.out.println("消费者:通知生产者可以生产了。");
        notify(); // 唤醒一个等待的线程
    }
    // 生产者调用
    public synchronized void produce() {
        // 如果仓库有货,生产者等待
        if (product == 1) {
            try {
                System.out.println("生产者:仓库已满,我等待...");
                wait(); // 释放锁,并进入等待队列
                System.out.println("生产者:被唤醒了,开始生产!");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 生产产品
        System.out.println("生产者:生产产品,仓库已满。");
        product = 1;
        // 唤醒可能在等待的消费者
        System.out.println("生产者:通知消费者可以消费了。");
        notify(); // 唤醒一个等待的线程
    }
}
public class ProducerConsumerDemo {
    public static void main(String[] args) {
        Warehouse warehouse = new Warehouse();
        // 创建消费者线程
        new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                warehouse.consume();
                try { Thread.sleep(500); } catch (InterruptedException e) {}
            }
        }, "消费者").start();
        // 创建生产者线程
        new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                warehouse.produce();
                try { Thread.sleep(500); } catch (InterruptedException e) {}
            }
        }, "生产者").start();
    }
}

可能的执行流程分析:

  1. 初始状态product = 0,仓库为空。
  2. 消费者先运行:进入 consume() 方法,发现 product == 0,调用 wait()消费者线程释放锁并进入等待状态
  3. 生产者运行:获取 Warehouse 对象的锁,进入 produce() 方法,发现 product == 0,开始生产,生产后 product = 1,然后调用 notify()
  4. notify() 的作用notify() 会唤醒消费者线程(因为它是唯一在等待的线程)。生产者线程此时仍然持有锁,所以消费者线程虽然被唤醒,但无法立即执行,必须等到 produce() 方法执行完毕,生产者线程释放锁。
  5. 消费者获得锁并执行:生产者线程执行完毕,释放锁,被唤醒的消费者线程重新获得锁,从 wait() 的下一句代码开始执行,打印“被唤醒了...”,消费产品,product 变为 0,然后调用 notify()(虽然此时没有线程在等待,但调用它无害)。
  6. 循环往复:这个过程不断重复,实现了生产者和消费者的交替工作。

notify() 的重要特性与风险

1 唤醒的随机性

notify()随机唤醒一个等待的线程,如果你有多个消费者线程在等待,notify() 可能会唤醒任何一个,这可能导致“虚假唤醒”或唤醒了错误的线程。

例子:假设有 2 个消费者线程 C1 和 C2 在等待,1 个生产者线程 P。

  1. P 生产了一个产品,调用 notify()
  2. 假设它随机唤醒了 C1,C1 获得锁,消费产品,然后释放锁。
  3. C2 仍然在等待,但仓库已经是空的,C2 不再检查条件,它可能会被其他 notify() 唤醒,然后直接尝试消费一个不存在的产品,导致错误。

2 虚假唤醒

即使在没有调用 notify() 的情况下,等待的线程也有极小概率会被意外唤醒,这是 JVM 的一个特性,虽然不常见,但我们必须考虑。

解决方案:永远使用 while 循环检查条件

为了解决上述两个问题,wait() 调用必须放在一个 while 循环中,而不是 if 语句中,每次被唤醒时,都必须重新检查等待条件是否满足。

修正后的 consume 方法:

public synchronized void consume() {
    // 使用 while 循环,而不是 if
    while (product == 0) { // 被唤醒后,再次检查条件
        try {
            System.out.println("消费者:仓库为空,我等待...");
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    // 只有当条件满足时,才会执行下面的代码
    System.out.println("消费者:消费产品,仓库清空。");
    product = 0;
    notify();
}

这样,即使线程被 notify() 唤醒,或者发生了虚假唤醒,它也会再次进入 while 循环,发现仓库还是空的,于是会再次调用 wait(),继续等待,只有当生产者真正放入了产品并调用了 notify(),并且该线程重新获得锁后,while 条件才会不成立,线程才会继续执行消费逻辑。


notify() vs notifyAll()

特性 notify() notifyAll()
唤醒线程数 随机唤醒一个等待线程 唤醒所有在该对象上等待的线程
效率 效率更高,因为只唤醒一个线程 效率较低,需要唤醒所有线程,它们会竞争锁
安全性 有风险,可能唤醒错误的线程,导致逻辑复杂 更安全,确保所有相关的线程都被检查条件,不会错过通知
使用场景 当你确切知道只有一个线程在等待,或者你不关心唤醒哪个线程,且唤醒的线程一定能处理当前情况时。 当你不确定有多少线程在等待,或者你需要唤醒所有相关线程来重新评估它们的状态时。在大多数情况下,notifyAll() 是更安全的选择。

何时使用 notifyAll() 一个典型的场景是:当某个共享资源的状态发生改变时,所有等待这个资源的线程(无论是生产者还是消费者)都应该被唤醒,去重新检查这个状态。

在一个线程池中,当一个任务完成时,可能需要唤醒所有等待任务的线程,让它们去重新检查是否有新任务。


wait() 的三个版本

Object 类还提供了另外两个 wait() 方法:

  1. wait(long timeout):等待指定的时间(毫秒),如果在超时时间内没有被其他线程唤醒,它会自动苏醒。
  2. wait(long timeout, int nanos):等待指定的时间(毫秒 + 纳秒),精度更高。

这两个方法可以用来避免线程永久等待(即“死等”),如果一个生产者线程因为某种原因崩溃了,那么所有等待它的消费者线程将永远等待下去,使用带超时的 wait() 可以确保线程最终会苏醒,从而可以处理一些异常情况或重新尝试。


总结与最佳实践

  1. 基本规则wait()notify() 必须在同步代码块或同步方法中调用,操作的是同一个对象锁。
  2. wait() 的作用:释放锁,让当前线程进入等待状态。
  3. notify() 的作用:随机唤醒一个等待线程,但不立即释放锁
  4. 防止死锁和错误wait() 调用必须包裹在 while 循环中,用于在被唤醒后重新检查等待条件,这是防止“虚假唤醒”和“唤醒错误线程”导致问题的关键。
  5. 选择 notify() 还是 notifyAll()
    • 优先考虑 notifyAll(),因为它更安全,能避免复杂的逻辑错误。
    • 只有在经过仔细分析,确定 notify() 不会带来问题(只有一个消费者线程)时,才使用 notify() 以提高效率。
  6. 现代替代方案:虽然 wait/notify 是 Java 的基础,但在现代 Java 开发中,更推荐使用高级并发工具,如 java.util.concurrent 包中的 BlockingQueueCountDownLatchCyclicBarrierSemaphore 等,它们已经封装好了复杂的线程同步逻辑,使用起来更简单、更安全、不易出错。

使用 BlockingQueue 实现生产者-消费者会非常简洁:

// 生产者
new Thread(() -> {
    try {
        queue.put("产品"); // 如果队列满,put() 方法会自动阻塞
    } catch (InterruptedException e) { /* ... */ }
}).start();
// 消费者
new Thread(() -> {
    try {
        String product = queue.take(); // 如果队列空,take() 方法会自动阻塞
        // 消费产品...
    } catch (InterruptedException e) { /* ... */ }
}).start();
分享:
扫描分享到社交APP
上一篇
下一篇