本章内容
volatile关键字
volatile关键字是Java中用来修饰变量的关键字之一,它的作用是保证变量在多线程之间的可见性,即:当一个线程修改了被volatile修饰的变量的值时,其他线程能够立即看到这个修改。
volatile关键字具备两层语义:
- 1)通过缓存一致性协议(MESI)禁用CPU缓存,保证不同线程之间对共享变量操作的可见性。
- 2)通过内存屏障禁止编译器和CPU指令重排序,保证不同线程之间对共享变量操作的有序性。
JDK1.5之后,对volatile关键字语义进行了增强,增加了hapens-before原则,保证不同线程之间对共享变量操作的有序性。
volatile可见性保证
在对volatile可见性进程说明之前,需要先了解Java内存模型(JMM)。
Java内存模型(JMM)
Java内存模型定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程读/写共享变量的副本。
如图所示:
图中,线程A与线程B之间通信需要经历以下两个步骤:
- 1)线程A将本地内存A中更新过的共享变量刷新到主内存中。
- 2)线程B从主内存中读取线程A已更新过的共享变量。
注意:本地内存又称工作内存,它是JMM中的一个抽象概念,涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
volatile可见性保证
JMM规范了主内存和线程工作内存的数据交换操作(原子性操作)。如图所示:
其中:
- lock(锁定):作用于主内存中的变量,一个变量在同一时间只能被一个线程锁定。该操作表示该线程独占锁定的变量。
- unlock(解锁):作用于主内存中的变量,表示这个变量的状态由处于锁定状态被释放,其他线程可以对该变量进行锁定。
- read(读取):作用于主内存中的变量,表示把一个主内存变量的值传输到线程的工作内存中,供后续的load操作使用。
- load(载入):作用于线程的工作内存中的变量,表示把从主内存中读取的变量的值放到工作内存的变量副本中。
- use(使用):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时,就会执行该操作。
- assign(赋值):作用于线程的工作内存中的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时,就会执行该操作。
- store(存储):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给主内存,供后续的write操作使用。
- write(写入):作用于主内存中的变量,表示把从工作内存中得到的变量的值放入主内存的变量中。
Java中的volatile关键字由C语言实现,操作volatile关键字修饰的变量时,最终会在解析的汇编指令前加上lock前缀指令来保证工作内存中读取到的数据是主内存中最新的数据。具体是通过缓存一致性协议(MESI)来实现:当多个CPU从主内存读取数据到高速缓存时,如果其中一个CPU更新了共享变量的数据,则会通过总线将该共享变量的数据回写到主内存中,其他CPU会通过总线嗅探机制感知到缓存中共享变量数据的变更,并将工作内存中的共享变量数据失效,重新从主内存中读取共享变量的数据到缓存中。
IA32架构软件开发者手册对lock前缀指令的解释:
- 1)会将当前处理器缓存行的数据立即回写到系统内存中。
- 2)这个写回内存的操作会导致其他CPU中缓存了该内存地址的数据失效(MESI协议)。
代码示例:
/**
* @author 南秋同学
* volatile可见性保证示例
*/
@Slf4j
public class VolatileExample {
/**
* 定义volatile修饰的变量
*/
public volatile static boolean flag = false;
public static void main(String[] args) {
// 线程A
new Thread(() -> {
while (!flag) {
log.info("线程A执行业务处理");
}
log.info("线程A业务处理完成");
}).start();
// 线程B
new Thread(() -> {
try {
// 睡眠1s
Thread.sleep(1000);
} catch (InterruptedException e) {
log.warn("线程B执行异常");
}
flag = true;
log.info("线程B设置flag变量值为true");
}).start();
}
}
执行结果:
06:53:49.923 [Thread-0] INFO com.nq.order.VolatileExample - 线程A执行业务处理
06:53:49.923 [Thread-0] INFO com.nq.order.VolatileExample - 线程A执行业务处理
06:53:49.923 [Thread-0] INFO com.nq.order.VolatileExample - 线程A执行业务处理
06:53:49.923 [Thread-0] INFO com.nq.order.VolatileExample - 线程A执行业务处理
06:53:49.923 [Thread-0] INFO com.nq.order.VolatileExample - 线程A业务处理完成
06:53:49.923 [Thread-1] INFO com.nq.order.VolatileExample - 线程B设置flag变量值为true
以上代码中:
- 线程A不断检查flag的值,直到flag的值为true时才停止循环,并输出"线程A业务处理完成"。
- 线程B在休眠1秒后将flag的值设置为true。
- 由于flag被volatile修饰,因此,当线程B将flag的值设置为true后,线程A能够立即看到flag修改后的值,从而停止循环并输出相应的结果。
volatile不能保证原子性
假如两个线程同时读取(read)主内存中同一共享变量count的值,并将读取到的值加载(load)到线程的工作内存中,两个线程的CPU又同时使用(use)count值进行计算,并将计算结果赋值(assign)返回到工作内存中,其中一个线程通过总线先将更新后的count值存储(store)到主内存中,另一个线程会通过总线嗅探机制感知到缓存中count值的变更,并将工作内存中的count值失效。
代码示例:
/**
* @author 南秋同学
* volatile不能保证原子性示例
*/
@Slf4j
public class VolatileExample {
/**
* 定义volatile修饰的变量
*/
private volatile int count;
public void add(){
count++;
}
@SneakyThrows
public static void main(String[] args) {
VolatileExample volatileExample = new VolatileExample();
for (int i = 0; i < 1000; i++) {
new Thread(() -> {
try {
// 睡眠10ms
Thread.sleep(10);
} catch (InterruptedException e) {
log.warn("执行异常");
}
volatileExample.add();
}).start();
}
Thread.sleep(1000);
log.info("count值:{}", volatileExample.count);
}
}
执行结果:
07:22:40.101 [main] INFO com.nq.order.VolatileExample - count值:990
其中,count++是一个复合操作,包括三个步骤:
- 读取主内存中count的值。
- 将count的值+1。
- 将变更后的count值写回主内存。
volatile无法保证以上三个操作的原子性。
处理流程:
- 1)线程A读取count变量值(如:100),进入阻塞(10ms)。
- 2)线程B读取count变量值(此时count变量的值仍然为100),进入阻塞(10ms)。
- 3)线程A阻塞结束,将count+1后赋值给count变量(11),此时线程A还未将count变量的值回写到主内存中。
- 4)线程B阻塞结束,将count+1后赋值给count变量(11),此时线程B还未将count变量的值回写到主内存中。
- 5)线程A将count变量的结果回写到主内存中,线程B通过总线嗅探机制感知到缓存中count变量值的变更,并将工作内存中的count变量值失效,重新从主内存中读取线程A回写的count变量值(11)到缓存中。
- 6)线程B将count变量的值(此时count值为线程A回写的值)回写到主内存中。
最终,经过线程A和线程B分别对count变量的值进行+1操作后,主内存中count变量的值只增加了1,从而说明volatile不能保证原子性。
volatile有序性保证
volatile有序性通过内存屏障禁止编译器和CPU指令重排序以及happens-before原则来保证。
指令重排序
在程序执行过程中,为了提高性能,编译器和处理器通常会对指令进行重排序。
重排序主要分为三类:
- 编译器优化重排序:编译器在不改变单线程语义的情况下,会对执行语句进行重新排序。
- 指令集重排序:现代操作系统中的处理器都是并行的,如果执行语句之间不存在数据依赖性,处理器可以改变语句的执行顺序。
- 内存重排序:由于处理器会使用读/写缓冲区,出于性能的原因,内存会对读/写进行重排序。
内存屏障
内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,volatile修饰的变量在读写操作前后会插入读写屏障来禁止指令重排序,保证读写操作的有序性。
内存屏障主要功能:
- 1)禁止屏障两边的指令重排序。
- 2)刷新处理器缓存(保证内存可见性)。
内存屏障类型
内存屏障类型:
禁止指令重排序
通过内存屏障禁止指令重排序,如图所示:
写屏障处理步骤:
- 1)执行普通读写操作。
- 2)在每个volatile写操作前面插入一个StoreStore屏障,禁止StoreStore屏障前面的普通写和StoreStore屏障后面的volatile写重排序。
- 3)在每个volatile写操作后面插入一个StoreLoad屏障,禁止StoreLoad屏障前面的volatile写与StoreLoad屏障后面可能的volatile读/写重排序。
读屏障处理步骤:
- 1)在每个volatile读操作后面插入一个LoadLoad屏障,禁止LoadLoad屏障后面所有的普通读和volatile读重排序。
- 2)在每个volatile读操作后面插入一个LoadStore屏障,禁止LoadStore屏障后面所有的普通写和volatile读重排序。
- 3)执行普通读写操作。
注意:内存屏障另一个作用是强制更新一次CPU缓存。例如:写屏障会将这个屏障前写入的数据刷新到CPU缓存,这样任何试图读取该数据的线程将得到最新值。
happens-before原则
JDK1.5之后,对volatile关键字增加了hapens-before原则。happens-Before指的是前面一个操作的结果对后续操作可见。happens-Before约束了编译器的优化行为,允许编译器优化,但是要求编译器优化后一定遵守happens-Before原则。
happens-before的原则:
- 程序次序规则:在一个线程内,按照程序代码顺序,写在前面的操作happens-before于写在后面的操作。注意:该规则指的是控制流顺序而不是程序代码顺序(因为要考虑分支、循环等结构)。
- volatile变量规则:对一个volatile变量的写操作happens-before于后面对该变量的读操作。注意:该规则顺序指的是时间上的先后顺序。
- 传递性规则:如果A happens-before B且B happens-before C,则A happens-before C。
- 管程锁定规则:一个解锁操作happens-before于后面对同一个锁的加锁操作。注意:该规则顺序指的是时间上的先后顺序。
- 线程启动规则:线程对象的start()方法happens-before于该线程的每一个动作。
- 线程终止规则:线程中的所有操作happens-before于对此线程的终止检测,可以通过Thread.join()和Thread.isAlive()等方法的返回值检测线程是否已终止。
- 线程中断规则:对线程interrupt()方法的调用hapens-before于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否发生中断。
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)hapens-before于它的finalize()方法的开始。
volatile关键字使用
volatile相对于synchronized更加轻量级,但是volatile不具备原子性,因此,volatile不是真正意义上线程安全性的一种工具。
volatile需谨慎使用,一般情况下,只有在状态独立于程序内其他内容时才能使用volatile。
状态标识
代码示例:
/**
* @author 南秋同学
* 状态标识
*/
@Slf4j
public class VolatileExample {
/**
* 定义volatile修饰的状态标识
*/
private volatile boolean flag;
/**
* 关闭
*/
public void shutdown() { flag = true; }
/**
* 工作
*/
public void work() {
while (!flag) {
log.info("业务处理");
}
}
}
其中,使用volatile修饰状态标识flag时,当执行shutdown()方法将flag设置为true时,work()方法能够立刻感知flag值的变化,并结束业务处理。
双重检查锁
使用volatile和synchronized实现双重检查锁的单例模式。
代码示例:
/**
* @author 南秋同学
* 双重检查锁单例模式
*/
public class Singleton {
private volatile static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() { // ①
if(instance == null) { // ②
synchronized (Singleton.class) { // ③
if(instance == null) { // ④
instance = new Singleton(); // ⑤
}
}
}
return instance; // ⑥
}
}
其中,第⑤步instance = new Singleton()可分为三个步骤(伪代码):
- a)分配内存:memory = allocate()。
- b)初始化对象:initInstance(memory)。
- c)instance变量指向对象内存地址:instance=memory。
在并发情况下,如果instance变量没有使用volatile关键字修饰,则第⑤步会出现问题:代码在编译运行时,可能发生指令重排序(即:执行顺序由a->b->c重排序为a->c->b)。例如:当线程A执行到第⑤步时,线程B执行到第②步,假设此时线程A执行过程中发生了指令重排序,执行了步骤a和步骤c,但没执行步骤b。由于线程A执行步骤c后,instance变量会指向一段内存地址,因此,线程B判断instance是否为null时,instance不为null,会跳过第⑥步直接返回一个未初始化的对象。
【阅读推荐】
更多精彩内容,如:
- Redis系列
- 数据结构与算法系列
- Nacos系列
- MySQL系列
- JVM系列
- Kafka系列
- 并发编程系列
请移步【南秋同学】个人主页进行查阅。内容持续更新中......
【作者简介】
一枚热爱技术和生活的老贝比,专注于Java领域,关注【南秋同学】带你一起学习成长~