深入浅出volatile关键字
- 为什么会存在volatile这个关键字?也就是说volatile关键字存在的意义是什么?
我们首先要知道,无论何种语言要执行下去都要解释为CPU能识别的指令,但是CPU在执行指令的时候,需要对数据进行运算操作,那数据是从哪取出来的呢?
我们大家都比较了解的相关硬件是内存。
我们知道CPU的运算速度很快的,但是从内存中读取和写入数据的速度却是比较慢的。如果每次CPU运算数据都是从我们的内存条里面读取数据,也就是从主内存去读取的话,内存的读取速度远远慢于CPU的执行速度,无疑限制了CPU的运算能力,这个时候CPU的多级缓存就应运而生了。
CPU 模型
一般CPU的三级缓存L1,L2,L3容量是逐步扩大的,其中L1的缓存速度最快。
那么这个问题和volatile有什么关系呢?举个栗子
假设a在JVM加载初始化过程被赋值为0,线程A和B同时执行a = a + 1的操作,我们预期的结果是a = 2。
但是,如果线程A,B同时从内存中复制了a值至各自的CPU高速缓存中,A执行完了a = 1写入自己线程的缓存,再同步回内存,B也如此。这样一来,最终a的值就定格在了1,这是和预期结果所相悖的。也就是说,在多CPU,多线程编程的场景下,很有可能存在计算结果为脏数据的现象。
问题产生了解决方法是什么呢?
- 通过在总线加LOCK#锁的方式
- 缓存一致性协议
但是第一种方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。所以引申出来第二种解决方式
最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
这个时候我们插入一下 伪共享的概念:
大多数高性能处理器(如 UltraSPARC 处理器)在 CPU 的低速内存和高速寄存器之间插入一个高速缓存缓冲区。访问内存位置时,会使包含所请求内存位置的一部分实际内存(缓存代码行)被复制到高速缓存中。随后可能在高速缓存外即可满足对同一内存位置或其周围位置的引用,直至系统决定有必要保持高速缓存和内存之间的一致性。
然而,同时更新来自不同处理器的相同缓存代码行中的单个元素会使整个缓存代码行无效,即使这些更新在逻辑上是彼此独立的。每次对缓存代码行的单个元素进行更新时,都会将此代码行标记为无效。其他访问同一代码行中不同元素的处理器将看到该代码行已标记为无效。即使所访问的元素未被修改,也会强制它们从内存或其他位置获取该代码行的较新副本。这是因为基于缓存代码行保持缓存一致性,而不是针对单个元素的。因此,互连通信和开销方面都将有所增长。并且,正在进行缓存代码行更新的时候,禁止访问该代码行中的元素,这种情况称为伪共享(来自ORACLE文档解释)
这种情况称为伪共享
现在我们讲一下volatile
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
禁止进行指令重排序。
可见性
先看一段代码,假如线程1先执行,线程2后执行:
//线程1
boolean stop = false;
while(!stop){
doSomething();
}
//线程2
stop = true;
这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。
下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。
那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
但是用volatile修饰之后就变得不一样了:
第一:使用volatile关键字会强制将修改的值立即写入主存;
第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
第三:由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。
这个时候我们能保证线程1在运行时,线程2把数值给改动了,会导致主内存的数据被修改,也会让CPU的高速缓存中数据STOP的值所对应的缓存行状态为无效状态,那么,下次执行线程1的时候,CPU发现变量的值所对应的缓存行状态为无效,会重新去主内存中去获取最新的值,也就是保证了一致性的原则。
禁止指令重排
什么叫指令重排?
在计算机执行指令的顺序在经过程序编译器编译之后形成的指令序列,一般而言,这个指令序列是会输出确定的结果;以确保每一次的执行都有确定的结果。但是,一般情况下,CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化,在某些情况下,这种优化会带来一些执行的逻辑问题,主要的原因是代码逻辑之间是存在一定的先后顺序,在并发执行情况下,会发生二义性,即按照不同的执行逻辑,会得到不同的结果信息。
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
z = 4; //语句4
j = -1; //语句5
对于JVM或者CPU处理来说,这5行代码,执行先后顺序对最终的结果不会产生影响,也就是我们常说的能够实现最终一致性。
volatile关键字禁止指令重排序有两层意思:
1. 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
2. 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行
举个栗子,同样的代码不变
//x、y、z、j为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
z = 4; //语句4
j = -1; //语句5
那么我们能保证语句3执行的时候语句1和语句2一定执行完成,并且语句4和语句5一定没有执行。
但是对于语句1和语句2的执行顺序,语句4和语句5的执行顺序是不做保证的
volatile的原理和实现机制
下面这段话摘自《深入理解Java虚拟机》:
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
lock前缀指令实际上相当于一个内存屏障(也称为内存栅栏),内存屏障会提供3个功能:
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)它会强制将对缓存的修改操作立即写入主存;
3)如果是写操作,它会导致其他CPU中对应的缓存行无效