Java内存模型与线程

一、内存一致性模型

内存模型可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象

内存一致性模型(Memory Consistency Model)是用来描述多线程对共享存储器的访问行为,在不同的内存一致性模型里,多线程对共享存储器的访问行为有非常大的差别。这些差别会严重影响程序的执行逻辑,甚至会造成软件逻辑问题。

不同架构的物理机器可以拥有不一样的内存模型,而Java虚拟机也有自己的内存模型,并且与这里介绍的内存访问操作及硬件的缓存访问操作具有高度的可类比性。

不同的处理器架构,使用了不同的内存一致性模型,目前有多种内存一致性模型,从上到下模型的限制由强到弱:

  • 顺序一致性(Sequential Consistency)模型
  • 完全存储定序(Total Store Order)模型
  • 部分存储定序(Part Store Order)模型
  • 宽松存储(Relax Memory Order)模型

除了增加高速缓存之外,为了使处理器内部的运算单元能被充分利用,处理器可能会对输出的代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致(单线程没问题,多线程之间有数据共享就会出现问题)。Java虚拟机的即时编译器也会有指令重排的优化。

1.1 MESI协议

MESI协议是指四个状态的首字母,每个缓存行(每个线程的共享变量在缓存中的存储位置)有四个状态,可以用两个比特表示。

  • 处于“Modified”状态的缓存行:当前CPU已经对缓存行的数据进行了修改,但是该缓存行的内容并没有在其它CPU的缓存中出现。因此,处于该状态的缓存行可以认为被当前CPU所“拥有”。这就是所谓的“脏”行,它的内容和内存中的内容不一样。由于只有当前CPU的缓存持有最新的数据,因此要么将“脏”数据写回到内存,要么将该数据“转移”给其它缓存。
  • 处于“Exclusive”状态的缓存行:该状态非常类似于“Modified”状态,缓存的内容确保没有在其它CPU的缓存中出现。唯一的差别是,该缓存行还没有被当前的CPU修改,也就是说缓存行内容和内存中的是一样,是对内存数据的最新复制。但是,由于当前CPU能够在任何时刻将数据存储到该缓存行而不考虑其它CPU,因此处于“Exclusive”状态的缓存行也可以认为被当前CPU所“拥有”。
  • 处于“Shared”状态的缓存行:表示缓存行的数据和主存中的一样,并且可能已经被复制到至少一个其它CPU的缓存中。但是,在没有得到其他CPU“许可”的情况下,任何CPU不能向该缓存行存储数据。与“Exclusive”状态相同,由于内存中的值是最新的,因此当需要丢弃该缓存行时,可以不用向内存回写。
  • 处于“Invalid”状态的缓存行:表示该缓存行已经失效了,不能再被继续使用了。当有新数据进入缓存时,它可以直接放置到一个处于“Invalid”状态的缓存行上,不需要做其它的任何处理。

状态转变过程:

为了维护这个状态机,需要各个CPU之间进行通信,会引入下面几种类型的消息:

  • 读消息:该消息包含要读取的缓存行的物理地址。
  • 读响应消息:该消息包含较早前的读消息所请求的数据,这个读响应消息要么由物理内存提供,要么由某一个其它CPU上的缓存提供。例如,如果某一个CPU上的缓存拥有处于“Modified”状态的目标数据,那么该CPU上的缓存必须提供读响应消息。
  • 使无效消息:该消息包含要使无效的缓存行的物理地址,所有其它CPU上的缓存必须移除相应的数据并且响应此消息。
  • 使无效应答消息:一个接收到使无效消息的CPU必须在移除指定数据后响应一个使无效应答消息。
  • 读使无效消息:该消息包含要被读取的缓存行的物理地址,同时指示其它CPU上的缓存移除对应的数据。因此,正如名字所示,它将读消息和使无效消息合并成了一条消息。读使无效消息同时需要一个读响应消息及一组使无效应答消息进行应答。
  • 写回消息:该包含要回写到物理内存的地址和数据。这个消息允许缓存在必要时换出处于“Modified”状态的数据,以便为其它数据腾出空间。

1.2 MESI优化

MESI缓存一致性协议可以保证系统中的各个CPU核上的缓存都是一致的。但是也带来了一个很大的问题,由于所有的操作都是“同步”的必须要等待远端CPU完成指定操作后收到响应消息才能真正执行对应的存储或加载操作,这样会极大降低系统的性能。比如说,如果CPU0和CPU1上同时缓存了同一段数据,如果CPU0想对其进行更改,那么必须先发送使无效消息给CPU1,等到CPU1真的将该缓存的数据段标记成“Invalid”状态后,会向CPU0发送使无效应答消息,理论上只有CPU0收到这个消息后,才可以真的更改数据。但是,从要更改到真的能更改已经经过了好几个阶段了,这时CPU0只能等在那里。

为了提高性能,具体会引入如下两个模块:存储缓冲无效队列

1.2.1 存储缓冲

在写数据之前我们先要得到缓存段的独占权,如果当前CPU没有独占权(数据有多个缓存),要先让系统中别的CPU上缓存的同一段数据都变成无效状态。为了提高性能,可以引入一个叫做存储缓冲(Store Buffer)的模块,将其放置在每个CPU和它的缓存之间。

当前CPU发起写操作,如果发现没有独占权,可以先将要写入的数据放在存储缓冲(每个CPU独享)中,并继续运行,仿佛独占权瞬间就得到了一样。当然,存储缓冲中的数据最后还是会被同步到缓存中的(当收到所有有此变量缓存的CPU的使无效应答消息),但就相当于是异步执行了,不会让CPU等了。并且,当前CPU在读取数据的时候应该首先检查其是否存在于存储缓冲中。

Store Forwarding优化:

当CPU0暂存数据更新到StoreBuffer0中后,如果后面有对该数据行的读取,则不会去读Cache0中的未更新的缓存行数据,而是去读StoreBuffer0中的缓存行数据。这就是Store Forwarding优化。

1.2.2 无效队列

无效队列:如果当前CPU上收到一条消息,要使某个缓存段失效,但是此时缓存正在处理其它事情,那这个消息可能无法在当前的指令周期中得到处理,而会将其放入所谓的无效队列(Invalidation Queue)中,同时立即发送使无效应答消息。那个待处理的使无效消息将保存在队列中,直到缓存行有空为止。

加入了这两个模块之后,CPU的性能是提高了,但缓存一致性就遭到了一定程度的破坏。假设变量X所在内存同时被两个CPU都缓存了,但是这时候CPU0对变量X的值做出了修改,这之后CPU1如果试图读取变量X的值时,有可能读到的是老的值(CPU0将值更新好了,然而CPU1的失效队列还没有执行,相应缓存行还没有被置为无效,会读取老的值),当然也有可能读到的是新的值。但是,在经过一段不确定的时间后,CPU1一定是可以读到变量X新的值,可以理解为满足所谓的最终一致性。

1.3 MESI优化带来的问题

假设A变量和B变量在CPU0和CPU1中的缓存都存在,也就是处于“Shared”状态,而C变量是CPU0独占的,也就是处于“Exclusive”状态。假设系统经历了如下几个步骤:

  1. 在对变量A和B赋值时,CPU0发现其实别的CPU也缓存了,因此会将它们临时放到存储缓冲中。
  2. 在对变量C赋值时,CPU0发现是独占的,那么可以直接修改缓存的值(会引起后续写顺序的改变),该缓存行的状态被切换成了“Modified”。注意,这个时候,如果在CPU1上执行了读取变量C的操作,其实已经可以读到变量C的最新值了,CPU1发送读消息,CPU0发送读响应消息,包含最新的数据,同时将缓存行的状态都切换成“Shared”。但是,如果这个时候如果CPU1尝试读取变量A或者变量B的数据,将会获得老的数据(A,B缓存行原本是S状态,CPU0要修改A,B,首先要发向CPU1发送A,B失效消息,接着将修改写入存储缓冲,然后收到应答之后,CPU0中的A,B变为E状态,再进行缓存和内存修改。但CPU1收到失效消息之后,可能放入失效队列直接返回失效响应消息,所以CPU1的A,B缓存行还是S状态,读取A,B可能还会读到旧的值
  3. CPU0开始处理对应变量A和B的存储缓冲,将它们更新进缓存,但之前必须要向CPU1发送使无效消息。这里再次假设变量A的缓存正忙,而变量B的可以立即处理。那么变量A的使无效消息将存放在CPU1的无效队列中,而变量B的缓存行已经失效。这时,如果CPU1尝试获得变量B,是可以获得最新的数据的,而变量A还是不行。
  4. CPU1对应变量A的缓存已经空闲了,可以处理当前无效队列的请求,因此变量A对应的缓存行将失效。直到这时CPU1才可以真正的读到变量A的最新值。

通过以上的步骤可以看到,虽然在CPU0上是先对变量A赋值,接着对B赋值,最后对C赋值,但是在CPU1上“看到”的顺序刚好是相反的,先“看到”C,接着“看到”B,最后看到“C”。在CPU1上会产生一种错觉,方式CPU0是先对C赋值,再对B赋值,最后对A赋值一样。这种由于缓存同步顺序的问题,让程序看起来好像指令被重排序了的情况,称作“伪”重排序。
 

1.4 什么是重排序

重排序并没有严格的定义。整体上可以分为两种:

  • 真·重排序:编译器、底层硬件(CPU等)出于“优化”的目的,按照某种规则将指令重新排序(尽管有时候看起来像乱序)。
  • 伪·重排序:由于缓存同步顺序等问题,看起来指令被重排序了(导致可见性问题)。

重排序也是单核时代非常优秀的优化手段,有足够多的措施保证其在单核下的正确性。在多核时代,如果工作线程之间不共享数据或仅共享不可变数据,重排序也是性能优化的利器。然而,如果工作线程之间共享了可变数据,由于两种重排序的结果都不是固定的,会导致工作线程似乎表现出了随机行为。

1.5 缓存行伪共享

目前主流的CPU缓存行大小默认64字节,多线程下,假设一个缓存行如果存放了两个共享变量a,b

  • 线程1会访问共享变量a,线程2会访问共享变量b。
  • 由于缓存刷新是以缓存行为基本单位的,所以当线程1修改共享变量a会导致其他线程对应的缓存行失效,则线程2虽然不访问共享变量a,但因为共享变量b与共享变量a在同一个缓存行里,则导致线程2的缓存行也失效,则它需要重新从内存读取共享变量b

 Java如何解决缓存行伪共享

  • java8中增加了一个主键@sun.misc.Contended,加上这个主键,自动补齐缓存行,使得单个共享变量独享一个缓存行。
  • 该注解默认无效,需要在jvm启动时设置-XX:-RestrictContended

1.6 总结

由于编译器优化引起的是静态的,是由编译器决定的,一旦程序编译完成就定下来了。而其它剩下的场景都是动态的,在处理器执行的时候,根据当前系统状态动态的调整。并且不同的架构的处理器提供不同级别的数据一致性保证,这也称作所谓的内存模型。有的平台提供很强的保证(比如X86),有的比较弱(比如Arm)。

由于缓存同步顺序引入的乱序问题称作“伪”重排序,就是说即使某个CPU按照代码的次序执行了程序更新了数据,但是在其它CPU看来,看到数据的次序和代码执行的次序也不一样。而其它剩下的情况都是所谓的“真”重排序,也就是说代码执行的顺序确实是和程序中的顺序不一样。不管是“真”重排序还是“伪”重排序,对于系统中其它的CPU来说,叠加后产生的影响是一样的,都是看到数据的次序和代码执行的次序不一样。这其实可以分解成三个问题:

  1. 执行指令的CPU不是按照指令执行的次序修改内存的(由于“真”重排序);
  2. 修改内存的操作不是按照实际修改的顺序被别的CPU感知的(由于缓存一致性问题而引入的“伪”重排序);
  3. 别的CPU不是按照指令执行的次序来感知内存更改的(还是由于“真”重排序);

而对于第二个问题,其实又可以分解成两个问题:

  • 2a)修改内存的操作不是按照实际修改的顺序被“提交”给缓存系统的(由于有存储缓冲的存在)。比如上述1.3中的例子,独享变量C会先于共享变量A的修改,共享变量的操作会在等待其他的CPU回应时放入存储缓冲;
  • 2b)别的CPU不能按照“提交”给缓存系统的次序感知内存的更改(由于有无效队列的存在),比如上述1.3中的例子,B缓存行会立即执行失效操作,而A缓存行会延迟执行缓存行失效的操作,所以对数据的感知顺序会发生变化。

因此,对于修改数据的CPU0来说,由于有问题1和2a的存在,可以总结为其提交给内存和缓存系统的写数据并不是按照其代码的顺序执行的;而对于感知数据更改的CPU1来说,由于有问题2b和3来说,也可以总结为其不是按照代码的顺序感知的。

二、内存屏障

对于内存的访问,我们只关心两种类型的指令的顺序,一种是读取,一种是写入。对于读取和加载指令来说,它们两两一起,一共有四种组合:

  1. LoadLoad:前一条指令是读取,后一条指令也是读取。
  2. LoadStore:前一条指令是读取,后一条指令是写入。
  3. StoreLoad:前一条指令是写入,后一条指令是读取。
  4. StoreStore:前一条指令是写入,后一条指令也是写入。

2.1 内存一致性模型的具体介绍:

顺序一致性(Sequential Consistency)模型:顺序存储模型是最简单的存储模型,也称为强定序模型。CPU会按照代码来执行所有的读取与写入指令,即按照它们在程序中出现的次序来执行。同时,从主存储器和系统中其它CPU的角度来看,感知到数据变化的顺序也完全是按照指令执行的次序。也可以理解为,在程序看来,CPU不会对指令进行任何重排序的操作。在这种模型下执行的程序是完全不需要内存屏障的。但是,带来的问题就是性能会比较差,现在已经没有符合这种内存一致性模型的系统了。

完全存储定序(Total Store Order)模型:这种内存一致性模型允许对StoreLoad指令组合进行重排序。如果第一条指令是写入,第二条指令是读取,那么有可能在程序看来,读取指令先于写入指令执行。但是,对于其它另外三种指令组合还是可以保证按照顺序执行。这种模型就相当于前面提到的,在CPU和缓存中间加入了存储缓冲,而且这个缓冲还是一个满足先入先出(FIFO)的队列。先入先出队列就保证了对StoreStore这种指令组合也能保证按照顺序被感知。我们非常熟悉的X86架构就是使用的这种内存一致性模型。

部分存储定序模型:这种内存一致性模型除了允许对StoreLoad指令组合进行重排序外,还允许对StoreStore指令组合进行重排序。但是,对于其它另外两种指令组合还是可以保证按照顺序执行。这种模型就相当于也在CPU和缓存中间加入了存储缓冲,但是这个缓冲不是先入先出的

宽松存储模型:这种内存一致性模型允许对上面说的四种指令组合都进行重排序。这种模型就相当于前面说的,既有存储缓冲,又有无效队列的情况。

2.2 屏障的种类

写内存屏障

一个写内存屏障可以提供这样的保证,站在系统中的其它组件的角度来看,在屏障之前的写操作看起来将在屏障后的写操作之前发生。

如果映射到上面的例子来说,首先,写内存屏障会对处理器指令重排序做出一些限制,也就是在写内存屏障之前的写入指令一定不会被重排序到写内存屏障之后的写入指令之后。其次,在执行写内存屏障之后的写入指令之前,一定要保证清空当前CPU存储缓冲中的所有写操作,将它们全部“提交”到缓存中。这样的话系统中的其它组件(包括别的CPU),就可以保证在看到写内存屏障之后的写入数据之前先看到写内存屏障之前的写入数据。

写内存屏障仅仅限制了CPU对写操作的排序,对加载操作没有任何效果,对其它的指令也没有作用。而且,写内存屏障只是保证在写内存屏障之后的写入操作一定是在写内存屏障之前的写入操作之后被系统其它组件感知,它并不能保证在写内存屏障之前的所有写入操作的顺序,也不能保证在写内存屏障之后的所有写入操作的顺序

写内存屏障只管自己CPU上的写入操作能够按照一定次序被系统中其它部件感知,但是如果其它部件有缓存将旧数据缓存下来了,这它管不着。这个是下面介绍的读内存屏障要管的事,因此一般写内存屏障要和读内存屏障配对使用。
 

读内存屏障

一个读内存屏障可以提供这样的保证,站在系统中其它组件的角度来看,所有在读内存屏障之前的加载操作将在读内存屏障之后的加载操作之前发生。

还是用上面的例子来说明,首先,读内存屏障也会对处理器指令重排做出一些限制,也就是在读内存屏障之前的读取指令一定不会被重排序到读内存屏障之后的读取指令之后。其次,在执行读内存屏障之后的读取指令之前,一定要保证处理完当前CPU的无效队列。这样的话,当前CPU的缓存状态将完全遵照MESI协议,可以保证缓存数据一致性。

读内存屏障仅仅限制了CPU对加载操作的排序,对存储操作没有任何效果,对其它指令也没有任何作用。而且,读内存屏障只是保证在读内存屏障之后的读取操作一定是在读内存屏障之前的读取操作之后才去感知内存数据变化的,它并不能保证读内存屏障之前的所有读取操作顺序,也不能保证读内存屏障之后的所有读取操作的顺序。

读内存屏障只管自己CPU上的读取操作能够按照一定次序去感知系统内存中的值,但是对于其它CPU写入系统内存的次序没有任何约束。这个是上面介绍的写内存屏障要管的事,因此一般读内存屏障要和写内存屏障配对使用。

通用内存屏障(读写内存屏障)

一个通用内存屏障可以提供这样的保证,站在系统中其它组件的角度来看,通用内存屏障之前的加载、存储操作都将在通用内存屏障之后的加载、存储操作之前发生

还是用上面的例子来说明,首先,通用内存屏障也会对处理器指令重排做出一些限制,也就是在通用内存屏障之前的写入和读取指令一定不会被重排序到通用内存屏障之后的写入和读取指令之后。其次,在执行通用内存屏障之后的任何写入和读取取指令之前,一定要保证清空当前CPU存储缓冲中的所有写操作,并且还要处理完当前CPU的无效队列

通用内存屏障等同于同时包含了读和写内存屏障的功能,因此也可以替换它们中的任何一个,只不过可能会一定程度上影响性能。

通用内存屏障同时限制了CPU对加载操作和存储操作的排序,但是对其它指令没有任何作用。而且,通用内存屏障只是保证在通用内存屏障之后的所有写入和读取操作一定是在通用内存屏障之前的写入和读取操作之后才执行,它并不能保证通用内存屏障之前的所有读取和写入操作的顺序,也不能保证通用内存屏障之后的所有读取和写入操作的顺序。

一般写内存屏障、读内存屏障和通用内存屏障都会默认包含编译屏障。


2.3 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的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

根据JMM规则,结合内存屏障的相关分析,可以得出以下保守策略: 
- volatile读之后,会添加LoadLoad内存屏障。 
- volatile读之后,会添加LoadStore内存屏障。 
- volatile写之前,会添加StoreStore内存屏障。 
- volatile写之后,会添加StoreLoad型内存屏障

3、Java内存模型

Java内存模型(JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果

定义Java内存模型必须足够严谨,让Java的并发内存访问不会有歧义;但是也要定义的足够宽松,使得虚拟机的实现能有足够的空间去利用硬件的各种特性(寄存器、高速缓存和指令集中的特有指令)来获取更好的执行速度。

3.1 主内存和工作内存

Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注再虚拟机中把变量值存储到内存和从内存中读取出变量值这样的底层细节。此处的变量与Java编程中所说的变量有所区别,它包括了实例字段静态字段构成数组对象的元素,但不包括局部变量方法参数,因为这两个是线程私有的,不会被共享,自然不会产生竞争问题。

Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存交互,也没有限制即时编译器是否要进行调整代码执行顺序(指令重排)这类优化策略。

Java内存模型规定了所有变量都存储在主内存中。每条线程还有自己的工作内存(高速缓存中或者寄存器中),线程的工作内存保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)都必须在工作内存中进行,而不能直接读写主内存的数据。不同的线程之间也无法直接访问对方工作内存的变量,线程间变量值的传递均需要通过主内存来完成。

为了获取更好的运行速度,虚拟机(操作系统优化策略或者硬件)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

3.2 内存间的交互操作

Java内存模型定义了以下8种操作来完成主内存和工作内存之间的交互。Java虚拟机实现时必须保证下面的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台是时允许有例外的)。

lock(锁定):作用于主内存的变量,它把一个变量便是为一条线程独占的状态。

unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才能被其他线程锁定。

read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便于随后的load动作使用。

load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

user(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时执行这个操作。

assign(赋值):作用于工作内存的变量,它把一个从执行引擎接受的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值字节码指令时执行这个操作。

store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

write(写入):作用域主内存的变量,它把store操作从工作内存得到的变量的值放入到主内存的变量中。

如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read、load操作。

如果要把一个变量从工作内存同步到主内存,那就要按顺序执行store、write操作。

按顺序执行,但不要求连续执行。即read和load之间、store和write之间可以插入其他操作。

除此之外,Java内存模型还规定了上述8种基本操作必须满足如下规则:

1、不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受或者从工作内存发起回写了但主内存不接受的情况出现

2、不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。(对于普通变量,其他线程还是会从工作内存中读取旧的值,直到再次去主内存读取)

3、‘不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。

4、一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说,就是对一个变量实施use、store操作之前,必须先执行过了assign和load操作。

5、一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。

6、如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。(重新从主内存读取变量,保证最新的值)

7、如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。

8、对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

3.3 volatile

Java内存模型为volatile专门定义了一些特殊的访问规则,当一个变量被定义为volatile之后,它将具备两项特性。其实在底层volatile的读写流程和上述的普通变量的读写流程在内存一致性模型中是相同的,只不过会在volatile变量的前后会加入内存屏障,保证了下面的两种特性:

3.3.1 可见性

第一项是保证此变量对象所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递需要通过主内存来完成。比如,线程A修改了一个普通变量的值,它会再回写到主内存中(上述规则第二条)。然而,线程B想要读取到最新的值必须重新从主内存中读取,如果还是从工作内存中读取,则读取不到新的值。

然而,虽然volatile可以保证共享变量的可见性,但保证不了并发下共享变量运算的线程安全。由于Java里的运算操作符并非原子操作,导致volatile变量再并发下一样是不安全的。在不符合以下两条规则的运算场景中,我们仍然需要通过加锁来保证原子性:

1、运算结果并不依赖变量的当前值,或者能保证只有单一线程修改变量值。

2、变量不需要与其他状态变量共同参与不变约束。

3.3.2 禁止重排序优化

第二项是禁止指令重排序优化。 对于普通变量仅能保证在方法的执行过程中,所有依赖赋值结果的地方都能读取到正确的结果,而不能保证变量赋值的顺序与代码中的执行顺序一致(根据数据依赖性,即重排序之后和原先代码顺序执行的结果相同,但指令执行顺序可能不同)。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。然而,重排序在多线程场景下就会出现问题,最典型的就是DCL问题。

3.4 volatile实现原理

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的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

JMM针对编译器制定了volatile重排序规则表

是否能重排第二个操作
第一个操作普通读/写volatile读volatile写
普通读/写NO
volatile读NONONO
volatile写NONO

- 在第一个操作为volatile读的时候,其后的所有变量读写操作都不会重排序到前面(这样加屏障的时候就不用再volatile之前加屏障了)。 
- 在第二个操作为volatile读的时候,其之前的所有volatile读写操作都已完成, 
- 在第一个操作为volatile写的时候,其后的volatile变量读写操作都不会重排序到前面。 
- 在第二个操作为volatile写的时候,其之前的所有变量的读写操作都已完成。

举例来说,第三行最后一个单元格的意思是:当第一个操作为普通变量的读或写,第二个操作为volatile变量的写,则编译器不能重排序。

当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。

当第一个操作是volatile读时,不管第一个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile之前。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来 禁止特定类型的处理器重排序。根据JMM规则,结合内存屏障的相关分析,可以得出以下保守策略: 
  • volatile读之后,会添加LoadLoad内存屏障。 
  • volatile读之后,会添加LoadStore内存屏障。 

LoadLoad 屏障用来禁止 理器把上面的 volatile 与下面的普通 重排序。LoadStore屏障用来禁止 理器把上面的 volatile 与下面的普通写重排序。上述volatile 写和 volatile 读的内存屏障插入策略非常保守。
  • volatile写之前,会添加StoreStore内存屏障。 
  • volatile写之后,会添加StoreLoad型内存屏障。 

StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。这里比较有意思的是,volatile写后面的StoreLoad屏障。此屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM最终选择了在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个 写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时, 选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率。
在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

为什么有了内存一致性模型,还需要volatile?

内存一致性模型-->MESI保证缓存一致性-->使用存储缓冲和失效队列优化MESI,提升处理速度-->引起指令伪重排、缓存一致性失效等问题-->其他线程对共享变量读写感知顺序产生错误-->内存读写屏障解决此问题-->java层面volatile基于读写屏障。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值