Volatile——以DCL失效谈内存屏障用来禁止指令重排序的原理

作者: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()是怎样执行的:

生成汇编码是 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前面的“整体”锁住当前面的完成了之后lock后面的“整体”才能完成,当写完成后,释放锁,把缓存刷新到主内存。

 

总结

好好理解好上面的“整体”,我们不难发现其实禁止重排也只是相对而言的,虚拟机这样做,其实也是为了效率考虑,因为只锁一部分整体让其有序就能达到目的的话,就没必要让每一步都有序,因为这样太影响优化了,指令重排在优化性能上的作用是很大的。

 

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值