Volatile——以DCL失效谈内存屏障用来禁止指令重排序的原理 小鱼儿 2022-01-31 23:31 317阅读 0赞 作者:HJsir 来源:CSDN 原文:https://blog.csdn.net/hjsir/article/details/80713783 -------------------- ### 引言 ### 大家都知道volatile关键字具有两重语义即: 1. **保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。** 2. **禁止进行指令重排序。** 第一个好理解,也就是说每次修改都立即更新到主内存,那么禁止重排序这个在网上更多的解释是说使用了内存屏障,使得前后的指令无法进行重排序。(关于volatile详解) **那么问题来了,什么是内存屏障? volatile是怎么实现的? 这么实现为什么就能禁止重排了?带着三个问题我们往下看**。 ### ### ### 什么是内存屏障? ### 内存屏障是硬件层提供的保障一致性的能力的一系列方法,注意一系列,所以内存屏障不止一种 1. lfence,是一种Load Barrier 读屏障 2. sfence, 是一种Store Barrier 写屏障 3. mfence, 是一种全能型的屏障,具备ifence和sfence的能力 4. **Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。** 前面三种不是本文重点,看看了解下就好,本文重点是第四种,因为在X86平台上volatile是用Lock前缀就是使用的第四种。 首先**内存屏障有两种作用**: 1. 阻止屏障两边的指令重排序 2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效 而**Lock前缀**是这样实现的 1. 它先对总线/缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的脏数据全部刷新回主内存。 2. 在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。**Lock后的写操作会让其他CPU相关的cache失效,从而从新从内存加载最新的数据,这个是通过缓存一致性协议做的**。 那么这个lock前缀在JIT汇编代码中是啥样的?怎么操作的?我们来举例看看 ## ## ## 从DCL失效来看lock的作用 ## 下面是一段标准的DCL单例代码 public class Singleton { private volatile static Singleton instance = null; public static Singleton getInstance() { if(null == instance) { synchronized (Singleton.class) { if(null == instance) { instance = new Singleton(); } } } return instance; } } 首先回顾一下DCL失效,在DCL中当执行 **Instance = new Singleton()**的时候看起来是一句代码执行,但是在虚拟机里面不是这样的,他大概在虚拟机里执行了三件事 1. 给Singleton的实例分配内存(此时还没有指向mInstance) 2. 调用构造函数,初始化成员 3. 将mInstance对象指向分配的内存空间 在jdk1.5之前java内存模型中cache和寄存器到主内存的回写顺序规定,还有java编译器允许乱序执行,所以执行顺序可能不一致有的是1-2-3有的是1-3-2,在多线程中很可能线程A做了1步和第3步还没来得及做第二步的时候,就被切换到B线程,**B线程发现第三步已经执行了,所以直接拿来用了,但是这个时候是错误的,因为第二步没有执行,成员未被初始化,这就是DCL失效**。 那么我们来看看在JIT汇编代码中mInstance = new Singleton()是怎样执行的: ![70][] 生成汇编码是 **lock addl $0x0, (%rsp)**, 在写操作(putstatic instance)之前使用了lock前缀,锁住了总线和对应的地址,这样其他的CPU写和读都要等待锁的释放。当写完成后,释放锁,把缓存刷新到主内存。 结合DCL失效说就是,之所以DCL失效就是因为初始化成员还没执行就先执行了指向分配的内存,这样我们的实例已经不为null了,就导致后面的线程可能拿到没初始化的实例。而加了 volatile之后,volatile在最后加了lock前缀,把前面的步骤锁住了,这样**如果你前面的步骤没做完,是无法执行最后一步刷新到内存的**,换句话说**只要执行到最后一步lock,必定前面的操作都完成了**。那么即使我们完成前面两步或者三步了,还没执行最后一步lock,或者前面一步执行了就切换线程2了,**线程2在判断的时候也会判断实例为空**,进而由线程2完成后面的所有操作。当写完成后,释放锁,把缓存刷新到主内存。 > **综上所述**:lock的作用就是,保证前面的**Instance = new Singleton()**完全完成后,才通过lock将**Instance的值** 更新到内存。也由于lock其他线程中的**Instance的值**都失效了。所以这时其他线程读到的**Instance的值**都是初始化成功后的实例。 ### ### ### 注意 ### 这里我们就可以看到此内存屏障只保证lock前后的顺序不颠倒,但是并没有保证前面的所有顺序都是要顺序执行的,比如我有1 2 3 4 5 6 7步,而lock在4步,那么前面123是可以乱序的,只要123乱序执行的结果和顺序执行是一样的,后面的567也是一样可以乱序的,但是整体上我们是顺序的,把123看成一个整体,4是一个整体 567又是一个整体,所以整体上我们的顺序的执行的,也达到了看起来禁止重排的效果 所以其实内存屏障禁止重排就是:利用lock**把lock前面的“整体”锁住**,**当前面的完成了之后l**ock后面的“整体”才能完成,当写完成后,释放锁,把缓存刷新到主内存。 ### 总结 ### 好好理解好上面的“整体”,我们不难发现**其实禁止重排也只是相对而言的**,虚拟机这样做,其实也是为了效率考虑,因为只锁一部分整体让其有序就能达到目的的话,就没必要让每一步都有序,因为这样太影响优化了,指令重排在优化性能上的作用是很大的。 [70]: /images/20220201/2305e2d9f46d4370b3e26cfb43e5daf0.png
还没有评论,来说两句吧...