1. 线程之间可见性
1.1 问题引出
Volatile到底是用来干嘛的,来看一段程序
1 | public class Volatile问题引出 { |
两个线程共同维护一个变量flag,一个睡1s后修改flag为true,另一个等待它变为true后输出一句话
运行就会发现永远不能输出,因为一个线程感受不到另一个线程对它的改变,这是为什么呢?
因为JMM中有规定,线程不能直接操作主存中的值,只能拷贝到自己的工作内存再进行操作
也就是说线程改变了自己工作内存中的flag的值,并没有影响到另外一个线程的工作内存。明白了这点后,再把上面的代码volatile注释放开,这时候就能输出并结束了,这就是volatile的其中一个作用:线程间的可见性
误区:缓存一致性协议
其实volatile跟缓存一致性协议根本没什么关系,网上很多文章会将其和缓存一致性协议放在一起说,其实他们不是一回事
再看刚才的这一句,还是注释掉了volatile,但是while循环中多了一句打印System.out.println(“随便打印一条语句”);其他地方完全一样
1 | public class Volatile问题引出 { |
运行,看到可以经历很多次循环后还是打印了flag
这就是因为缓存一致性,更新了主存里面的东西,比如同步锁,可以看到volatile被注释掉了还是能更新,跟volatile没有半毛钱关系
2. 禁止指令重排序
2.1 什么是指令重排序
就是说cpu为了运行效率可能会将两条没有依赖性的指令顺序进行对调
这里有一段很巧妙的程序证明指令重排序存在
1 | public class 证明指令重排序 { |
这段程序巧妙在哪呢?看两个线程的任务逻辑,可以分析出不管哪个线程先执行,或者交叉执行,只要a = 1在x = b之前,b = 1在y = a之前,都不可能出现x = 0, y = 0的
说的再简单点:
如果a = 1先执行,那么y必为1
如果b = 1先执行,那么x必为1
如果a = 1, b = 1先执行,那么x, y都为1
只有被重排序为
1 | //线程1 |
才有可能x = 0, y = 0,明白了吗?
我们来看下指令重排序的底层原因
在java中Object o = new Object();产生了以上汇编指令
1 | new -> 生成一个初始值m=0 |
指令重排序,可能会调整为如下
1 | new -> 生成一个初始值m=0 |
这就有可能会造成我们拿到的指针不为null,但是此时的值还为0
2.2 DCL单例要不要加volatile
单例模式的一种实现:double-checked locking
1 | public class Singleton { |
这种方式高效,且实现了线程安全和懒加载
- 懒加载就是说定义的时候没有直接new,而是在调用getInstance的时候new
- 线程安全是说synchronized包裹了判空和new,
- 高效是说锁的粒度很细,只锁住了判空和new,因为外层可能会有别的业务代码。另一方面,DCL中外面的那层判空,避免了直接的synchronized锁竞争,提高了效率
好了,简单介绍完后,来说为什么要加volatile,一定要加,因为之前说了指令重排序,可能造成指针先存在,后赋值的现象。如果线程1发生指令重排序,先astore有了指针,此时停下,第二个线程走完整个流程,判空不为空导致直接return,但是又还没有new,因此return了个半初始化状态的对象
因此DCL单例一定要加volatile,禁止指令重排序
2.3 volatile如何做到禁止指令重排序的?
内存屏障,在JVM级别,内存屏障只是一个规范,就像是接口一样,交给不同的CPU实现,这个东西不同的硬件实现不一样。
JVM中规定有4中屏障
- LoadLoadBarrier读读屏障
- LoadStoreBarrier读写屏障
- StoreLoadBarrier写读屏障
- StoreStoreBarrier写写屏障
在volatile写之前加写写屏障,之后加写读屏障
在volatile读之后加读读屏障和读写屏障
不同的硬件厂商有自己不同的实现,其实并没有严格遵循规范(比如因特尔的sfence、ifence、mfence指令),但不管怎样都遵循两个最基本的原则:
happens before:销毁必不可能在创建前
as-if-serial:这个单次翻译过来就是“看起来像顺序执行”,也就是说不管你怎么重排序,单线程下执行出来的结果永远不变
再看看hotspot的实现
用的是lock,为什么?因为偷懒,没有针对不同的厂商进行不同的指令,而是汇编lock