1、volatile关键字主要有三方面作用
1、实现long/double类型变量的原子操作
2、防止指令重排序(内存屏障实现,JIT编译器搞的)
3、实现变量的可见性(内存屏障实现)
2、解析“实现long/double类型变量的原子操作”
对于long和double原生数据类型都是占8个字节,也就是64位, 对于Java的原生类型还有其它6种,为啥单单只提到long和double呢?因为除了这俩原生类型,其它的原生类型不管是变量的读写都是原子性的,比如说:int a = 1;,它就是一个原子操作,也就是线程安全的。但是!!对于long和double类型它们却不是原子的,怎么理解,下面用一图来阐述一下为啥不是原子的:

如图,这里以long类型来进行说明,总共是64位,而实际在地址上是分为低32位和高32位来表示的,对于计算机,有32和64位的处理器,像32位的机器很显然无法寻址到64位地址,那对于这样的机器对于long类型是如何来处理的呢?比如
double a = 1.0;
写读的情况下的问题
(线程1写到一半,线程2写完了,线程1再写另外一半)
它在写入时是先写入低32位的数字,再写入高32位的数字,然后再将高低32组合起来就变成了最终值了,所以很明显这种写操作不是原子性的,分步骤了嘛,所以如果在多线程的环境下,此时非原子的操作就会产生问题了,下面来分析一下会产生啥问题:

此时又有一个线程来读取long数据,此时这个线程读到的是新的低32位的数据+旧的高32位的数据,是不是最终读出来的结果肯定就不如预期了。
同时写的情况下

好,此时线程2又准备来写低32位了,此时就变成这样了:

此时,线程1再准备写高32位数据时,是不是整个数据就乱了,再读的话,就是线程1和线程2的一个中间结果,这就是对于long和double这俩数据类型的一个非常严重的问题,此时要解决这个问题,就可以用volatile关键字声明既可:
3、volatile关键字对硬件上的影响:
这是必须要理解的前提条件,这里再稍加阐述一下背景:在JVM当中,如果不用volatile修饰变量的话,程序在读取该变量时往往不会直接从内存当中读取,而是从cpu的寄存器中读取,因为寄存器是CPU直接可以操纵最快的途径,而内存要比寄存器要慢得多,如果没有volatile修饰的变量由于不是直接从内存当中读取的,所以有可能读取的值不是最新的值;而当使用volatile修饰变量时,应用就不会从寄存器中获取该变量的值,而是从内存(高速缓存)中获取,这样就能保存每次读取的都是最新的,因为直接是从内存中读的,但是肯定会损失一些性能,毕境比从寄存器中读要慢一些。
4、volatile跟锁关系:
在有些文献当中将volatile关键字是一个“轻量级的锁”,为啥?因为在某些场景下volatile关键字和锁有一些类似的地方。类似的有以下两点:
1、确保变量的内存可见性。
2、防止指令重排序。
volatile关键字和锁都使用了内存屏障实现的,对于synchronized代码块而言,对应的字节码指令
monitorenter
内存屏障 (Acquire Barrier,获取屏障)/ /防止monitorenter指令和下面代码重排序
.....
内存屏障 (Release Barrier,释放屏障)/ /防止monitorexit指令和上面代码重排序
monitorexit
既然类似那直接用volatile来实现锁操作不就可以了么?其实还是有不同的点的:
1、相比于锁,volatile可以确保对变量写操作的原子性(一条CPU的指令),但是它不具备排他性(像synchronized关键字就有排他性,所谓排他性就是同一时间只能有一个线程进行上锁,其它线程只能进行等待)。使用volatile关键字修饰一个变量时,当一个线程对这个变量进行写操作,同时其他的线程也可以对它进行写操作,这个语义可以确保修改这个变量是正确的
2、使用锁可能会导致线程的上下文切换(内核态与用户态之间的切换),而使用volatile并不会出现这种情况。volatile始终处于用户态状态
5、volatile使用场景:
总结:如果要实现volatile写操作的原子性,那么在等号右侧的赋值变量中就不能出现被多线程所共享的变量,哪怕这个变量也是volatile也不可以。
虽说volatile可以称之为“轻量级的”锁,但是!!它不能取代锁,因为它自身有一些难以解决的问题存在,什么问题呢?下面进一步阐述一下:
int a = b + 2;
像上面这句代码会产生几个指令呢?其实是会产生两个指令,第一个指令是b+1,而第二个指令是将b+1的值赋值给a,很明显不是原子性的操作。那咱们用一下volatile呗:
volatile int a = b + 2;
这样就能保证原子操作了么?no!!!!因为对于等式右侧的"b+2"这个可以被多个线程访问,那a的值也就有不确定性了,如如果这样修改呢?
volatile int b = 1;
volatile int a = b + 2;
也不行,虽说b是原子性了,但是“b + 2”还不是呀。那再看一个等式:
valatile int a = a++;
也不能确保a变量的原子性,因为a++这本身就不是原子的,先加再赋值两步操作,所以对于这种赋值操作右侧不是原子性的情况不适合使用volatile,而正确的使用姿势应该是这样:
volatile int count = 1;
volatile boolean flag = false;
下面再来看一个等式;
volatile Date date = new Date();
由于new Data()它背后是先在堆中开辟空间,然后最终返回一个引用赋值给变量date,也不是一个原子的,这里只能保证引用赋值操作是原子的,所以此时的volatile关键字保证不了原子性。
6、何为指令重排序?
这其实涉及到JIT(Just In Time)的一些功能,在现代化的JVM编译器当中,它会根据我们所写的代码的情况自动的一定程序的优化,其中优化当中就有一个可能就是会对咱们的指令进行一定的修改,比如按照顺序执行了三条指令:1、2、3【对应我们的代码顺序】,但是在编译完之后可能生成的字节码会变成3、2、1,或1、3、2等,也就是对指令进行重排序了,这里用一个简单的例子来直观的看一下指令重排序的大概思想:
int a = 0;
int b = 1;
a++;
重排后可能为:
int a = 0;
a++;
int b = 0;
对于这个重排序其实是编译器为了让我们的程序执行的性能更高而采取的一种优化手段,但是!!!在极端情况下这种指令重排序的优化手段并不是我们需要的,所以此时就需要防止某些指令重排序,而是按我们所编写的代码的顺序来执行。对于指令重排序而言,在单线程环境下肯定是没任何问题的,如果有问题也不可能出现这种优化策略了,重点是在多线程的环境下这种所谓优化的指令重排序策略可能就会产生问题,而这个volatile关键字就具备这种防止指令重排序的功能。
7、阐述内存屏障(memeory barrier):
对于volatile关键字变量的读写操作,本质上都是通过内存屏障来执行的,而内存屏障兼具了如下两方面的能力:
-
1、防止指令重排序。
-
2、实现变量内存的可见性。
1、volatile写入操作
int a = 1;
String s = "Hello";
内存屏障 (Release Barrier,释放屏障)
volatile boolean v = false; / /写入操作
内存屏障(Store Barrier,存储屏障)
-
释放屏障:防止下面的volatile与上面的所有操作的指令重排序,即遇到该屏障,则会把它之前的所有代码发布出去,其他的线程就能立马看到最新的结果
-
存储屏障:它的重要作用是刷新处理器的缓存,结果是可以确保该存储屏障之前一切的操作所生成的结果对于其他处理器来说都可见(注意:是包括当前的写入操作)
2、volatile读取操作:
内存屏障 (Load Barrier,加载屏障)
boolean v1 = v; / /读取操作 ,前面有volatile boolean v = false;
内存屏障 (Acquire Barrier,获取屏障)
int a = 1;
String s = "Hello";
- 加载屏障:可以刷新处理器缓存,同步其他处理器对该volatile变量的修改结果。(即让v一定是最新的值,而非旧值。在volatile变量读入到工作内存时,都会刷新处理器缓存)
- 获取屏障:可以防止上面的volatile读取操作与下面的所有操作语句的指令重排序,则会把volatile读取操作执行的代码发布出去,其他的线程就能立马看到最新的结果(其实是其他CPU总线嗅探到共享变量的数据变化,CPU会将在工作内存的该变量对应的缓存行设置为无效状态, 当CPU对该变量进行读取时需要重新往主内存中再次读取,相当于可以看到最新的结果)
3、两个操作的对比


最后总结一下:
1、对于读取操作来说,volatile可以确保该操作与其后续的所有读写操作都不会进行指令重排序。
2、对于修改操作来说,valatile可以确保该操作与其上面的所有读写操作都不会进行指令重排序。
注意:
在上面的举例中都是Java的原生数据类型
如果是一个引用类型呢?比如说ArrayList,那对于volatile的内存屏障功效是不起作用的,为啥?因为ArrayList中的读写操作都不是原子的,比如读操作,得先找到元素的地址,然后再进行读取,但是!!如果将ArrayList的引用赋值给另一个volatile的ArrayList,这就可以确保原子操作,也就有了volatile相关的功效了。
网友评论