当前位置:  首页>> 技术小册>> Java并发编程实战

06 | 用“等待-通知”机制优化循环等待

在Java并发编程中,处理线程间的同步与协作是至关重要的一环。传统的循环等待(也称为忙等待或自旋等待)方式,虽然简单直观,但在多线程环境中却极易导致CPU资源的浪费和效率低下。特别是当线程需要等待某个条件成立时,如果采用循环检查该条件的方式,不仅会无谓地消耗CPU资源,还可能因为过高的CPU占用率而影响到系统的整体性能。为了优化这种情况,Java并发包(java.util.concurrent)提供了丰富的同步机制,其中“等待-通知”模式(Wait-Notify Mechanism)是一种非常有效且广泛使用的解决方案。

一、理解“等待-通知”机制

“等待-通知”机制是Java中用于线程间通信的一种模式,它依赖于Object类中的wait()notify()notifyAll()三个方法来实现。这三个方法都是java.lang.Object类的成员,因此Java中的任何对象都可以作为同步锁和通信媒介。

  • wait():让当前线程进入等待状态(阻塞状态),直到其他线程调用此对象的notify()方法或notifyAll()方法。调用wait()方法之前,线程必须获得该对象的锁。调用wait()方法后,线程会释放这个锁,并进入等待队列中等待。
  • notify():唤醒在此对象监视器上等待的单个线程。如果有多个线程在等待,选择哪个线程唤醒是未定义的。调用notify()方法之前,线程也必须获得该对象的锁。
  • notifyAll():唤醒在此对象监视器上等待的所有线程。

二、循环等待的问题

在不使用“等待-通知”机制的情况下,当线程需要等待某个条件满足时,可能会采用循环检查的方式来实现。这种方式虽然简单,但存在明显的问题:

  1. CPU资源浪费:线程会不断地检查条件是否满足,即使条件不满足也会持续占用CPU资源。
  2. 响应性差:如果条件长时间不满足,线程将一直占用CPU,无法及时响应其他任务。
  3. 效率低下:在多个线程同时等待同一条件时,每个线程都在无意义地消耗资源,整体效率极低。

三、使用“等待-通知”机制优化

通过“等待-通知”机制,我们可以有效地避免循环等待带来的问题。其基本思想是:当线程需要等待某个条件时,不是通过循环检查,而是调用wait()方法进入等待状态,并释放锁;当条件满足时,另一个线程会调用notify()notifyAll()方法唤醒等待的线程,并重新竞争锁以继续执行。

示例场景:生产者-消费者问题

为了更直观地说明“等待-通知”机制的应用,我们以经典的生产者-消费者问题为例进行说明。

场景描述:有一个共享的资源(如缓冲区),生产者线程负责向其中生产数据,消费者线程负责从中消费数据。生产者必须在缓冲区未满时才能生产,消费者必须在缓冲区非空时才能消费。

不使用“等待-通知”机制的实现(简化版,仅作对比):

  1. public class Buffer {
  2. private final int capacity;
  3. private int size = 0;
  4. private final List<Integer> items = new ArrayList<>();
  5. public Buffer(int capacity) {
  6. this.capacity = capacity;
  7. }
  8. public synchronized void produce(int item) throws InterruptedException {
  9. while (size == capacity) {
  10. Thread.sleep(100); // 简单的等待方式,实际应用中应避免
  11. }
  12. items.add(item);
  13. size++;
  14. }
  15. public synchronized int consume() throws InterruptedException {
  16. while (size == 0) {
  17. Thread.sleep(100); // 同样是简单的等待方式
  18. }
  19. int item = items.remove(0);
  20. size--;
  21. return item;
  22. }
  23. }

使用“等待-通知”机制的实现

  1. public class Buffer {
  2. private final int capacity;
  3. private int size = 0;
  4. private final List<Integer> items = new ArrayList<>();
  5. private final Object lock = new Object(); // 显式锁对象
  6. public Buffer(int capacity) {
  7. this.capacity = capacity;
  8. }
  9. public void produce(int item) throws InterruptedException {
  10. synchronized (lock) {
  11. while (size == capacity) {
  12. lock.wait(); // 等待空间
  13. }
  14. items.add(item);
  15. size++;
  16. lock.notifyAll(); // 通知可能在等待的消费者
  17. }
  18. }
  19. public int consume() throws InterruptedException {
  20. synchronized (lock) {
  21. while (size == 0) {
  22. lock.wait(); // 等待数据
  23. }
  24. int item = items.remove(0);
  25. size--;
  26. lock.notifyAll(); // 通知可能在等待的生产者
  27. return item;
  28. }
  29. }
  30. }

在这个优化后的实现中,我们使用了显式的锁对象lock来同步访问缓冲区和进行线程间的通信。当缓冲区满时,生产者线程会调用lock.wait()进入等待状态,并释放锁;当消费者消费了数据后,通过调用lock.notifyAll()唤醒所有等待的线程(包括生产者和消费者),但通常情况下,由于条件限制,只有生产者或消费者中的一个会被唤醒并继续执行。

四、注意事项

  1. 避免虚假唤醒wait()方法可能在没有被notify()notifyAll()显式唤醒的情况下就返回了,这被称为虚假唤醒(Spurious Wakeup)。因此,在wait()的循环中应该总是重新检查条件是否满足。
  2. 确保在同步块中调用wait()notify()notifyAll()方法必须在同步块或同步方法中调用,且这些方法的调用对象必须是同一个对象,即同步锁。
  3. 使用notifyAll()而非notify():除非你有明确的理由知道只有一个线程会等待某个条件,否则通常应该使用notifyAll()来唤醒所有等待的线程,以避免潜在的死锁或活锁问题。
  4. 确保线程安全:在使用“等待-通知”机制时,必须确保对共享资源的访问是线程安全的,即所有对共享资源的修改都应该在同步块中进行。

五、总结

通过“等待-通知”机制,我们可以有效地优化Java并发编程中的循环等待问题,提高系统的响应性和效率。在生产者-消费者等典型场景中,这一机制显得尤为重要。理解和掌握“等待-通知”机制是Java并发编程的基础,也是提升程序性能和稳定性的关键。