volatile底层原理简介

1.抛砖引玉

首先,我们来看下这段代码,

value = 1;

isFinsh = false;

//线程A

void exeOnCPUA(){

      value = 10;

      isFinsh = true;

}

//线程B

void exeOnCPUB(){

        if(isFinsh){

        value == 10;

  }

}

一般情况下,value==10是true,但是在某些情况下,value==10是false的,为什么呢,一方面是变量的可见性问题,一方面是编译重排序,我们再来看一段代码,

public class DoubleCheckExample {

    public static Instance instance;

    public static Instance getInstance() {

        if (instance == null) {                       

            synchronized (DoubleCheckExample.class) { 

                if (instance == null) {               

                    instance = new Instance();         

                }

            }

        }

        return instance;

    }

}

这段代码是著名的DCL单例模式,也叫双重检查锁,那么通过这段代码真的可以获得单例吗,通常来讲是没问题的,但是某些情况下,仍然会出现问题,究其原因,就是发生了指令重排序。

那么,怎么解决以上2个问题呢,那就要说到我们今天的主角了-volatile,那他又是如何解决的呢,我们先来看一下操作系统内存结构的优化史。

2.操作系统内存结构

单核CPU

在早期,一台PC机通常都是单核CPU,CPU从主存中获取数据进行计算,但是由于I/O操作的速度比起CPU计算的速度差了几个数量级,后来又在他们中间加了一层高速缓存,如图1,

CPU直接从高速缓存中获取数据,这样,系统的性能得到了很大的提高。

多核CPU

但是随着计算量的增大,单核CPU已经远远不能满足计算的要求了,于是就发展到了现代计算机的多核CPU,计算速度获取了很大提高,但是也带来了一些问题。我们试想一下以下场景:

1.CPU1 从主存中读取了一个字节,以及它相邻的字节到CPU1的高速缓存中。

2.CPU2 做了上面同样的工作。这样CPU1,CPU2的高速缓存拥有了同样的数据。

3.CPU1 修改了那个字节,被修改后,那个字节被放回CPU1的高速缓存。但是该字节并没有被写入主存中。

4.CPU2 访问该字节,但由于CPU1并未将数据写入主存中,所以CPU2访问的还是他高速缓存中的老数据,这就带来了数据的不同步。

MESI

为了解决这个问题,芯片设计者制定了一个规则。当一个CPU修改高速缓存中的字节时,计算机中的其它CPU会被通知,它们的高速缓存将视为无效。于是,在上面的情况下,CPU2发现自己的高速缓存中数据已无效,CPU1将立即把自己的数据写回 主存中,然后CPU2重新读取该数据。这就是缓存一致性协议M(Modified) E(Exclusive) S(Shared) I(Invalid)。M表示修改,E表示独享,S表示共享,I表示无效。举个例子,

CPUA发出一条指令,从主存中读取x。

CPUA从主存通过bus读取到cache a中并将该cache a设置为E(独享)状态。

CPUB发出一条指令,从主存读取x。

CPUB试图从主存中读取x时,CPUA检测到了地址冲突,这时CPUA做出响应。此时x 存储于cache a和cache b中,x在chche a和cache b中都被设置为S状态(共享)。

CPUA对x进行计算,计算完成后发指令需要修改x.

CPUA将x设置为M状态(修改)并通知缓存了x的CPUB, CPUB将本地cache b中的x设置为I状态(无效)

CPUA对x进行赋值。

CPUB发出要读取x的指令。

CPUB通知CPUA,CPUA将修改后的数据同步到主存时cache a修改为E(独享)。

CPUB从主存中重新读取x,将cache a和同步后cache b中的x设置为S状态(共享)。

以上就是MESI作用的整个过程,他保证了变量x在各CPU中的数据始终都是最新的。

存储缓存(Store Bufferes)

缓存的一致性消息传递是要时间的,这就使得他们切换时会产生延迟。当一个缓存被切换状态时其他缓存收到消息完成各自的切换并且发出回应消息这么一长串的时间中CPU都会等待所有缓存响应完成。可能出现的阻塞会导致各种各样的性能问题和稳定性问题。

比如你需要修改本地缓存中的一条信息,那么你必须将I(无效)状态通知到其他拥有该缓存数据的CPU缓存中,并且等待确认。等待确认的过程会阻塞处理器,这会降低处理器的性能。因为这个等待远远比一个指令的执行时间长的多。

为了避免这种CPU运算能力的浪费,就引入了Store Bufferes。CPU把它想要写入到主存的值写到存储缓存,然后继续去处理其他事情。当所有失效确认都接收到时,数据才会最终被提交。

失效队列

但是这么做又带来了2个风险,

1.CPU会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。

2.保存什么时候会完成,这个并没有任何保证。

另外,执行失效也需要CPU花费时间去处理。同时,存储缓存的容量也是有限的,所以CPU有时仍然需要等待失效确认的返回。这两个操作都会使得性能大幅降低。为了应付这种情况,引入了失效队列。它们的约定如下:

对于所有的收到的失效请求,失效消息必须立刻发送。

失效并不真正执行,而是被放在一个特殊的队列中,在方便的时候才会去执行。

CPU不会发送任何消息给所处理的缓存条目,直到它处理失效信息。

内存屏障

但是,即使是这样,CPU还是不知道什么时候该进行优化,什么时候不该进行优化,最终,他将这个任务丢给了程序员,由程序员决定什么时候进行优化,这就是内存屏障。内存屏障分为2种,

读屏障Load Memory Barrier:是一条告诉CPU在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。

写屏障Store Memory Barrier:是一条告诉CPU在执行这之后的指令之前,应用所有已经在存储缓存中的保存的指令。

现在我们再来看一下上文中的第一段代码,

void exeOnCpuA() {

    value = 10;

    //在更新数据之前必须将所有存储缓存(store buffer)中的指令执行完毕。

    storeMemoryBarrier();

    isFinsh = true;

}

void exeOnCpuB() {

    while(!isFinsh);

    //在读取之前将所有失效队列中关于该数据的指令执行完毕。

    loadMemoryBarrier();

    value == 10;

}

可以看到,内存屏障完美的解决了可见性和重排序的问题。

这里简单解释下上文中的代码为什么会发生重排序,重排序主要分为3种,

编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从Java源代码到最终实际执行的指令序列,会经历这3种重排序,

源代码-》编译器优化重排序-》指令级并行重排序-》内存系统重排序-》最终执行的指令序列

3.JMM

上文中简单介绍了内存系统是如何来禁止重排序和实现可见性的,那么java中又做了什么呢,实际上java做了2件事。

第一,java根据操作系统的内存结构抽象出了自己的一个模型,即JMM-java内存模型,JMM中的主存,也就是java内存布局中的堆,对应着操作系统的主内存,JMM中的工作内存对应着CPU的寄存器和高速缓存。

第二,java根据操作系统的内存屏障抽象出了自己的内存屏障,即

LoadLoad屏障:对于这样的语句Load1;LoadLoad;Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。 

StoreStore屏障:对于这样的语句Store1;StoreStore;Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。 

LoadStore屏障:对于这样的语句Load1;LoadStore;Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

StoreLoad屏障:对于这样的语句Store1;StoreLoad;Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。

当JVM遇到volatile修饰的关键字时会做如下处理,

在每个volatile写操作的前面插入一个StoreStore屏障。

在每个volatile写操作的后面插入一个StoreLoad屏障。

在每个volatile读操作的后面插入一个LoadLoad屏障。

在每个volatile读操作的后面插入一个LoadStore屏障。

这样,既保证了线程可见性,也禁止了重排序。顺便说明一下,这四个屏障是JMM的规范,而不是具体的字节码指令,因为你可以看到volatile变量在字节码中只是一个标志位,通过javap反编译出来的字节码中并没有任何的屏障,只是说JVM执行引擎会在执行时插入一个对应的屏障,或者说在JIT生成机器指令的时候插一条对应逻辑的屏障。

最后,我们来看一下,JVM将volatile转换成汇编指令的时候,是如何插入屏障的,如图2,

我们发现,他在指令的前端加了一个lock,这是一个原子指令,目的就是实现一个全屏障,即读写屏障。

至此,volatile底层原理就介绍到此。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容