volatile 和 内存屏障

接下来看看volatile是如何解决上面两个问题的:
被volatile修饰的变量在编译成字节码文件时会多个lock指令,该指令在执行过程当中会生成相应的 内存屏障,以此来解决可见性跟重排序的问题。
内存屏障的做用:
1.在有内存屏障的地方, 会禁止指令重排序,即屏障下面的代码不能跟屏障上面的代码交换执行顺序。
2.在 有内存屏障的地方,线程修改完共享变量之后会 立刻把该变量从本地内存写回到主内存而且让其余线程本地内存中该变量副本失效(使用MESI协议)

做者:凌风郎少
连接:https://www.jianshu.com/p/0c3a349663db
来源:简书
简书著做权归做者全部,任何形式的转载都请联系做者得到受权并注明出处。

volatile的实现原理

  • 经过对OpenJDK中的unsafe.cpp源码的分析,会发现被volatile关键字修饰的变量会存在一个lock:”的前缀。
  • Lock前缀,Lock不是一种内存屏障,可是它能完成相似内存屏障的功能Lock会对CPU总线和高速缓存加锁,能够理解为CPU指令级的一种锁。相似于Lock指令。
  • 在具体的执行上,它先对总线和缓存加锁,而后执行后面的指令,在Lock锁住总线的时候,其余CPU的读写请求都会被阻塞直到锁释放。最后释放锁后会把高速缓存中的脏数据所有刷新回主内存且这个写回内存的操做会使在其余CPU里缓存了该地址的数据无效

 那么当写两条线程Thread-A与Threab-B同时操做主存中的一个volatile变量i时,Thread-A写了变量i,那么:java

         Thread-A发出LOCK#指令编程

  • 发出的LOCK#指令锁总线(或锁缓存行)(由于它会锁住总线,致使其余CPU不能访问总线,不能访问总线就意味着不能访问系统内存而后释放锁最后刷新回主内瞬间完成的,写回时候其余缓存行失效同时让Thread-B高速缓存中的缓存行内容失效 
  • Thread-A向主存回写最新修改的i

Thread-B读取变量i,那么:缓存

  • Thread-B发现对应地址的缓存行被锁了,等待锁的释放,缓存一致性协议会保证它读取到最新的值从新从主存读

由此能够看出,volatile关键字的读和普通变量的读取相比基本没差异,差异主要仍是在变量的写操做上。安全


 为何static volatile int i = 0; i++;不保证线程安全?多线程

由于i++并非一个原子操做这是由i++自己特质决定的,它包含了三步(实际上对应的机器码步骤更多,可是这里分解为三步已经足够说明问题):架构

一、获取i
二、i自增
三、回写i并发

A、B两个线程同时自增i
因为volatile可见性,所以步骤1两条线程必定拿到的是最新的i,也就是相同的i
可是从第2步开始就有问题了,有可能出现的场景是线程A自增了i并回写,可是线程B此时已经拿到了i,不会再去拿线程A回写的i,所以对原值进行了一次自增并回写
这就致使了线程非安全,也就是你说的多线程技术器结果不对jvm

若是线程A对i进行自增了之后cpu缓存不是应该通知其余缓存,而且从新load i么?高并发

拿的前提是读,问题是,线程A对i进行了自增,线程B已经拿到了i并不存在须要再次读取i的场景,固然是不会从新load i这个值的。性能

ps:也就是线程B的缓存行内容的确会失效。可是此时线程B中i的值已经运行在加法指令中,不存在须要再次从缓存行读取i的场景。


 volatile是“轻量级”synchronized,保证了共享变量的“可见性”(JMM确保全部线程看到这个变量的值是一致的),当CPU写数据时,若是发现操做的变量是共享变量,即在其余CPU中也存在该变量的副本,会发出信号通知其余CPU将该变量的缓存行置为无效状态而且锁住缓存行,所以当其余CPU须要读取这个变量时,要等锁释放,并发现本身缓存行是无效的,那么它就会从内存从新读取。

 volatile是“轻量级”synchronized,保证了共享变量的“可见性”(JMM确保全部线程看到这个变量的值是一致的),使用和执行成本比synchronized低,由于它不会引发线程上下文切换和调度。


工做内存Work Memory其实就是对CPU寄存器和高速缓存的抽象,或者说每一个线程的工做内存也能够简单理解为CPU寄存器和高速缓存。


 volatile做用:

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

2.lock后的写操做会回写已修改的数据,同时让其它CPU相关缓存行失效,从而从新从主存中加载最新的数据

3.不是内存屏障却能完成相似内存屏障的功能,阻止屏障两遍的指令重排序


volatile只能保证对单次读/写的原子性。由于long和double两种数据类型的操做可分为高32位和低32位两部分,所以普通的long或double类型读/写可能不是原子的。所以,鼓励你们将共享的long和double变量设置为volatile类型,这样能保证任何状况下对long和double的单次读/写操做都具备原子性。

  队列集合类LinkedTransferQueue,在使用volatile变量时,追加64字节的方式来优化队列出队和入队的性能。

追加字节能优化性能?这种方式看起来很神奇,但若是深刻理解处理器架构就能理解其中的奥秘。让咱们先来看看LinkedTransferQueue这个类,它使用一个内部类类型来定义队列的头节点(head)和尾节点(tail),而这个内部类PaddedAtomicReference相对于父类AtomicReference只作了一件事情,就是将共享变量追加到64字节。咱们能够来计算下,一个对象的引用占4个字节,它追加了15个变量(共占60个字节),再加上父类的value变量,一共64个字节。

为何追加64字节可以提升并发编程的效率呢?由于对于英特尔酷睿i七、酷睿、Atom和NetBurst,以及Core Solo和Pentium M处理器的L一、L2或L3缓存的高速缓存行是64个字节宽,不支持部分填充缓存行(处理器支持也能够),这意味着,若是队列的头节点和尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每一个处理器都会缓存一样的头、尾节点,当一个处理器试图修改头节点时,会将整个缓存行锁定,那么在缓存一致性机制的做用下,会致使其余处理器不能访问本身高速缓存中的尾节点,而队列的入队和出队操做则须要不停修改头节点和尾节点,所以在多处理器的状况下将会严重影响到队列的入队和出队效率。

  Doug lea使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,使头、尾节点在修改时不会互相锁定。 

那么是否是在使用volatile变量时都应该追加到64字节呢?不是的。在两种场景下不该该使用这种方式。

 缓存行非64字节宽的处理器。如P6系列和奔腾处理器,它们的L1和L2高速缓存行是32个字节宽。

 共享变量不会被频繁地写。由于使用追加字节的方式须要处理器读取更多的字节到高速缓冲区,这自己就会带来必定的性能消耗,若是共享变量不被频繁写的话,锁的概率也很是小,就不必经过追加字节的方式来避免相互锁定。


 volatile关键字使用的是Lock指令,volatile的做用取决于Lock指令。CAS不是保证原子的更新,而是使用死循环保证更新成功时候只有一个线程更新不包括主工做内存的同步 CAS配合volatile既保证了只有一个线程更新又保证了多个线程更新得到的是最新的值互不影响。


 volatile的变量在进行写操做时,会在前面加上lock质量前缀。

 Lock前缀,Lock不是一种内存屏障,可是它能完成相似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,能够理解为CPU指令级的一种锁

 Lock前缀是这样实现的

 先对总线/缓存加锁而后执行后面的指令最后释放锁后会把高速缓存中的脏数据所有刷新回主内存

 Lock锁住总线的时候,其余CPU的读写请求都会被阻塞,直到锁释放。Lock后的写操做会让其余CPU相关的cache失效,从而重新从内存加载最新的数据,这个是经过缓存一致性协议作的。 


 lock前缀指令至关于一个内存屏障(也称内存栅栏)既不是Lock中使用了内存屏障,也不是内存屏障使用了Lock指令,内存屏障主要提供3个功能:

  1. 确保指令重排序时不会把其后面的指令排到内存屏障以前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操做已经所有完成;
  2. 强制将对缓存的修改操做当即写入主存,利用缓存一致性机制,而且缓存一致性机制会阻止同时修改由两个以上CPU缓存的内存区域数据;
  3. 若是是写操做,它会致使其余CPU中对应的缓存行无效。

 内存屏障CPU指令若是你的字段是volatileJava内存模型将在写操做后插入一个写屏障指令,在读操做前插入一个读屏障指令。

下面是基于保守策略的JMM内存屏障插入策略:

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

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

在每一个volatile读操做的前面插入一个LoadLoad屏障。

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

内存屏障,又称内存栅栏,是一组处理器指令,用于实现对内存操做的顺序限制 

内存屏障能够被分为如下几种类型
LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操做要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操做执行前,保证Store1的写入操做对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操做被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续全部读取操做执行前,保证Store1的写入对全部处理器可见。它的开销是四种屏障中最大的。        在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

为何会有内存屏障

  • 每一个CPU都会有本身的缓存(有的甚至L1,L2,L3),缓存的目的就是为了提升性能,避免每次都要向内存取。可是这样的弊端也很明显:不能实时的和内存发生信息交换,分在不一样CPU执行的不一样线程对同一个变量的缓存值不一样。
  • volatile关键字修饰变量能够解决上述问题,那么volatile是如何作到这一点的呢?那就是内存屏障内存屏障是硬件层的概念,不一样的硬件平台实现内存屏障的手段并非同样,java经过屏蔽这些差别,统一由jvm来生成内存屏障的指令Lock是软件指令。

内存屏障是什么

  • 硬件层的内存屏障分为两种Load Barrier  Store Barrier读屏障写屏障
  • 内存屏障有两个做用:
  1. 阻止屏障两侧的指令重排序
  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效
  • 对于Load Barrier来讲,在指令前插入Load Barrier,可让高速缓存中的数据失效,强制重新从主内存加载数据
  • 对于Store Barrier来讲,在指令后插入Store Barrier,能让写入缓存中的最新数更新写入主内存,让其余线程可见

 java内存屏障

 StoreLoad Barriers是一个“全能型”的屏障,它同时具备其余3个屏障的效果。

volatile语义中的内存屏障

  • volatile的内存屏障策略很是严格保守,很是悲观且毫无安全感的心态:

在每一个volatile写操做前插入StoreStore屏障这个屏障先后的2Store指令不能交换顺序,在写操做后插入StoreLoad屏障这个屏障先后的2Store Load指令不能交换顺序
在每一个volatile读操做前插入LoadLoad屏障这个屏障先后的2Load指令不能交换顺序,在读操做后插入LoadStore屏障这个屏障先后的2Load Store指令不能交换顺序

    • 因为内存屏障的做用,避免了volatile变量和其它指令重排序、线程之间实现了通讯,使得volatile表现出了锁的特性。
    • Java中对于volatile修饰的变量,编译器在生成字节码时,会在指令序列中插入内存屏障禁止处理器重排序。

 Java经过几种原子操做完成工做内存和主内存的交互:

 lock:做用于主内存,锁住主内存主变量。

 unlock:做用于主内存,解锁主内存主变量

 read:做用主内存,主内存传递到工做内存。

 load:做用于工做内存,主内存传递来的值赋给工做内存工做变量。

 use:做用工做内存,工做内存工做变量值传给执行引擎。

 assign:做用工做内存,引擎的结果值赋值给工做内存工做变量

 store:做用于工做内存的变量,工做内存工做变量传送到主内存中。

 write:做用于主内存的变量,工做内存传来工做变量赋值给主内存主变量。‘

 read and load 从主存复制变量到当前工做内存

use and assign  执行代码,改变共享变量值 
store and write 用工做内存数据刷新主存相关内容

 其中use and assign 能够屡次出现

 可是这一些操做并非原子性,也就是在read load以后,若是主内存count变量发生修改以后,线程工做内存中的值因为已经加载,不会产生对应的变化,因此计算出来的结果会和预期不同.

相关文章
相关标签/搜索