Java 多线程之三:volatile 与等待通知机制示例
在 Java 多线程编程中,保证线程间的可见性和协调性是两大核心挑战。如果处理不当,将导致数据不一致、程序死锁或资源浪费等问题。Java 内存模型(JMM)提供了一些关键技术来应对这些挑战,其中 volatile 关键字和等待/通知机制扮演了至关重要的角色。
volatile主要解决了可见性问题和部分有序性问题。它能确保一个线程对变量的修改能立即被其他线程看到。- 等待/通知机制 则解决了线程间的协调问题。它允许一个线程在某个条件不满足时主动等待,并在条件可能满足时被其他线程唤醒,从而高效地实现线程间的协作。
本文将深入探讨这两项技术,通过详细的代码示例、原理解析和最佳实践,帮助你牢固掌握它们在并发编程中的应用。
目录#
- volatile 关键字
- 等待/通知机制
- volatile 与等待/通知机制的对比与结合
- 总结
- 参考资料
一、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 后:
- 保证可见性:任何线程对该变量的写操作都会立即刷新回主内存。
- 保证可见性:任何线程对该变量的读操作都会从主内存中重新读取最新值。
只需将上面的 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 中分为三步:
- 分配对象内存空间。
- 初始化对象(调用构造函数)。
- 将
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. 通知所有等待在该锁上的线程
}关键细节:
- 使用
while而不是if来检查条件:这是为了防止虚假唤醒。在某些情况下,即使没有调用notify,线程也可能被唤醒。使用while循环可以在被唤醒后再次检查条件是否真正满足,如果不满足则继续等待。 - 优先使用
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 等),它们封装了这些底层细节,更安全也更易用。
参考资料#
- Oracle Java 官方文档
- Brian Goetz, et al. 《Java Concurrency in Practice》. Addison-Wesley Professional, 2006. (中文版:《Java 并发编程实战》)
- Oracle Java Tutorials
- 【深入理解Java虚拟机】JVM高级特性与最佳实践(第3版) - 周志明 著