决战圣地玛丽乔亚Day14 ----Volatile关键字分析
volatile:
背景:
CPU和主存读写速度不一致,出现了高速缓存。把一些经常读的数据存入高速缓存,交互计算和高速缓存进行,修改完毕后再从高速缓存刷回主存中。
但是问题来了! CPU是多核,不同的cpu都有自己的高速缓存,那么一份数据可能就被缓存在不同的cache中。 并发操作就会造成数据的不准。
硬件方面可以通过缓存一致性协议,Lock锁总线的方式来支持并发操作。但是太过于笨重,是很早期的产物了。然后又出现缓存一致性协议。在写数据时,
判断是否当前使用的数据时共享变量,如果是共享变量就把该变量在其他的CPU缓存中置为失效,如果其他CPU读取发现失效缓存会重新从内存读取。
Volatile其实是一个很基础底层的并发问题,如果想完全弄懂,首先要知道并发的三个概念:原子性、可见性、有序性。这是聊并发绕不开的三个概念。
原子性:
要么整个流程全部完成,要么就全部不做。在过程中不可打断。
可见性:
多线程操作,对数据的修改,其他线程是否知情。
有序性:
这个主要是处理器对程序进行效率优化导致,会进行指令重排序。单线程指令重排序,会保证最终的结果一致性,但是如果多线程还是进行指令重排序,就会影响最终结果的一致性。
java对于并发三要素的规范:
内存模型(所有变量存在于内存中,每个线程有自己的工作内存(cache))
1.原子性。
对于基础数据类型变量的读写保证原子性。
但是如 X++
x = x +1
y = x
这种操作都不是原子性
2.可见性
volatile保证,修改volatile的值后会立刻更新到主存
3.有序性
sychronized和Lock保证有序性,因为变成了单线程模式。
java内存模型的happens-before原则:
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
- volatile变量规则:对变量的写优先于读
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。 A>B B>C A>C
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
理解一下就是单线程下,会保证最终结果一致性。变量的写在读之前,变量的解锁在锁之前,变量的顺序有传递性
深入剖析Volatile关键字:
1.保证不同线程的实时可见性!修改完立马刷新数据,其他线程共享的置为失效。
2.禁止指令重排序!
1、原子性
Volatile也只能保证读/写的原子性,对于x++这种,需要先读后写的非一步操作,是不能保证原子性的。
原因:因为修改后立即刷新到内存,其他缓存失效这个操作,是一步到位的。如果是读+写这种非一步操作,完全可以在线程1读后阻塞,然后线程2读,两个线程读到的数据是一致的,
但是两个线程分别+1是在读到的数据上进行修改,读到的数据已经是老数据了,会造成误差。
对于这种情况,我们可以使用sychronized和Lock或者AtomicInteger这种封装好包进行原子性操作。
AtomicInteger主要是用到了CAS操作,CAS说白了就是用处理器提供的CMPXCHG指令实现了原子操作
3、有序性
1)当程序执行到volatile变量的读写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
简单来说,volatile禁止指令重排序,确保了volatile变量修饰的变量的语句在结构上的位置不会发生变化,且其前后的语句的最终一致性要保证。
x =
2
;
//语句1
y =
0
;
//语句2
flag =
true
;
//语句3
x =
4
;
//语句4
y = -
1
;
//语句5
前面讲述了源于volatile关键字的一些使用,下面我们来探讨一下volatile到底如何保证可见性和禁止指令重排序的。
下面这段话摘自《深入理解Java虚拟机》:
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)它会强制将对缓存的修改操作立即写入主存;
3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
volatile的使用场景:
volatile还是不能完全的取代synchronized,因为他对原子性的支持有限,synchronized能保证完全的原子性。
所以,我们在对状态量的原子性做要求的时候,可以用一用volatile, volatile boolean sign = false;
还有一种情况,就是double-check 单例的一种写法,这个也有很多东西可以讲。
public class Single {
private static Single3 instance;
private Single() {}
public static Single getInstance() {
if (instance == null) {
synchronized (Single.class) {
if (instance == null) {
instance = new Single3();
}
}
}
return instance;
}
}
这个是最初版本的单例模式。
- 第一个if (instance == null),其实是为了解决效率问题,只有instance为null的时候,才进入synchronized的代码段——大大减少了几率。
- 第二个if (instance == null),则是为了防止可能出现多个实例的情况
这里看似没有问题,但是问题出在
instance = new Single3();
这里看似是一个原子操作,实际上底层进行了三步操作:
1.首先给Signle3分配内存空间
2.调用Signle3的构造函数初始化成员变量,形成实例
3.将实例对象指向分配的内存空间
JVM会对这三步进行一个指令重排序,所以2,3的步骤是可能会发生变化的,因为分配内存空间是固定的所以不受影响。
也就是说:有可能先形成实例,然后然后指向内存空间。也有可能先给实例分配内存空间(instance不为null),再进行初始化。
如果把实例对象指向内存空间,这时instance不为null但是需要初始化,线程二突然抢占,线程二不知道这个instance是没有初始化的,直接去进第一个ifnull判断,发现没有问题,不为null,使用后发现没有初始化直接报错。
这里的关键问题在于,线程1没有写,线程2就去读。
所以改进的版本应该是:
public class Single {
private static volatile Single4 instance;
private Single() {}
public static Single getInstance() {
if (instance == null) {
synchronized (Single.class) {
if (instance == null) {
instance = new Single();
}
}
}
return instance;
}
}
这里就安全了很多,使用了volatile关键字。
instance = new Single();
并不是保证了instance内部三个顺序的禁止指令重排序。
而是说,我在向这个volatile变量instance写入数据完成之前,不允许读。
也就是如果线程2在线程1的new instance之前想要进行ifnull的判断,是不被允许的。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 25岁的心里话
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 一起来玩mcp_server_sqlite,让AI帮你做增删改查!!
· 零经验选手,Compose 一天开发一款小游戏!