Java 多线程之三:volatile 与等待通知机制示例

在 Java 多线程编程中,保证线程间的可见性和协调性是两大核心挑战。如果处理不当,将导致数据不一致、程序死锁或资源浪费等问题。Java 内存模型(JMM)提供了一些关键技术来应对这些挑战,其中 volatile 关键字和等待/通知机制扮演了至关重要的角色。

  • volatile 主要解决了可见性问题和部分有序性问题。它能确保一个线程对变量的修改能立即被其他线程看到。
  • 等待/通知机制 则解决了线程间的协调问题。它允许一个线程在某个条件不满足时主动等待,并在条件可能满足时被其他线程唤醒,从而高效地实现线程间的协作。

本文将深入探讨这两项技术,通过详细的代码示例、原理解析和最佳实践,帮助你牢固掌握它们在并发编程中的应用。

目录#

  1. volatile 关键字
    1. 内存可见性问题
    2. volatile 的解决方案
    3. 禁止指令重排序
    4. volatile 的局限性
    5. 最佳实践与常见用法
  2. 等待/通知机制
    1. 机制概述
    2. 核心方法:wait(), notify(), notifyAll()
    3. 标准范式与关键细节
    4. 完整示例:生产者-消费者模型
  3. volatile 与等待/通知机制的对比与结合
  4. 总结
  5. 参考资料

一、volatile 关键字#

1.1 内存可见性问题#

在 Java 内存模型中,每个线程都有自己的工作内存(可以理解为 CPU 高速缓存的一个抽象)。为了提升执行效率,线程在操作普通变量时,通常会从主内存中拷贝一份副本到工作内存,操作完成后再刷新回主内存。这就可能导致一个问题:一个线程修改了变量的值,但另一个线程无法立即看到这个修改。

示例:无法终止的循环

public class VisibilityProblem {
    private static boolean flag = false; // 普通变量
 
    public static void main(String[] args) {
        Thread workerThread = new Thread(() -> {
            System.out.println("Worker thread started, waiting for flag...");
            while (!flag) {
                // 空循环,等待 flag 变为 true
            }
            System.out.println("Worker thread: Flag is now true. Exiting.");
        });
 
        Thread controllerThread = new Thread(() -> {
            try {
                // 主线程休眠 1 秒,确保 workerThread 先运行
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true; // 修改 flag 的值
            System.out.println("Controller thread: Set flag to true.");
        });
 
        workerThread.start();
        controllerThread.start();
    }
}

可能的输出:

Worker thread started, waiting for flag...
Controller thread: Set flag to true.
// 程序永远不会结束,workerThread 陷入了死循环!

问题分析: workerThread 在启动后,将 flag 的初始值 false 读入了自己的工作内存。此后,它一直在自己的工作内存中检查 flag 的值。即使 controllerThread 将主内存中的 flag 改为了 true,workerThread 也感知不到这个变化,从而导致了无限循环。

1.2 volatile 的解决方案#

volatile 关键字的主要作用就是解决这种可见性问题。当一个变量被声明为 volatile 后:

  1. 保证可见性:任何线程对该变量的写操作都会立即刷新回主内存。
  2. 保证可见性:任何线程对该变量的读操作都会从主内存中重新读取最新值。

只需将上面的 flag 声明修改为 private static volatile boolean flag = false;,程序就能正常终止。

修正后的输出:

Worker thread started, waiting for flag...
Controller thread: Set flag to true.
Worker thread: Flag is now true. Exiting.

1.3 禁止指令重排序#

除了可见性,volatile 还通过禁止指令重排序来保证有序性。为了优化性能,编译器和处理器可能会对指令进行重排序。volatile 变量通过插入内存屏障(Memory Barrier)来避免其前后的指令被重排序,从而保证了特定顺序。

一个经典的例子是单例模式的双重检查锁(DCL):

public class Singleton {
    private static volatile Singleton instance; // 必须使用 volatile
 
    private Singleton() {}
 
    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new Singleton(); // 非原子操作!
                }
            }
        }
        return instance;
    }
}

instance = new Singleton() 这一行代码在 JVM 中分为三步:

  1. 分配对象内存空间。
  2. 初始化对象(调用构造函数)。
  3. 将 instance 引用指向分配的内存地址。

如果没有 volatile,步骤 2 和 3 可能被重排序。可能导致一个线程拿到了一个尚未完全初始化的对象。使用 volatile 可以禁止这种重排序,确保对象的发布是安全的。

1.4 volatile 的局限性#

volatile 非常强大,但它不是万能的。它的一个关键局限性是不保证原子性。

示例:volatile 不保证原子性

public class AtomicityProblem {
    private static volatile int count = 0;
 
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++; // 这不是一个原子操作
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++; // 读-改-写三步操作
            }
        });
 
        t1.start();
        t2.start();
        t1.join();
        t2.join();
 
        System.out.println("Final count: " + count); // 结果几乎肯定不是 20000
    }
}

count++ 实际上是一个复合操作(读 -> 加1 -> 写),volatile 只能保证每次读到的都是最新值,但不能保证在执行 count++ 的整个过程中不被其他线程打断。要解决原子性问题,需要使用 synchronized 或 java.util.concurrent.atomic 包下的原子类(如 AtomicInteger)。

1.5 最佳实践与常见用法#

  • 状态标志:如开篇示例,用一个 volatile boolean 变量来安全地停止线程。
  • 一次性安全发布:如双重检查锁模式,确保对象被安全地初始化并发布。
  • 独立观察:定期“发布”观察结果供程序其他部分使用。例如,一个传感器程序每隔几秒采集一次数据,并将数据保存在一个 volatile 变量中,其他线程可以随时读取最新的传感器读数。
  • "读-写锁"的简易版:如果读操作远多于写操作,可以结合 synchronized 写和 volatile 读,实现一种简单的共享锁。但通常更推荐使用 ReentrantReadWriteLock。

二、等待/通知机制#

2.1 机制概述#

等待/通知机制是 Java 内置的线程间通信方式,它基于对象的监视器锁(Monitor)。核心思想是:一个线程 A 调用了对象 O 的 wait() 方法后,会释放锁并进入等待状态。另一个线程 B 获取到对象 O 的锁,在满足某个条件后,调用对象 O 的 notify() 或 notifyAll() 方法,这会唤醒正在等待的线程 A。线程 A 被唤醒后需要重新获取锁,然后继续执行。

2.2 核心方法:wait(), notify(), notifyAll()#

这些方法都是 java.lang.Object 类的 final 方法,因此所有对象都拥有这些方法。

方法作用
wait()调用该方法的线程释放锁并进入等待状态,直到被其他线程唤醒。
wait(long timeout)带超时的等待,超过指定时间后会自动唤醒。
notify()随机唤醒一个在该对象上等待的单个线程。
notifyAll()唤醒所有在该对象上等待的线程。

重要前提:这些方法的调用必须在同步代码块或同步方法中,即线程必须持有该对象的监视器锁,否则会抛出 IllegalMonitorStateException 异常。

2.3 标准范式与关键细节#

使用等待/通知机制有一个经典的标准范式,遵循它可以避免很多常见的陷阱。

等待方(消费者)的标准范式:

synchronized (lockObject) { // 1. 获取对象的锁
    while (condition is not met) { // 2. 循环检查条件
        try {
            lockObject.wait(); // 3. 条件不满足,释放锁并等待
        } catch (InterruptedException e) {
            // 处理中断异常
        }
    }
    // 4. 条件满足,执行相应的业务逻辑
    doBusiness();
}

通知方(生产者)的标准范式:

synchronized (lockObject) { // 1. 获取对象的锁
    change condition; // 2. 改变条件
    lockObject.notifyAll(); // 3. 通知所有等待在该锁上的线程
}

关键细节:

  1. 使用 while 而不是 if 来检查条件:这是为了防止虚假唤醒。在某些情况下,即使没有调用 notify,线程也可能被唤醒。使用 while 循环可以在被唤醒后再次检查条件是否真正满足,如果不满足则继续等待。
  2. 优先使用 notifyAll():notify() 只会唤醒一个线程,如果唤醒的是同一个类型的等待线程(比如另一个生产者),可能会导致程序死锁或效率低下。notifyAll() 会唤醒所有等待线程,让它们自己去竞争锁并检查条件,更加安全可靠。

2.4 完整示例:生产者-消费者模型#

下面是一个使用等待/通知机制实现的经典生产者-消费者模型,使用一个容量为 1 的队列。

public class ProducerConsumerDemo {
    // 共享资源
    private static final Queue<Integer> queue = new LinkedList<>();
    private static final int MAX_SIZE = 1; // 队列容量为1
    private static final Object lock = new Object(); // 锁对象
 
    public static void main(String[] args) {
        Producer producer = new Producer();
        Consumer consumer = new Consumer();
        producer.start();
        consumer.start();
    }
 
    // 生产者线程
    static class Producer extends Thread {
        @Override
        public void run() {
            for (int i = 0; i < 10; i++) {
                synchronized (lock) {
                    // 检查条件:如果队列已满,则等待
                    while (queue.size() == MAX_SIZE) {
                        try {
                            System.out.println("Queue is full, Producer waits.");
                            lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    // 生产数据
                    queue.offer(i);
                    System.out.println("Produced: " + i);
                    // 生产后通知消费者可以消费了
                    lock.notifyAll();
                }
                try {
                    Thread.sleep(500); // 模拟生产耗时
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
 
    // 消费者线程
    static class Consumer extends Thread {
        @Override
        public void run() {
            for (int i = 0; i < 10; i++) {
                synchronized (lock) {
                    // 检查条件:如果队列为空,则等待
                    while (queue.isEmpty()) {
                        try {
                            System.out.println("Queue is empty, Consumer waits.");
                            lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    // 消费数据
                    Integer data = queue.poll();
                    System.out.println("Consumed: " + data);
                    // 消费后通知生产者可以生产了
                    lock.notifyAll();
                }
                try {
                    Thread.sleep(1000); // 模拟消费耗时
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

输出示例:

Produced: 0
Queue is full, Producer waits.
Consumed: 0
Queue is empty, Consumer waits.
Produced: 1
Queue is full, Producer waits.
Consumed: 1
...

这个示例清晰地展示了生产者与消费者如何通过 wait() 和 notifyAll() 高效、安全地协作。


三、volatile 与等待/通知机制的对比与结合#

特性volatile等待/通知机制
核心功能保证可见性,禁止重排序实现线程间协作
原子性不保证通过与 synchronized 结合,可以保证临界区代码的原子性
线程状态不改变线程状态(Running, Runnable)会使线程在 Waiting 状态和 Runnable 状态间切换
适用场景状态标志,独立观察复杂的线程协作,如生产者-消费者
结合使用共享条件变量有时可声明为 volatile,但通常条件判断在同步块内,synchronized 已保证可见性。等待/通知机制本身依赖于 synchronized 提供的锁和内存语义。

它们通常用于解决不同维度的问题,但在一个复杂的多线程程序中,往往会结合使用。例如,在一个网络服务器中,可能用一个 volatile 变量作为全局的“关闭”标志,同时使用等待/通知机制来管理 worker 线程池中的任务分配。


四、总结#

  • volatile 是轻量级的同步机制,主要用于解决内存可见性和指令重排序问题。它简单高效,但功能有限,不保证原子性。
  • 等待/通知机制 是功能更强大的线程协作工具,它通过 wait()/notify() 和 synchronized 关键字结合,能够实现复杂的线程间通信和流程控制。使用时务必遵循标准范式,特别是使用 while 循环检查条件以防止虚假唤醒。

理解并正确运用 volatile 和等待/通知机制,是编写正确、高效、稳定的 Java 并发程序的基础。对于更复杂的并发场景,建议直接使用 java.util.concurrent 包中提供的高级工具类(如 CyclicBarrier, CountDownLatch, BlockingQueue 等),它们封装了这些底层细节,更安全也更易用。


参考资料#

  1. Oracle Java 官方文档
  2. Brian Goetz, et al. 《Java Concurrency in Practice》. Addison-Wesley Professional, 2006. (中文版:《Java 并发编程实战》)
  3. Oracle Java Tutorials
  4. 【深入理解Java虚拟机】JVM高级特性与最佳实践(第3版) - 周志明 著