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,那么:

         Thread-A发出LOCK#指令

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

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

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

由此可以看出,volatile关键字的读和普通变量的读取相比基本没差别,差别主要还是在变量的写操作上。


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

因为i++并不是一个原子操作这是由i++本身特质决定的,它包含了三步(实际上对应的机器码步骤更多,但是这里分解为三步已经足够说明问题):

1、获取i
2、i自增
3、回写i

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

如果线程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字节能够提高并发编程的效率呢?因为对于英特尔酷睿i7、酷睿、Atom和NetBurst,以及Core Solo和Pentium M处理器的L1、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变量发生修改之后,线程工作内存中的值由于已经加载,不会产生对应的变化,所以计算出来的结果会和预期不一样.

posted @ 2019-07-24 20:07  无天666  阅读(17531)  评论(3编辑  收藏  举报