目录
一. volatile 基础
- 首先volatile有两大功能,volatile 修饰的变量可以保证线程可见性与指令重排
- 先说一下为什么要保证变量的线程可见性: 在JVM中分主内存跟本地内存,线程读取数据是基于主内存读取的,由主内存数据读取到本地内存中,而修改数据时,先修改本地内存中的数据,然后将本地内存中的数据刷出给主内存,普通变量时存在一个问题,假设一个线程数据,修改完本地内存后,在向主内存刷出时,其它线程通过主内存读取到了旧数据,就会造成数据安全问题
- 先简述一下volatiol是怎么保证可见性的:两个方面,一是内存屏障禁止指令重排,二是CUP主线嗅探机制,通过这两项保证了volatile的可见性
- 防止指令重排: 在读取被volatile修饰的变量时多执行了一个“load addl $0x0, (%esp)”操作,是一个防止指令重排序的内存屏障,禁止把后面的指令重排序到内存屏障之前的位置
- CUP主线嗅探机制: 被volatile修饰的变量会添加"lock"指令,被添加"lock"指令的变量在修改时会立即推送到主内存,被"lock"修饰的变量修改推送到主内存后,其它线程嗅探主内存,当发现自己本地内存中持有该变量时会设置为无效,后续使用再通过主内存获取最新的值
- volatile 是怎么防止指令重排的: 基于JMM"先行发生原则happens-before"规范,实现了一下特点
例如: 读取被volatile修饰的变量赋值时,多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;(什么是指令重排序:是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理)。
- volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
二. 内存屏障
- 解释在了解 volatile前先了解一下内存屏障,volatile是基于内存屏障做的
- java 虚拟机中提供了四种内存屏障, 前面学了JMM"先行发生原则happens-before"规范,而内存屏障就相当于是一种落地实现
- 可以结合java提供的Unsafe类源码,中的三个native类型的方法,了解四大内存屏障,下载OpenJDK找到Unsafe.java—>Unsafe.cpp—>OrderAccess.hpp 一直追到c++上
//java中Unsafe类下的三个方法
public native void loadFence();
public native void storeFence();
public native void fullFence();
- OpenJDK下Unsafe.java对应java中Unsafe下的三个方法
- java底层是c++ 继续向下追找到Unsafe.cpp
- 继续向下追找到OrderAccess.hpp中,loadload(), storestore(), loadstore(), storeload()这四个指令就内存屏障
- 上面了解到: JMM四大内存屏障指令就是: loadloa, storestore, loadstore, storeload 这四个指令分别表示什么意思
三. volatile 与内存屏障
- 上面将了内存屏障,那么内存屏障与volatile有什么关系,操作普通变量与volatile修饰的变量时有什么不同(在操作volatile类型变量时底层会执行上面说的内存屏障指令,达到可见性,禁重排的效果)
- 根据内存屏障指令解释: “写volatile类型变量时”
- 写操作之前, 底层会插入一个"StoreStore"屏障指令,后续的写之前要保证第一个sotre写已经执行完毕并刷出到主内存
- 写操作之后, 底层会插入一个"StoreLoad"屏障指令,保证写操作已经刷新到主内存后,才允许后续的load读操作执行,并防止与后面的volatile读产生重排序
- 根据内存屏障指令解释: “读volatile类型变量时”
- 读操作之前, 会插入一个"LoadLoad"指令,保证后续的读操作,要在前面的读操作执行完毕后执行,并禁止前面的volatile读与普通类型数据的读产生重排序
- 读操作之后,会插入一个"LoadStore"指令,保证在读之前,前面的写操作要执行完毕,并将数据刷新到主内存后执行,并禁止读之前当前数据前面的votalie读与当前读之前后面的普通读产生重排序
- 重排序问题(不存在数据依赖关系,其实发生重排序也没问题)
四. volatile 变量的读写过程
- 先看一段案例代码
import java.util.concurrent.TimeUnit;
public class VolatileDemo {
//1.普通类型变量
private static boolean flag = true;
//2.volatile类型变量
//private static volatile boolean flag = true;
public static void test() throws InterruptedException {
new Thread(() -> {
while (flag) {
System.out.println(Thread.currentThread().getName() + "线程执行");
}
System.out.println(Thread.currentThread().getName() + "线程执行完毕");
}, "t1").start();
TimeUnit.SECONDS.sleep(1L);
new Thread(() -> {
flag = false;
System.out.println(Thread.currentThread().getName() + "线程,修改flag标识为false停止t1线程");
}, "t2").start();
}
public static void main(String[] args) throws InterruptedException {
test();
}
}
- 上述代码中使用volatile与不使用的flag出现的问题
- 重点 JMM内存模型中定义了8种工作内存(线程私有)与主内存之间的原子操作
- 根据上图了解到volatile类型变量的读写过程(成对出现的)
通过了解 volatile 的读写过程了解为什么不具备原子性
- 先给出答案: volatile类型变量对复合操作例如(i++)不具备原子性,既然保证了可见性为什么不具备原子性呢, 示例代码(多线程下对volatile类型变量num进行++操作,最终结果应该是10000,实际会少几个)
private volatile int num =0;
public void volatileAdd() throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
num++;
}
}).start();
}
TimeUnit.SECONDS.sleep(5L);
System.out.println(num);
}
public static void main(String[] args) throws InterruptedException {
VolatileDemo demo = new VolatileDemo();
demo.volatileAdd();
}
- 执行"javap -v VolatileDemo.class" 命令查看字节码会发现在volatileAdd()方法中对"num++"时实际是分为三步去执行的
第一步:执行字节码中的"getfield"拿到num的原始值
第二步:字节码中的"add"进行累加操作
第三步:字节码中的"putfield"写,把累加后的值写回去
- 对于volatile修饰的变量执行单条jvm指令是可以保证原子性的,如果是多条jvm指令,则不能保证,以"i++"为例,底层实际会执行三条指令
- 第一步执行"i load" 先读取到变量i
- 第二步"i add" 对i变量进行累加
- 第三方"i store"将累加后的值赋值给i变量
- 在进行累加操作时由于是分为三步去执行的,假设多线程,第二个线程在第一个线程读取旧值还没开始写之前执行,那么第二个线程与第一个线程拿到的值是相同的(问题在蓝框处),可能会出现先后两个线程读取了相同的值到本地内存中,然后第一个线程写入成功,由于内存屏障第二个线程本地内存会作废,出现写丢失的问题(还是理解为两个线程读取了相同的数据,在相同的数据上进行累加,少了一次)
五. 如何正确使用 volatile
- 单一(不存在例如i++这种复合复制,只有赋值操作)是可以用的,例如状态标识,判断业务是否结束等
- 开销较低的读,写锁策略,例如下方,操作val变量会设计到线程安全问题,但是在业务上是读多,写少,所以setVal()写方法上我们使用synchronized修饰,getVal()读方法上不需要,只将val这边变量修饰为volatile即可
private volatile int val;
public synchronized void setVal(int val) {
this.val = val;
}
public int getVal() {
return val;
}
- DCL双端锁
DCL 双端锁
- DCL 双端锁: 一种安全的双端锁检索单例模式
public class SafeDoubleCheckSingleton {
//1.私有化构造器防止外部调用创建对象
private SafeDoubleCheckSingleton(){
}
//2.提供私有化对象属性,创建对象后赋值给该属性(在第四步解释
//为什么使用volatile修饰)
private static volatile SafeDoubleCheckSingleton singleton;
//3.提供外部获取对象方法
public static SafeDoubleCheckSingleton getInstance() {
if (singleton == null) {
synchronized (SafeDoubleCheckSingleton.class) {
if (singleton == null) {
//4.内部创建对象赋值给私有对象属性,
//重点注意此处是有隐患的,多线程环境下,由于重排序,
//该对象可能还未完成初始化就被其它线程读取,结果返回了null
//防止这个问题发生singleton属性需要使用volatile修饰
singleton = new SafeDoubleCheckSingleton();
}
}
}
return singleton;
}
}
- 解释
六. 总结
- 什么是内存屏障: 是一类cpu同步屏障指令, 是cpu或编译器随机访问内存操作中的一个同步点, 使得此点之前的所有操作都执行后才可以继续执行此点之后的操作避免代码重排
- 由java中提供的Unsafe下的 loadFence(),storeFence(), fullFence()一直向下追OpenJDK–>最终在c++的OrderAccess中会找到对应的四个指令loadloa(), storestore(), loadstore(), storeload(), JMM四大内存屏障就是: loadload, storestore, loadstore, storeload
- 四大屏障指令分别代表什么意思
loadload: 保证第一个load读在第二个load之前执行
storestore: 后续的sotre写之前要保证第一个sotre写已经执行完毕并刷出到主内存后
loadstore: 写操作执行前要保证前面的读操作已经执行结束
storeload: 保证写操作已经刷新到主内存后,才允许后续的load读操作执行
- 根据内存屏障指令解释: “写 volatile类型变量时,JMM会把该线程对应的本地内存中的共享变量的值立即刷新回主内存”
- 在对volatile类型变量进行写操作之前, 底层会插入一个"StoreStore"屏障指令,后续的写之前要保证第一个sotre写已经执行完毕并刷出到主内存
- 在对volatile类型变量进行写操作之后, 底层会插入一个"StoreLoad"屏障指令,保证写操作已经刷新到主内存后,才允许后续的load读操作执行,并防止与后面的volatile读产生重排序
- 根据内存屏障指令解释: “读volatile类型变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量”
- 在对volatile类型变量进行读操作之前, 底层会插入一个"LoadLoad"屏障指令,保证后续的读操作,要在前面的读操作执行完毕后执行,并禁止前面的volatile读与普通类型数据的读产生重排序
- 在对volatile类型变量进行读操作之后,底层会插入一个"LoadStore"屏障指令,保证在读之前,前面的写操作要执行完毕,并将数据刷新到主内存后执行,并禁止读之前当前数据前面的votalie读与当前读之前后面的普通读产生重排序
- volatile 只保证可见性,对于i++等累加操作不能保证一致性,原因:通过"javap -v"命令查看带有i++等操作的字节码文件,发现i++底层实际是通过三个步骤去执行的,1加载i的值, 2进行累加操作, 3将累加后的结果写回,根据了解voliatle读写过程,在读出完毕与写入开始有一个空档,假设第一与第二两个线程先后都将相同的数据都到了本地内存,第一个线程累加将数据写回了内存,由于内存屏障,会将第二个线程操作的本地内存设置为无效出现写丢一次,进而造成数据不安全问题(还是理解为两个线程读取了相同的数据,在相同的数据上进行累加,少了一次)