java volatile关键字

缓存一致性问题

        内存模型描述的是程序中各变量(实例域、静态域和数组元素)之间的关系,以及计算机系统将变量存储到内存和从内存取出变量这样的底层细节。计算机在运行程序时,每条指令都是在cpu中执行的,执行指令时会涉及到数据的读写而程序运行的数据是存储在主内存中的,显然在主内存读取数据没有cpu执行指令的速度快,如果所有的交互都需要和主存打交道则会大大影响效率,所以就有了高速缓存。有了高速缓存会带来数据一致性问题,不同核心的缓存和主存值在某一瞬间值不一致。具体在java内存模型中,有main memory,每个线程也有自己的工作区,java内存模型规定,一个线程要在自己的工作区中保存要访问的变量的副本,之后线程直接修改副本变量的值,在修改完之后,把线程变量副本的值写回到对象在堆中变量,而不能直接修改主内存的变量,这就可能导致同一个变量在某个瞬间,一个线程的memory中的值可能与另一个线程memory或者main memory中的值不一致 。为了解决缓存一致性,CPU制造商制定了一个规则:当一个CPU修改缓存中的字节时,其他CPU会被通知,它们的缓存将视为无效


volatile到底做了什么?


      -server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*LazySingleton.getInstance

查看生成的汇编指令:


对于volatile修饰的变量在生成的汇编指令前有#lock前缀,#lock前缀是用来干什么的呢?

1. 锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线,因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存

2 不是内存屏障却能完成类似内存屏障的功能,阻止指令重排序

 锁总线中由于效率问题,后来的处理器都通过缓存一致性协议来保证多缓存的数据一致性。 这类协议就是要使多组缓存的内容保持一致。

缓存一致性协议

CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器一写内存,其它处理器马上知道这块内存在它们的缓存段中已失效。

处理器上有一套完整的协议,来保证Cache一致性。比较经典的Cache一致性协议当属MESI协议,奔腾处理器有使用它,很多其他的处理器都是使用它的变种。

单核Cache中每个Cache line有2个标志:dirty和valid标志,它们很好的描述了Cache和Memory(内存)之间的数据关系(数据是否有效,数据是否被修改),而在多核处理器中,多个核会共享一些数据,MESI协议就包含了描述共享的状态。

         在MESI协议中,每个Cache line有4个状态,可用2个bit表示,它们分别是:

 M(Modified)和E(Exclusive)状态的Cache line,数据是独有的,不同点在于M状态的数据是dirty的(和内存的不一致),E状态的数据是clean的(和内存的一致)。


       只有Core 0访问变量x,它的Cache line状态为E(Exclusive)。


         3个Core都访问变量x,它们对应的Cache line为S(Shared)状态。


   Core 0修改了x的值之后,这个Cache line变成了M(Modified)状态,其他Core对应的Cache line变成了I(Invalid)状态。

在MESI协议中,每个Cache的Cache控制器不仅知道自己的读写操作,而且也监听(snoop)其它Cache的读写操作。每个Cache line所处的状态根据本核和其它核的读写操作在4个状态间进行迁移。

一个缓存除在Invalid状态外都可以满足cpu的读请求,一个invalid的缓存行必须从主存中读取(变成S或者 E状态)来满足该CPU的读请求。

一个写请求只有在该缓存行是M或者E状态时才能被执行,如果缓存行处于S状态,必须先将其它缓存中该缓存行变成Invalid状态(也即是不允许不同CPU同时修改同一缓存行,即使修改该缓存行中不同位置的数据也不允许)。该操作经常作用广播的方式来完成,例如:Request For Ownership (RFO)

缓存可以随时将一个非M状态的缓存行作废,或者变成Invalid状态,而一个M状态的缓存行必须先被写回主存。

一个处于M状态的缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S状态之前被延迟执行。

一个处于S状态的缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。

一个处于E状态的缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S状态。

对于M和E状态而言总是精确的,他们在和该缓存行的真正状态是一致的。而S状态可能是非一致的,如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。

从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。

只有当缓存行处于E或者M状态时,处理器才能去写它,也就是说只有在这两种状态下,处理器是独占这个缓存行的。当处理器想写某个缓存行时,如果它没有独占权,它必须先发送一条"我要独占权"的请求给总线,这会通知其它处理器把它们拥有的同一缓存段的拷贝失效(如果有)。只有在获得独占权后,处理器才能开始修改数据----并且此时这个处理器知道,这个缓存行只有一份拷贝,在我自己的缓存里,所以不会有任何冲突(其他核的缓存行都被置为无效了)。反之,如果有其它处理器想读取这个缓存行(马上能知道,因为一直在嗅探总线),独占或已修改的缓存行必须先回到"共享"状态。如果是已修改的缓存行,那么还要先把内容回写到内存中。

以上是从硬件层面来解释,java提供的volatile关键字是对这些硬件底层细节的封装,下面从java内存模型角度来看看jmm希望volatile达到的效果。



           java内存模型把一个线程可以执行的动作有使用(use),赋值assign,装载load、存储store、锁定lock、解锁unlock,而主内存执行的动作有read,write、lock、unlock,每一个这样的动作都是原子的。使用和赋值操作是线程的执行引擎和线程的工作存储之间紧密耦合(一步就可以完成的)的交互过程,但主内存和线程的工作存储间的数据传送是松散耦合的,当数据从主内存复制到工作存储,必须出现两个动作:由主内存执行的read操作,一段时间后由线程执行的相应load动作到线程的工作存储。反之,由线程执行store操作,一段时间后由主内存执行相应的write动作完成线程的工作存储到主内存的数据复制。在主内存和线程的工作存储间传送数据需要一定的传送时间,并且每次传送的时间可能是不同的,因此,在另一个线程看来,线程对不同变量所执行的动作可能是按照不同的顺序(相对程序代码语义顺序)执行的,(比如线程内的程序代码是先给变量a赋值,再给b赋值,而在另一个线程却可能先看到主内存中b变量的更新,再看到a变量的更新)。

每种操作的详细定义

           线程的use动作把一个变量的线程工作拷贝的内容传送给线程执行引擎。每当线程执行一个用到变量的值的虚拟机指令时执行这个动作。

            线程的assign动作把一个值从线程执行引擎传送到变量的线程工作拷贝。每当线程执行一个给变量赋值的虚拟机指令时执行这个动作。

          主内存的read动作把一个变量的主内存拷贝的内容传输到线程的工作内存以便后面的load动作使用。

          线程的load动作把read动作从主内存中得到的值放入变量的线程工作拷贝中。

          线程的store动作把一个变量的线程工作拷贝内容传送到主内存中以便后面的write动作使用。

           主内存的write动作把store动作从线程工作内存中得到的值放入主内存中一个变量的主拷贝。

           和主内存紧密同步的线程的lock动作使线程获得一个独占锁定的声明。

           和主内存紧密同步的线程的unlock动作使线程释放一个独占锁定的声明。

          这样,线程和变量的相互作用由use、assign、load和store动作的序列组成。主内存为每个load动作执行read动作,为每个Store动作执行write动作。线程的锁定的相互作用由lock或unlock动作顺序组成。

           变量规则: 不允许一个线程丢弃它最近的assign操作;不允许一个线程无原因地把数据从线程的工作存储写回到主内存,一个个新的变量只能在主内存中产生并且不能在任何线程的工作内存中初始化。假如动作a是线程t对变量v执行的load或store操作,动作p是主内存对变量v执行的相应read和write动作,类似地,假设动作b是线程t对同一个变量v执行的另外load或者store动作,动作q是主内存对变量v执行的相应read或者write动作,如果a先于b,必须有p先于q,(也即主内存执行对给定的一个变量的主拷贝动作必须遵循线程执行时要求的先后顺序,注意,这条规则只适用于一个线程对于同一个变量不同动作的情况,是针对单线程提出的。对于volatile 类型的变量有更严格的规则)

volatile变量

          volatile类型变量规则:对于volatile变量,每个线程对该变量实施的动作有以下附加的规则,假定t表示一个线程,v,w表示volatile变量,

         1: 只有当线程t对变量v执行的前一个动作是load的时候,线程t才能对变量v执行use操作,并且,只有当线程t对变量v执行的后一个动作是use的时候,线程t才能对v执行load操作,这样就保证了其它线程对变量 v修改后,线程使用时必须先去主内存加载。

         2:只有当线程t对变量v执行的前一个动作是assign时候,线程t才能对变量v执行store操作,并且只有当线程t对变量v执行的后一个动作是store时,线程t才能对v执行assign动作,这样保证修改后会立即写会主内存

         3: 假定动作a是线程t对变量v实施的use或assign动作,假定动作f是和动作a相关联的load或store动作,假定动作p是和动作f相对应的对变量v的read或write动作,类似的,动作b是线程t对变量w实施的use或assign动作,动作g是和动作b相关联的load或store动作,假定动作q是和动作g相应的对变量w的read和write动作。如果a先于b,那么p先于q。也即对变量v、w写会主内存的顺序与程序代码对v、w赋值先后顺序一致。

       以上规则保证了,对于申明为volatile的变量的每个use或assign动作都要访问主内存一次(这应该是达到这种效果,并不会每次都会去访问主内存,多核硬件架构下硬件去监听其他核对共享变量的修改,然后对修改的变量,重新去读主内存),并且按照线程的执行语义所指定的次序访问主内存。

volatile关键字规则:

       1: 在write volatile字段之后,其值会被flush出处理器cache,写回memory

       2: 在read volatile变量之前,会验证工作内存是否有效

       3: 禁止reorder(重排序,即与原程序指定的顺序不一致)任意两个volatile变量,并且同时严格限制reorder volatile变量周围的非volatile变量。这一点即volatile具有变量的“顺序性”,即指令不会重新排序,而是按照程序指定的顺序执行。

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

      2: 处理器重排序。如果不存在数据依赖,处理器可以改变语句对应机器指令的执行顺序。

       重排序不会影响单线程的运行结果,但对多线程会有影响。在jvm底层volatile是采用内存屏障来实现的有序性的。观察volatile关键字所生成的汇编代码发现,加入volatile后多处了一个lock前缀指令,lock前缀指令实际上就相当于一个内存屏障,内存屏障是一组处理指令,用来实现对内存操作的顺序限制。

        一个变量申明为volatile,当一个线程修改共享变量后,它会立即被更新到主内存中,系统会通知其他线程本地该变量的缓存无效(硬件自己实现的),读取时需要从主内存重新读取,如果一个使用动作序列已经读取了就不会再重新读取,重新读取时针对一个新的使用序列。


例子讲解

       上面结果输出有可能是a=1,b=4,可能发生了重排序,使得b=4在a=3前面执行,如果是volatile变量,则会以程序语义执行的顺序写回主内存。

           如果pleasestop没有被声明为volatile,线程执行run的时候检查的是自己的副本,而不能及时得到其它线程已经调用tellmetostop()修改了pleasestop的值。volatile关键字保证了变量在被修改时,其它线程能够立刻看到这个更新。


     在上面程序,如果一个线程已调用完构造器,new 先分配地址,但something的实例域还没被赋值,这是对象还没有被完全初始化好,如果存在重排序,会先赋值给instance引用,这样另一个线程拿到instance所指向的对象其实是不完整的,在使用时会存在问题。使用volatile修饰后,就不会存在重排序的情况,这样就会确保将实例域的数据写回到主内存的动作在将实例赋值给instance引用动作之前发生(即volatile的 happens-before 规则),所以这样就确保了在使用前对象已完全初始化完成。

       在新的内存模型下,线程可以在不使用同步的情况下看到该对象在构造器里设置的final域。在构造期间,不要公布this引用!


        能够确保另一调用reader的线程它能看到f.x的值,因为f.x是final的。但不能保证它看到的f.y是4,如果是引用类型,也会有这样的保障,在拿到final引用类型前,这个引用所指向的对象的所有域是完全初始化完成。

彩蛋

大家有没有想过既然有了mesi协议为什么还需要volatile关键字 ?

一:

1. volatile和MESI差着好几层抽象,中间会经历java编译器,java虚拟机和JIT,操作系统,CPU核心。

volatile在Java中的意图是保证变量的可见性。为了实现这个功能,必须保证1)编译器不能乱序优化;2)指令执行在CPU上要保证读写的fence。

对于x86的体系结构,voltile变量的访问代码会被java编译器生成不乱序的,带有lock指令前缀的机器码。而lock的实现还要区分,这个数据在不在CPU核心的专有缓存中(一般是指L1/L2)。如果在,MESI才有用武之地。如果不满足就会要用其他手段。而这些手段是虚拟机开发者,以及操作系统开发者需要考虑的问题。简而言之,CPU里的缓存,buffer,queue有很多种。MESI只能在一种情况下解决核心专有Cache之间不一致的问题。

此外,如果有些CPU不支持MESI协议,那么必须用其他办法来实现等价的效果,比如总是用锁总线的方式,或者明确的fence指令来保证volatile想达到的目标。

如果CPU是单核心的,cache是专供这个核心的,MESI理论上也就没有用了。但是依然要考虑主存和Cache被多个线程切换访问时带来的不一致问题。

总之,volatile是一个高层的表达意图的“抽象”,而MESI是为了实现这个抽象,在某种特定情况下需要使用的一个实现细节。

你可以把JSR-133看作是一套UT的规范。不管底下CPU/编译器怎么折腾,只要voltile修饰的变量满足JSR-133所描述的所有场景,就算是一个好的java实现。而基于这个规范,java开发人员才能安心的开发并发代码,而不至于被底层细节搞疯。

二:

首先,volatile是java语言层面给出的保证,MSEI协议是多核cpu保证cache一致性的一种方法,中间隔的还很远,我们可以先来做几个假设:

回到远古时候,那个时候cpu只有单核,或者是多核但是保证sequence consistency,当然也无所谓有没有MESI协议了。那这个时候,我们需要java语言层面的volatile的支持吗?当然是需要的,因为在语言层面编译器和虚拟机为了做性能优化,可能会存在指令重排的可能,而volatile给我们提供了一种能力,我们可以告诉编译器,什么可以重排,什么不可以。

那好,假设更进一步,假设java语言层面不会对指令做任何的优化重排,那在多核cpu的场景下,我们还需要volatile关键字吗?答案仍然是需要的。因为 MESI只是保证了多核cpu的独占cache之间的一致性,但是cpu的并不是直接把数据写入L1 cache的,中间还可能有store buffer。有些arm和power架构的cpu还可能有load buffer或者invalid queue等等。因此,有MESI协议远远不够。

3. 好的,到了现在这步,我们再来做最后一个假设,假设cpu写cache都是按照指令顺序fifo写的,那现在可以抛弃volatile了吧?你觉得呢?我都写到标题4了,那肯定不行啊!因为对于arm和power这个weak consistency的架构的cpu来说,它们只会保证指令之间有比如控制依赖,数据依赖,地址依赖等等依赖关系的指令间提交的先后顺序,而对于完全没有依赖关系的指令,比如x=1;y=2,它们是不会保证执行提交的顺序的,除非你使用了volatile,java把volatile编译成arm和power能够识别的barrier指令,这个时候才是按顺序的。

高级语言提供关键字,是为了屏蔽底层硬件和内核的不一致性,x86平台和其他cpu 对内存一致性的保证有不同的程度,语言能封装这种差异,方便了上层开发者

你可能感兴趣的:(java volatile关键字)