在讲 volatile 之前,我们先看一个案例:
import java.util.concurrent.*;
class State {
int a = 0, b = 0, c = 0;
// volatile int a = 0, b = 0, c = 0; // 用于测试 volatile 的效果
}
public class Test {
public static void main(String[] args) throws InterruptedException {
int iterations = 1000_0000; // 测试迭代次数
ExecutorService executor = Executors.newFixedThreadPool(2); // 线程池,复用两个线程
CountDownLatch latch = new CountDownLatch(iterations); // 用于等待所有任务完成
for (int i = 1; i <= iterations; ++i) {
State state = new State();
// 赋值任务
Runnable writerTask = () -> {
state.a = 1;
state.b = 1;
state.c = state.a + 1;
};
// 读取任务
Runnable readerTask = () -> {
int c = state.c, b = state.b, a = state.a;
// 检查是否存在不符合预期的情况
if (b == 1 && a == 0) {
System.out.println("Unexpected error: b == 1 && a == 0");
} else if (c == 2 && a == 0) {
System.out.println("Unexpected error: c == 2 && a == 0");
} else if (c == 2 && b == 0) {
System.out.println("Unexpected error: c == 2 && b == 0");
}
};
// 提交任务到线程池
executor.submit(writerTask);
executor.submit(readerTask);
// 每个循环结束后,减少一次计数
latch.countDown();
}
// 等待所有任务完成
latch.await();
executor.shutdown(); // 关闭线程池
System.out.println("Done, Test completed.");
}
}
输出:
Unexpected error: c == 2 && b == 0
Unexpected error: c == 2 && b == 0
Unexpected error: c == 2 && b == 0
Unexpected error: b == 1 && a == 0
Unexpected error: c == 2 && b == 0
Unexpected error: c == 2 && b == 0
Unexpected error: c == 2 && a == 0
Unexpected error: b == 1 && a == 0
Unexpected error: b == 1 && a == 0
Unexpected error: b == 1 && a == 0
Unexpected error: b == 1 && a == 0
从输出的结果看,显然读取线程中读到的值与预期不符。
为什么读线程读到的值与预期不符?
事实上,如果你将 State 中的 a b c 三个变量用 volatile 修饰,就不会出现这样的问题。
static class State {
// int a = 0, b = 0, c = 0;
volatile int a = 0, b = 0, c = 0;
}
在上述案例中,读线程读到的值与我们的预期不符,主要的原因是存在内存可见性问题。
指令重排序也可能导致这样的问题
什么是内存可见性问题?
为了平衡内存 I/O 短板,现代计算机会在 CPU 上增加缓存,每个 CPU 物理核心都有独立的 L1 Cache(一级缓存)、L2 Cache(二级缓存),每个 CPU 上的所有物理核心共享一个 L3 Cache(三级缓存)。
每个 CPU 的处理过程为:先将计算需要用到的数据缓存在 CPU 高速缓存中,在 CPU 进行计算时,直接从高速缓存中读取数据并且在计算完成之后写入到缓存中。在整个运算过程完成后,再把缓存中的数据同步到主内存。
由于每个线程可能会运行在不同的 CPU 物理核心内, 所以每个线程拥有自己的高速 Cache(高速缓存)。同一份数据可能会被缓存到多个 CPU 物理核心中,在不同 CPU 物理核心中运行的线程看到同一个变量的的缓存值就会不一样,这就是内存可见性问题。
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有 MSI、MESI、MOSI 等。最常见的就是 MESI 协议。
Java 的 volatile 关键字通过上述硬件级别的缓存一致性协议来保证共享变量的内存可见性,即将共享变量的改动值立即刷新回主存。
什么是 volatile?
在 Java 中,volatile 是一种轻量级的同步机制,主要有以下两个作用:
保证可见性:对于一个被 volatile 修饰的变量,在一个线程中的修改,其他线程能够立即看到。而普通的变量则不能保证可见性,修改后的值可能会被缓存到 CPU 缓存或线程工作内存中,导致其他线程无法立即看到最新值,从而引发数据不一致的问题。
禁止指令重排序:为了提高程序的执行效率,JVM 可能会对指令进行优化和重排序。但有些指令重排序可能会导致程序的错误行为,例如单例模式中的 DCL(Double Check Lock)模式。使用 volatile 可以禁止指令重排序,从而保证程序的正确性。
工程应用场景
以 Spring Boot / Spring Cloud 框架为例,下面是一些工程上可能遇到的应用场景。
状态标记:我们可能需要使用某些状态标记来控制业务流程。在多线程情况下,如果这些状态标记的值需要在不同线程之间共享,那么就需要使用 volatile 修饰这些变量,以确保在一个线程中的修改,其他线程能够立即看到。
双重检查锁定模式(Double Checked Locking Pattern):我们可能需要使用一些单例模式来保证系统中某些对象的唯一性。如果要使用双重检查锁定模式来实现单例,就需要将单例对象的引用声明为 volatile 类型,以确保它在多线程环境下的可见性和正确性。
静态变量:我们通常会使用静态变量来存储一些共享的数据。如果这些静态变量需要在不同线程之间共享,那么就需要使用 volatile 修饰这些变量,以确保在一个线程中的修改,其他线程能够立即看到。
原子性操作:我们可能需要对一些共享变量进行原子性操作,例如增加或减少计数器的值。如果这些操作需要在不同线程之间共享,那么就需要使用 volatile 修饰这些变量,并使用 Java 提供的原子类(AtomicInteger、AtomicLong 等)来保证线程安全。
评论