暮色四合,晚风轻拂,湖面上泛起点点波光,宛如撒下了一片星河。
在多线程编程中,确保共享变量的可见性是非常重要的。volatile 关键字就是为了解决这个问题而设计的。本文将深入介绍 volatile 关键字的作用、原理以及在实际开发中的应用场景。
计算机中的三级缓存通常是指处理器芯片(CPU)
上的 L1、L2 和 L3 缓存层次结构。这三级缓存按照其靠近处理器核心和主存的距离分布,具有不同的特点和作用:
这三级缓存层次结构设计的目的是在处理器核心和主存之间提供多层次的快速访问存储,以提高数据访问速度和整体系统性能。 L1 缓存作为最快速但容量最小的缓存,L2 缓存作为 L1 缓存未命中时的备用存储,而 L3 缓存则更大、更慢但能提供更高的整体性能,因为在一个计算机系统中的多个核心之间共享数据。
缓存虽然可以提升系统性能,却也带来了两个非常严重的问题:
想要 CPU 缓存与主内存保证一致性,这想想就很复杂,尤其是在多线程环境下。为了简化 JAVA 开发人员的工作,JAVA 定义了一个概念 —— JMM。
JMM(Java Memory Model,Java 内存模型)是 Java 平台定义的一种规范,用于规定 Java 程序中多线程之间的内存访问和操作行为。它定义了多线程程序中的共享内存模型,以及在共享内存模型下,对变量读写、锁的获取和释放等操作的具体规则。
JMM 主要解决了以下几个问题:
JMM 通过对线程之间的内存交互行为进行规范,使得程序员能够编写出正确的多线程程序。
JMM 定义了 8 种原子性
操作,以确保在多线程环境中对共享内存的访问和操作保持正确性和一致性。以下是这 8 种操作的具体用途:
这些操作都是原子的,不能被中断。它们共同支持了线程间的同步和并发控制,使得 Java 程序在各种平台下都能达到一致的并发效果。
在并发编程中,有几个关键的概念是确保多线程程序正确性的基础:可见性、有序性和一致性。
可见性(Visibility):可见性指的是一个线程对共享变量的修改,是否能及时被其他线程看到。在多线程环境中,每个线程都有自己的工作内存(缓存),一个线程对变量的修改可能不会立即被写回主内存,其他线程也可能从自己的工作内存中读取变量的旧值,从而导致数据不一致。例如:
public class VisibilityExample {
// 一个共享变量,控制线程是否停止
private static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
// 启动一个新线程,运行一个无限循环
Thread thread = new Thread(() -> {
while (!stop) { // 循环检查 stop 变量
// busy-wait 忙等待
}
});
thread.start();
Thread.sleep(1000); // 确保新线程启动并运行一段时间
stop = true; // 更新 stop 变量,尝试让线程停止
}
}
在上述代码中,主线程更新 stop 变量,但如果没有适当的同步机制,工作线程可能永远看不到这个更新。
有序性(Ordering):有序性指的是程序执行过程中指令的顺序。在单线程环境中,程序的执行顺序通常按照代码的编写顺序进行。然而,在多线程环境中,由于编译器优化、处理器重排序等原因,指令的实际执行顺序可能与代码的编写顺序不同,这可能导致线程间不一致的行为。例如:
public class OrderingExample {
private int a = 0;
private boolean flag = false;
public void writer() {
a = 1; // 1. 赋值操作1
flag = true; // 2. 赋值操作2
}
public void reader() {
if (flag) { // 3. 检查 flag
int i = a; // 4. 使用变量 a
}
}
}
在这个示例中,编译器和处理器可能会将指令重排序,使得 a = 1
和 flag = true
的执行顺序不同于代码书写顺序,这会影响多线程环境下的正确性。
原子性(Atomicity):原子性指的是一个操作是不可分割的,即使在多线程环境下也是如此。原子操作执行时,其他线程不能中断或观察到它的部分完成状态。例如:
public class AtomicityExample {
private int count = 0;
public void increment() {
count++; // 递增操作(非原子性)
}
}
在上述代码中,count++
不是原子操作,它实际上由三个步骤组成:读取 count 的值
、增加值
、写回 count
。在多线程环境中,可能会出现竞态条件,导致最终结果不正确。
在 Java 中,volatile
是一个关键字,用于声明变量。当一个变量被声明为 volatile 时,它告诉编译器和运行时系统,这个变量是可见的(即对其他线程可见)并且不会被缓存。换句话说,使用 volatile 修饰的变量能够确保对它的读取
和写入
操作都是原子的,并且能够立即反映在其他线程中。
例如:当线程 1 执行写入操作之后,会立即执行写回主内存的操作,并通知其他线程缓存失效。当线程 2 执行读取操作时,会从主内存读取最新值到工作内存。
情景分析:假设有一个 volatile
变量 counter
,初始值为 0
。现在有两个线程 T1
和 T2
同时读取这个变量,然后各自对其进行递增操作(即 counter++
),最后将结果写回共享内存。
public class Counter {
// 计数器变量声明为 volatile,以确保在多线程环境中的可见性
private static volatile int counter;
public static void main(String[] args) throws InterruptedException {
// 循环运行测试方法 100 次
for (int i = 0; i < 100; i++) {
test(); // 调用测试方法
counter = 0; // 重置计数器为0,以便下一次测试
}
}
// 测试方法,用于演示多线程环境下的计数器操作
static void test() throws InterruptedException {
// 创建一个新线程 t1,该线程会在延迟后对计数器进行递增操作
Thread t1 = new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(500); // 线程休眠 500 毫秒
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
counter++; // 对计数器进行递增操作
});
// 创建另一个新线程 t2,该线程也会在延迟后对计数器进行递增操作
Thread t2 = new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(500); // 线程休眠 500 毫秒
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
counter++; // 对计数器进行递增操作
});
// 启动两个线程
t1.start();
t2.start();
// 等待两个线程执行完成
t1.join();
t2.join();
// 打印计数器的值
System.out.println(counter);
}
}
最终,测试结果如下图所示:
经多次测试,我们发现出现了并发问题。
为什么会出现问题?
这个问题的答案,常规回答是:
c o u n t e r + + counter++ counter++ 操作实际上分解为以下三个步骤:
在多线程环境下,这些步骤不是原子的,多个线程可能会交替执行这些步骤,导致竞态条件。例如上面的例子中,两个线程都同时读取了 c o u n t e r counter counter 为 0 0 0,然后分别加 1 1 1 并写回,导致最终值错误。
这个回答没什么不对。但仔细思考一下,我们提出几个问题:
volatile
关键字修饰吗?volatile
关键字不能解决上面的问题?volatile
关键字又有什么用?回答问题之前,我们先回顾一下可见性的定义:
可见性指的是一个线程对共享变量的修改,是否能及时
被其他线程看到。
而 volatile
关键字修饰的变量是满足可见性的,即一个线程对变量进行了修改,其他线程会及时
看到。
即:无论是
T
1
T1
T1 线程还是
T
2
T2
T2 线程谁先修改了变量,相互之间应该及时
收到对方修改之后变量的值。
例如:线程
T
1
T1
T1 将
c
o
u
n
t
e
r
counter
counter 的值先加
1
1
1,得到
1
1
1 时,线程
T
2
T2
T2 应该及时
获取到最新值
1
1
1,然后在新值上执行
+
+
++
++ 操作。反之,亦成立。
可是,事实并非如此。
我们将这段逻辑的处理流程放大,看看究竟发生了什么?
很明显,问题出现在递增阶段。递增阶段,当 T1 线程写回前,T2 线程已经读取了变量。这不是和可见性相违背了吗?
该如何理解可见性?
可见性指的是一个线程对共享变量的修改,是否能及时
被其他线程看到。
我们注意到,可见性是在修改变量之后立刻写回主存,并及时
让其他线程看到,并非立刻
让其他线程看到。
volatile
拥有可见性,但是不能保证原子性。所以,出现了上述的并发问题。
那么,想要解决这一问题,就需要使用同步机制保证原子性。
public class Counter {
// 将计数器变量声明为 volatile,以确保在线程间的可见性
private static volatile int counter;
public static void main(String[] args) throws InterruptedException {
// 运行测试方法 100 次
for (int i = 0; i < 100; i++) {
test(); // 调用测试方法
counter = 0; // 为下一次测试重置计数器
}
}
// 用于测试线程同步的方法
static void test() throws InterruptedException {
// 创建一个新线程 t1,在延迟后增加计数器值
Thread t1 = new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(500); // 等待 500 毫秒
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (Counter.class) { // 在 Counter 类对象上同步
counter++; // 在同步块中增加计数器值
}
});
// 创建另一个线程 t2,也在延迟后增加计数器值
Thread t2 = new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(500); // 等待 500 毫秒
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (Counter.class) { // 在 Counter 类对象上同步
counter++; // 在同步块中增加计数器值
}
});
// 启动两个线程
t1.start();
t2.start();
// 等待两个线程执行完成
t1.join();
t2.join();
// 在两个线程都完成后打印计数器的值
System.out.println(counter);
}
}
新的问题诞生了,可见性似乎很鸡肋。因为似乎可以不添加 volatile
关键字修饰,直接使用 synchronized
加锁同步。
public class Counter {
// 普通变量,未使用 volatile 修饰
private static int counter;
public static void main(String[] args) throws InterruptedException {
// 运行测试方法 100 次
for (int i = 0; i < 100; i++) {
test(); // 调用测试方法
counter = 0; // 为下一次测试重置计数器
}
}
// 用于测试线程同步的方法
static void test() throws InterruptedException {
// 创建一个新线程 t1,在延迟后增加计数器值
Thread t1 = new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(500); // 等待 500 毫秒
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (Counter.class) { // 在 Counter 类对象上同步
counter++; // 在同步块中增加计数器值
}
});
// 创建另一个线程 t2,也在延迟后增加计数器值
Thread t2 = new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(500); // 等待 500 毫秒
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (Counter.class) { // 在 Counter 类对象上同步
counter++; // 在同步块中增加计数器值
}
});
// 启动两个线程
t1.start();
t2.start();
// 等待两个线程执行完成
t1.join();
t2.join();
// 在两个线程都完成后打印计数器的值
System.out.println(counter);
}
}
上面的代码测试运行,我们会发现是正确的。
这是因为 sychronized
也是可以保证可见性的。这进一步证明了 volatile
似乎没有用。
然而,事实并非如此。我们需要有一个基本认知是:在并发编程中(即:多线程环境),有一些场景只需要保证可见性,而不需要保证原子性或有序性。
例如,以下场景只需保证可见性:
标志位:使用 volatile
变量作为标志位来控制线程的行为。
public class FlagExample {
private volatile boolean stop = false;
public void runExample() {
Thread task = new Thread(() -> {
while (!stop) {
// do work
}
});
task.start();
// 在其他线程中停止任务
stop = true;
}
}
单次赋值的变量:一个变量只被赋值一次,然后被多个线程读取,但不会被其他线程修改。
public class Configuration {
// 使用 volatile 关键字修饰的变量,保证了其在多线程环境下的可见性
private volatile Map<String, String> configMap;
public Configuration() {
// 在构造函数中,我们只对 configMap 变量赋值一次
// 假设 loadConfig() 方法从某个配置文件中读取配置,并返回一个 Map
this.configMap = loadConfig();
}
// 这个方法用于获取配置信息
// 由于 configMap 是 volatile 的,所以每个线程都能看到它的最新值
public String getConfig(String key) {
return configMap.get(key);
}
// 这个方法用于加载配置信息
// 在这个示例中,我们假设它返回一个空的 HashMap
// 在实际应用中,你可能需要从文件、数据库或其他地方加载配置
private Map<String, String> loadConfig() {
// 加载配置的具体实现
return new HashMap<>();
}
}
指令重排序是编译器和处理器为了优化程序性能而进行的一种优化技术,它可能会改变指令的执行顺序,但并不影响程序最终的执行结果。然而,在多线程环境下,指令重排序可能会导致线程间的竞态条件和不确定的结果。
public class ReorderExample {
private static int x = 0, y = 0; // 共享变量x和y
private static int a = 0, b = 0; // 共享变量a和b
public static void main(String[] args) throws InterruptedException {
// 线程one执行a=1,然后x=b
Thread one = new Thread(() -> {
try {
Thread.sleep(100); // 为了增加指令重排序的可能性,让线程睡眠100毫秒
} catch (InterruptedException e) {
e.printStackTrace();
}
a = 1;
x = b;
});
// 线程other执行b=1,然后y=a
Thread other = new Thread(() -> {
try {
Thread.sleep(100); // 为了增加指令重排序的可能性,让线程睡眠100毫秒
} catch (InterruptedException e) {
e.printStackTrace();
}
b = 1;
y = a;
});
one.start(); // 启动线程one
other.start(); // 启动线程other
one.join(); // 等待线程one执行完成
other.join(); // 等待线程other执行完成
// 输出(x, y)的值
System.out.println("(x, y) = (" + x + ", " + y + ")");
}
}
在上面的代码中,多执行几次可能会出现多种不同的结果,例如: ( x , y ) = ( 1 , 1 ) (x, y) = (1, 1) (x,y)=(1,1) 或 ( x , y ) = ( 1 , 0 ) (x, y) = (1, 0) (x,y)=(1,0)。
指令重排序是一种优化技术,但是在多线程环境中是会有问题的。所以,我们需要禁止指令重排。想要禁止指令重排,我们可以通过使用 volatile
关键字达到目的。
当一个变量被声明为 volatile
后,对这个变量的写操作就会有一个内存屏障(这是一种特殊的指令),这个屏障可以防止指令重排。具体来说,编译器和处理器在执行程序时,必须在读取 volatile
变量之前的操作都执行完毕,且在读取操作后,所有写入 volatile
变量的操作都未执行。
以下面代码为例:
volatile boolean ready = false;
int data = 0;
void write() {
data = 1; // 操作1
ready = true; // 操作2
}
void read() {
if (ready) { // 操作3
int result = data; // 操作4
}
}
在这个例子中,ready 是一个 volatile 变量。由于 volatile 变量的写操作(操作2)有一个内存屏障,所以操作1(data = 1;)必须在操作2(ready = true;)之前执行。这就保证了 write() 方法中的操作1 和操作2 的有序性。
同样,由于 volatile 变量的读操作(操作3)有一个内存屏障,所以操作4(int result = data;)必须在操作3(if (ready) { … })之后执行。这就保证了 read() 方法中的操作3和操作4的有序性。
volatile
有序性最经典的一个运用便是在单例模式中。
public class Singleton {
// 使用 volatile 关键字修饰,保证其在多线程环境下的可见性
private static volatile Singleton instance;
private Singleton() {
// 私有构造函数,防止外部直接创建实例
}
public static Singleton getInstance() {
// 第一次检查:如果实例不存在,则进入同步代码块
if (instance == null) {
synchronized (Singleton.class) {
// 第二次检查:如果实例仍然不存在,则创建新的实例
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
上述代码是单例模式的一种写法, getInstance()
方法首先检查 instance
是否已经被初始化。如果 instance
已经被初始化,那么就直接返回 instance
,这样就避免了每次调用 getInstance()
时都需要进入同步代码块,从而减少了同步的开销。
如果 instance
还没有被初始化,那么就进入同步代码块。在同步代码块中,我们再次检查 instance
是否已经被初始化。如果 instance
仍然没有被初始化,那么就创建一个新的 Singleton
实例。
这种方式称为双重检查锁定(Double-Checked Locking,简称 DCL),因为我们进行了两次 instance == null
的检查:一次是在同步代码块外,一次是在同步代码块内。
为什么要在同步代码块内再检查一次呢?这是因为可能会有多个线程同时进入同步代码块外的 if (instance == null)
。假设线程 A 和线程 B 同时进入了这个 if
,线程 A 首先进入同步代码块,创建了一个新的 Singleton
实例,然后线程 B 进入同步代码块。如果没有第二次检查,线程 B 会创建另一个 Singleton
实例,这就违反了单例模式。
这里,volatile
关键字的作用就是保证 instance
字段的读写操作不会被 CPU 指令重排,从而保证了程序的有序性。具体来说,当一个线程创建新的 Singleton
实例时(即 instance = new Singleton()
),这个操作实际上包含了以下三个步骤:
在 Java 中,这三个步骤可能会因为编译器优化而被重排序。例如,步骤2可能会在步骤1之后执行,也可能在步骤1之前执行。如果步骤2在步骤3之后执行,那么在多线程环境下,可能会出现一个线程获取到一个未完全初始化的 Singleton
对象。
使用 volatile
关键字可以禁止这种重排序。当 instance
变量被声明为 volatile
后,对它的写操作就会有一个内存屏障(这是一种特殊的指令),这个屏障可以防止重排序。这就是为什么我们需要在双重检查锁定模式中使用 volatile
关键字。
情景分析:假设有一个 volatile
变量 counter
,初始值为 0
。现在有两个线程 T1
和 T2
同时读取这个变量,然后各自对其进行递增操作,不过现在我们要求 T1
线程 +1
,T2
线程 +2
,最后将结果写回共享内存。
public class Counter {
private static volatile int counter; // 使用volatile修饰共享变量
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
test(); // 调用test方法
counter = 0; // 重置counter的值
}
}
// 测试方法
static void test() throws InterruptedException {
// 创建线程t1,对counter加1
Thread t1 = new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(500); // 线程休眠500毫秒
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
counter++; // 对共享变量counter加1
});
// 创建线程t2,对counter加2
Thread t2 = new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(500); // 线程休眠500毫秒
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
counter += 2; // 对共享变量counter加2
});
t1.start(); // 启动线程t1
t2.start(); // 启动线程t2
t1.join(); // 等待线程t1执行完毕
t2.join(); // 等待线程t2执行完毕
System.out.println(counter); // 输出counter的值
}
}
正常情况下,如果不发生并发冲突,可以获取到正确值 3 3 3。
我们知道上面的代码并不能保证线程安全,所以是有问题的。之前已经讨论过了,但是现在有一个问题:
这个错误的值是怎么得到的?
我们调整一下测试代码:
public class Counter {
private static volatile int counter; // 使用volatile修饰共享变量
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
test(); // 调用test方法
counter = 0; // 重置counter的值
}
}
// 测试方法
static void test() throws InterruptedException {
// 创建线程t1,对counter加1
Thread t1 = new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(500); // 线程休眠500毫秒
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
counter++; // 对共享变量counter加1
synchronized (Counter.class) {
System.out.println("T1: " + counter); // 输出t1线程操作后的counter值
}
});
// 创建线程t2,对counter加2
Thread t2 = new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(500); // 线程休眠500毫秒
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
counter += 2; // 对共享变量counter加2
synchronized (Counter.class) {
System.out.println("T2: " + counter); // 输出t2线程操作后的counter值
}
});
t1.start(); // 启动线程t1
t2.start(); // 启动线程t2
t1.join(); // 等待线程t1执行完毕
t2.join(); // 等待线程t2执行完毕
System.out.println(counter); // 输出counter的值
System.out.println(); // 输出空行,用于分隔不同次测试结果
}
}
测试效果如下:
我们发现,测试结果少了一种情况:
即,结果是:
或
出现这个问题的原因是: