volatile 详解(2.0)
volatile 详解
- 前面我们讲过的 JMM、Happen-before,JMM 是规范,有个细则叫 happen-before ,用来保证有序性的是 volatile、synchronized 关键字来捍卫
- volatile 凭什么可以保证有序性和可见性,靠的是内存屏障,内存屏障分为 loadload、StoreLoad、LoadStore、StoreStore
volatile 作用
作用: 保证可见性、保证有序性、不保证原子性
volatile的内存语义
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值
立即刷新回主内存
中。当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从
主内存中读取共享变量
所以 volatile 的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取
保证有序性
我们从一个最经典的例子来分析重排序问题。大家应该都很熟悉单例模式的实现,而在并发环境下的单例实现方式,我们通常可以采用双重检查加锁(DCL)的方式来实现。其源码如下:
1 | public class Singleton { |
现在我们分析一下为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:
- 分配内存空间。
- 初始化对象。
- 将内存空间的地址赋值给对应的引用。
但是由于操作系统可以对指令进行重排序
,所以上面的过程也可能会变成如下过程:
- 分配内存空间。
- 将内存空间的地址赋值给对应的引用。
- 初始化对象
如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。
保证可见性
1 | package tech.chen.juccode.a12; |
不加 volatile,没有可见性,程序无法停止,加了 volatile,保证可见性,程序可以停止
- 如果 flag 不加 volatile 修饰,当主线程 运行
flag = false;
时,t1 线程 访问的 flag 仍然是线程内部的缓存为 true ,这就是不可见。 - 添加 volatile 后,主线程改变 flag 时,所有其他线程持有该 flag值 的缓存都会
失效
,while 循环会从主存中再次读取 flag 发现 已经为 false,这就是可见。
volatile 实现原理
volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现:
什么是内存屏障
- 内存屏障(也称内存栅栏 , 内存栅障 , 屏障指令等 , 是一类同步屏障指令 , 是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。
内存屏障其实就是一种JVM指令
,Java 内存模型的重排规则会要求 Java 编译器在生成 JVM 指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile 实现了 Java 内存模型中的可见性和有序性,但 volatile 无法保证原子性 - 内存屏障之前的所有写操作都要回写到主内存,以保证内存屏障之后的所有读操作都能获得。
- 一句话:对一个 volatile 域的写, happens-before 于任意后续对这个 volatile 域的读,也叫写后读
volatile 有序性实现
happens-before 规则中有一条是 volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
1 | //假设线程A执行writer方法,线程B执行reader方法 |
根据 happens-before 规则,上面过程会建立 3 类 happens-before 关系。
- 根据程序次序规则:1 happens-before 2 且 3 happens-before 4。
- 根据 volatile 规则:2 happens-before 3。
- 根据 happens-before 的传递性规则:1 happens-before 4。
因为以上规则,当线程 A 将 volatile 变量 flag 更改为 true 后,线程 B 能够迅速感知
volatile 禁止重排序
为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序。
Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。
JMM 会针对编译器制定 volatile 重排序规则表。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。
总结一下
对于重排序 规范:
- 普通读写之后的 volatile 写操作不允许重排序到前边
- volatile 读之后的所有操作 不允许重排序到 前边
- volatile 写之后的 volatile 读/写 操作 不允许重排序到 前边
volatile 的应用场景
使用 volatile 必须具备的条件
- 对变量的写操作不依赖于当前值。
- 该变量没有包含在具有其他变量的不变式中。
- 只有在状态真正独立于程序内其他内容时才能使用 volatile。
单一赋值可以 , 包含复合运算赋值不可以(i++之类)
volatile int a = 10
volatile boolean flag = false状态标志,判断业务是否结束
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public class VolatileDemo {
public volatile static boolean flag = true;
public static void main(String[] args) {
new Thread(()->{
System.out.println(Thread.currentThread().getName() + "\t com in");
while (flag){
}
System.out.println(Thread.currentThread().getName() + "\t end in");
},"t1").start();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = false;
}
}开销较低的读,写锁策略
1
2
3
4
5
6
7
8
9
10
11
12
13
14/**
* 使用:当读远多于写,结合使用内部锁和 volatile 变量来减少同步的开销
* 理由:利用 volatile 保证读取操作的可见性;利用 synchronized 保证复合操作的原子性
*/
public class Counter{
private volatile int value;
public int getValue(){
return value; //利用volatile保证读取操作的可见性
}
public synchronized int increment(){
return value++; //利用synchronized保证复合操作的原子性
}
}懒加载单例模式
AtomicIntegerFieldUpdater 要求使用 public volatile 修饰的属性。