Java:volatile关键字

        今天我们来讲volatile这个关键字。

        volatile是Java里面提供的一种轻量级的同步机制,非常的轻量级,没有线程的上下文切换和调度。

        Volatile 关键字包含两个基本语义,分别是线程的可见性和有序性。可见性指的是当一个线程对某个变量值执行了修改操作,其它线程能感知到该变量值已经被修改,再次读取该变量值时将是被修改后的新值。有序性则是因为JVM通常情况下是会对要执行的指令进行优化性重排序,为保证特殊情况下某段指令能顺序执行,则需要禁止JVM对这段指令的优化性重排序,以保证指令按既定的顺序执行。

        很简单是吧,但是我们今天不研究这么简单的东西,来点高级的,就是来看看这个两个语义的低层原理是什么样的尼?这此之前,我们需要大概了解一下Java是如何干活的。

        搞Java的地球人都应该知道,在Java里面我们无论是干啥都是以线程的方式由CPU调度执行完成,所以呢在解释这个问题之前必须要看一下线程在被CPU调度时是怎么访问所需要的数据的。

 

         这是一个经典PC模型,但随着PC技术的发展,现代PC主板玩的套路五花八门,早已不是原来的那个它。但并不妨碍我们在经典的基础上来解析CPU总线概念。可以看出,在这个经典模型中,CPU访问内存,首先要经过CPU总线到达北桥芯片,再从北桥经内存总线后访问到内存。CPU到北桥之间的这总线通常叫CPU总线,也有叫前端总线,这条总线是PC系统中最快的总线,是芯片组与主板的核心,主要由CPU来使用。

         CPU的运行效率很高,相对来说内存的效率很低下了,甚至是远远跟不上CPU的步调,所以为了提升整体性能CPU通常会设置高速缓存。

 

        在CPU内部存储中速度最快的是寄存器,寄存器是CPU直接访问和处理数据的地方,举一个粟子,CPU是通过时分的方式来完成线程调度,当某一个线程分配的时间片结束(但当前线程未结束),下一个时间片已经分配给了另一个线程,这时就需要线程切换,切换前必须要做的一件重要事情就是将当前线程的执行状态暂存到寄存器,这样该线程在它的下一个时间片到来时就能根据寄存器暂存状态快速恢复执行。

        CPU不会直接访问缓存,要先从缓存将数据读入寄存器。通常CPU的运算单元会问寄存器要数据,寄存器上没有,则会从缓存查找,缓存也没有的时候则从主存调入。

        有了上前面这些基础,接下来我们就可以先来谈一谈Volatile的第一个可见性问题了。

        好的,问题来了,假设一个变量A=1,被CPU 1访问并缓存,时也被CPU 2访问并缓存,某一时刻CPU 1把变量修改成A=2并写回主存(有个需要注意的问题,就是考虑到总线的效率问题,这个修改是不太可能被实时写回主存,具体什么时候写回要看各CPU厂家的心情),此时在CPU2的缓存未失效之前是不知道变量A已经修改,将依然使用自己缓存中的A=1参与处理,显然这是不正确。

 

        为了解决上述这个问题,就有了MESI(缓存一致性协议)。MESI 是指4种状态的首字母,每个缓存行(Cache Line)有4个状态,可用2个bit表示,它们分别是:

状态

描述

监听任务

M(Modified)

该缓存行有效,数据被修改了,和内存中的数据不一致,数据只存在于本缓存行中

缓存行必须时刻监听所有试图读该缓存行相对应的内存的操作,其他缓存须在本缓存行写回内存并将状态置为E之后才能操作该缓存行对应的内存数据

E(Exclusive)

该缓存行有效,数据和内存中的数据一致,数据只存在于本缓存行中

缓存行必须监听其他缓存读主内存中该缓存行相对应的内存的操作,一旦有这种操作,该缓存行需要变成S状态

S(Shared)

该缓存行有效,数据和内存中的数据一致,数据同时存在于其他缓存中

缓存行必须监听其他缓存将该缓存行无效或者独享该缓存行的请求,并将该缓存行置为I状态

I(Invalid)

该缓存行数据无效

MESI工作原理:

1、CPU 1从内存中将变量A=1加载到缓存中,并将变量A的状态改为E(独享),并通过总线嗅探机制嗅探其它CPU 对变量A的操作(所有CPU的操作都会经过总线);

2、此时,CPU 2读取变量A,总线嗅探机制会将CPU 1中的变量A的状态置为S(共享),并将变量A加载到CPU2的缓存中,状态为S;

3、CPU 1对变量A进行修改操作A=2,此时CPU 1中的变量A会被置为M(修改)状态,而CPU2中的变量A会被通知,改为I(无效)状态,此时CPU 2中的变量A做的修改是不会被写回主存的;

4、CPU 1将修改后的数据写回主存,并将变量A置为E(独占)状态;

5、此时,CPU 2发现自己缓存的A为无效状态,会重新去主存中加载变量A,同时CPU 1和CPU 2中的变量A都改为S状态。

 

        在嗅探机制之前实现缓存同步是使用总线锁(如果看过volatile字节码指令就会发现其实发出的就是一条lock指令),但是锁机制会严重影响效率,所以就采用嗅探机制。因为所有CPU的操作都会经过总线,嗅探机制是各CPU缓存监听共同的总线,关心总线中关于自己缓存有关的数据操作,相应的修改自己缓存的状态。

        通过这个MESI协议,我们就可以实现一个CPU修改了某一个变量值,另一个CPU就可以马上取到其被修改后的新值,这就是Volatile的线程可见性。

        Volatile的可见性到这里就差不多了,接着我们再来谈一谈有序性的问题

        在开头的时候就说到了,JVM会根据一些特定的规则会对一批指令的执行顺序进行优化性的重新排序,当然这个重排序不可能胡乱排序,必须按一定的规则来。

        这里有一个不得不说的故事,那就是AS-IF-SERIAL 语义,其意思就是不管怎么重排序程序的执行结果不能被改变。编译器、运行时(Runtime) 和处理器都必须遵守这个语义。为了遵守AS-IF-SERIAL语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。

举个简单的粟子:

①  A=1;

②  B=2;

③  C=A+1;

        这3条程序通常按①②③顺序执行,但事实上在执行的时候,执行①读取了A=1,在要执行② 时发现B还在主存没有调入,从主存调入缓存还需要时间,但这个时候CPU并不需要停下来待B调入,会发现③中计算C需要的资源A已经在缓存了,就跳过② 直接执行③,等到②的资源到位后再回来执行②,所以最终的执行顺序就成了①③②。通过种重排序可以使用有效提升CPU的利用效率。

        好的,问题又来了,既然指令重排可以提升CPU的利用效率,而Volatile的有序性就恰好是要禁止指令重排,是为什么呢?不担心降低CPU的利用效率吗?

        禁止指令重排会影响CPU执行效率是必然,那为什么还要一条路走到黑呢?这就牵涉到一门很重要的艺术,那就是平衡的艺术。在一些特定的情况下指令重排后确实会产生一些我们不希望的问题,就拿我们常用的单例来讲:

        public class Singleton {

                private static Singleton instance;

               public static Singleton getInstance() {

                        if (instance == null) { // 第一次检查

                                synchronized (Singleton.class) {

                                       if (instance == null) {

                                                instance = new Singleton(); // 创建单例

                                       }

                               }

                       }

               return instance; 

               }

        }

在第A线程执行到instance = new Singleton(); 创建单例对象这一行时就要注意了,因为在Java里面创建一个对象通常分3步完成:

        memory = allocate(); // 1:分配对象的内存空间

        ctorInstance(memory); // 2:初始化对象

        instance = memory;  // 3:设置instance指向刚分配的内存地址

然后,问题就出在了上面代码中的2和3之间,这两步可能会被重排序:

        memory = allocate(); // 1:分配对象的内存空间

        instance = memory;  // 3:设置instance指向刚分配的内存地址

        ctorInstance(memory); // 2:初始化对象java

        可以看出,代码2和3被重排后,代码3会先于2执行,执行完3后对象就已经被暴露出去,但此时代码2尚未执行,即该对象还没有完成初始化。这在单线程环境下是没有问题的,但在多线程环境下会出现问题,其它线程会看到一个还没有被初始化的对象。

        2和3的重排序不影响线程A的最终结果,但会导致线程B在会断出instance不为空,线程B接下来将访问尚未完成初始化的instance引用的对象,就会报错。

        为了避免出现这种我们不希望出现的报错,就有了volatile的第二个线程有序性语义:禁止指令重排序。

 

        那禁止指令重排序是如何实现的呢?

        这里有一个不得不说的故事:内存屏障。内存屏障其实就是一组CPU指令,分为Load Barrier 和 Store Barrier两种,即读屏障和写屏障。主要有两个作用:

        (1)阻止屏障两侧的指令重排序;

        (2)强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。

        对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

Java的内存屏障则是在上述两种的基础上发展而成的四种:LoadLoad、StoreStore、LoadStore、StoreLoad,完成一系列的屏障和数据同步功能。

屏障

执行说明

LoadLoad

在该屏障之后的读操作读取数据之前,要保证该屏障之前的读操作已经读数完毕

LoadStore

在该屏障之后的写操作执行之前,要保证该屏障之前的写操作已经对其它线程可见(已经刷入主存)

StoreLoad

该屏障之后的写操作数据被刷入主存之前,要保证该屏障之前的读操作已经读数完毕

StoreLoad

该屏障之后的所有读操作执行之前,要保证该屏障之前的写操作已经对其它线程可见(已经刷入主存)

StoreLoad屏障的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

 

volatile的内存屏障策略(非常严格保守,非常悲观):

操作

之前

之后

写操作

插入StoreStore屏障

插入StoreLoad屏障

读操作

插入LoadLoad屏障

插入LoadStore屏障

 

        现在回过头再来研究一下volatile是如何通过内存屏障来保证前面那个单例不出问题的。还是看instance = new Singleton(); 创建单例对象这一句,实事上这是一个写操作,如果instance变量为volatile修饰,那么就应该是这样的:

        StoreStore屏障

        instance = new Singleton();

        StoreLoad屏障

        就是说在“StoreLoad屏障”之后的读操作执行之前,会保证其之前的instance = new Singleton();写操作必须是已经完成,即包括对象初始化的数据都已经全部刷入主存(不刷入主存对其它线程不可见),在此之前其它线程从主存访问instance会为NULL。

 

        由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了类似锁的特性(是不是像有synchronized锁一样顺序执行)。

讲一下volatile的性能,比普通变量还是要稍差的,因为其语义本身对CPU缓存就不友好(每次读前都把缓存给无效了),同时也需要在本地代码中插入许多内存屏障来保证顺序执行,但比同步锁要好太多。

 

        还有一个问题要注意,就是volatile不具备原子性。

        假设对变量count的自增操作,这个处境有读、运算、和写三个操作复合而成,即先读入count,然后CPU对其执行+1运算,最后将count写入主存。但volatile只保证了在同一线程中这三操作是顺序执行,在多线程环境中就无法保存不同线程中这个三个操作的顺序性,如A、B两个线程,A线程读取了变量count的原始值0,但是在+1计算的时候被阻塞,此时,因为A线程没有将自增运算的结果刷回主存,B线程的自增操作也将读入变量count的原始值0,最终这两个线程都会将自己的运算结果count=1刷回主存,而不是我们期望的count=2结果。

 

总结一下:

        Volatile 有两个语义,是线程的可见性和有序性。

        可见性指的是当一个线程对某个变量值执行了修改操作,其它线程能感知到该变量值已经被修改,再次读取该变量值时将是被修改后的新值。

        有序性则就是禁止指令重排序。

 

        最后,我们说到了volatile具有类似synchronized同步锁一样顺序执行特性,且性能比同步锁要好太多,却又没有同步锁一样的原子性,那么同步锁又是什么样的呢?那么好的,下一堂我将来讨论同步锁关键字synchronized的实现原理,敬请期待。

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值