Java 并发编程_Volatile

时间:2021-03-27 20:50:16
共享变量:在多个线程之间能够被共享的变量被称为共享变量。共享变量包括所有的实例变量,静态变量和数组元素。他们都被存放在堆内存中,Volatile只作用于共享变量。 

内存屏障(Memory Barriers):是一组处理器指令,用于实现对内存操作的顺序限制。 

缓冲行(Cache line):缓存中可以分配的最小存储单位。处理器填写缓存线时会加载整个缓存线,需要使用多个主内存读周期。 

原子操作(Atomic operations):不可中断的一个或一系列操作 

缓存行填充(cache line fill):当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适当的缓存(L1,L2,L3的或所有) 

缓存命中(cache hit):如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存。 

写命中(write hit):当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中。 

写缺失(write misses the cache):一个有效的缓存行被写入到不存在的内存区域。 


Volatile 的实现原理: 

Java 代码在编译后会变成字节码,然后在 JVM 里运行,而字节码最终需要转化为汇编代码在CPU 上执行,所以 Java 中所使用的并发机制依赖于 JVM 的实现和 CPU 的指令。 


在多线程并发编程中 synchronized 和 Volatile 都扮演着重要的角色,Volatile 是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。它在某些情况下比synchronized 的开销更小。 


Volatile 的官方定义:java 编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java 语言提供了 volatile,在某些情况下比锁更加方便。如果一个字段被声明成 volatile,java 线程内存模型确保所有线程看到这个变量的值是一致的。 


Volatile 变量修饰符如果使用 恰当的话,它比 synchronized 的 使用和执行成本会更低,因为它不会引起线程上下文的切换和调度。 

对一个 volatile 变量的单个读/写操作,与对一个普通变量的读/写操作使用同一个锁来同步,它们之间的执行效果相同。 

内存屏障:基本上,它是这样一条指令: a)确保一些特定操作执行的顺序; b)影响一些数据的可见性(可能是某些指令执行后的结果)。 

编译器和CPU可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。 

内存屏障另一个作用是强制更新一次不同CPU的缓存。例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。 

如果你的字段是volatile的,Java内存模型会在你对它写入之后插入一个写障指令,并且在你对它读取之前插入一个读障指令。这意味着如果你对一个volatile字段写入: 
    a)任何在你对这个字段写入之后访问它的线程都会得到更新后的值。 
   b)任何你在对这个字段写入之前做的事都被确保发生过了,而任何更新过的数据值都会变得可见,因为内存障把所有早先对缓存的写入都刷新了。 

一旦内存数据被推送到缓存,就会有消息协议来确保所有的缓存会对所有的共享数据同步并保持一致。这个使内存数据对CPU核可见的技术被称为内存屏障或内存栅栏。 

内存屏障或内存栅栏,也就是让一个CPU处理单元中的内存状态对其它处理单元可见的一项技术。 

内存屏障作为一个CPU级的指令,没有锁那样大的开销。内核并没有在多个线程间干涉和调度。但凡事都是有代价的。内存屏障的确是有开销的——编译器/cpu不能重排序指令,导致不可以尽可能地高效利用CPU,另外刷新缓存亦会有开销。所以不要以为用volatile代替锁操作就一点事都没。 

内存屏障提供了两个功能。首先,它们通过确保从另一个CPU来看屏障的两边的所有指令都是正确的程序顺序,而保持程序顺序的外部可见性;其次它们可以实现内存数据可见性,确保内存数据会同步到CPU缓存子系统。 

大多数的内存屏障都是复杂的话题。在不同的CPU架构上内存屏障的实现非常不一样。 

那么 Volatile 是如何来保证可见性的呢?在 x86 处理器下通过工具获取 JIT 编译器生成的汇编指令来看看对 Volatile 进行写操作 CPU 会做什么事情。 

Java 代码: 
instance = new Singleton();//instance 是 volatile 变量 

汇编代码: 

0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24:  lock addl $0×0,(%esp); 

有 volatile 变量修饰的共享变量进行写操作的时候会多第二行汇编代码,通过查 IA-32 架构软件开发者手册可知,lock 前缀的指令在多核处理器下会引发了两件事情。 
1) 将当前处理器缓存行的数据会写回到系统内存。 
2) 这个写回内存的操作会引起在其他 CPU 里缓存了该内存地址的数据无效。 

intel 的手册对 lock 前缀的说明如下: 
1. 确保对内存的读-改-写操作原子执行。在 Pentium 及 Pentium 之前的处理器中,带有 lock 前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从 Pentium 4,Intel Xeon及 P6 处理器开始,intel 在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在 lock 前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低 lock 前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。 
2. 禁止该指令与之前和之后的读和写指令重排序。 
3. 把写缓冲区中的所有数据刷新到内存中。 



处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完之后不知道何时会写到内存,如果对声明了Volatile 变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。 


Lock 前缀指令会引起处理器缓存回写到内存 。 

Lock 前缀指令导致在执行指令期间,声言处理器的 LOCK# 信号。在多处理器环境中,LOCK# 信号确保在声言该信号期间,处理器可以独占使用任何共享内存。(因为它会锁住总线,导致其他 CPU 不能访问总线,不能访问总线就意味着不能访问系统内存),但是在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销比较大。对于 Intel486 和 Pentium处理器,在锁操作时,总是在总线上声言 LOCK#信号。但在 P6 和最近的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反地,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”。  缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。 

一个处理器的缓存回写到内存会导致其他处理器的缓存无效。IA-32 处理器和 Intel 64 处理器使用 MESI(修改,独占,共享,无效)控制协议去维护内部缓存和其他处理器缓存的一致性。 

在多核处理器系统中进行操作的时候,IA-32 和 Intel 64 处理器能嗅探其他处理器访问系统内存和它们的内部缓存。它们使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如在 Pentium 和 P6 family 处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处理共享状态,那么正在嗅探的处理器将无效它的缓存行,在下次访问相同内存地址时,强制执行缓存行填充。 


happens-before:  

从 JDK5 开始,java 使用新的 JSR -133 内存模型。JSR-133 使用 happens-before 的概念来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 

happens-before 规则如下: 

    程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。 

    监视器锁规则:对一个监视器的解锁,happens- before 于随后对这个监视器的加锁。 

    volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。 

    传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。 

注意,两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行 happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。 


锁的 happens-before 规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。 


锁的语义决定了临界区代码的执行具有原子性。这意味着即使是 64 位的 long 型和double 型变量,只要它是 volatile 变量,对该变量的读写就将具有原子性。 


简而言之,volatile 变量自身具有下列特性: 

    可见性:对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。 
    原子性:对任意单个 volatile 变量的读/写具有原子性。 


由于 volatile 仅仅保证对单个 volatile 变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比 volatile 更强大;在可伸缩性和执行性能上,volatile 更有优势。 


Volatile的使用优化:  

著名的Java并发编程大师Doug lea在JDK7的并发包里新增一个队列集合类LinkedTransferQueue,他在使用Volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。 

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


/** head of the queue */ 
private transient final PaddedAtomicReference<QNode> head; 


/** tail of the queue */ 
private transient final PaddedAtomicReference<QNode> tail; 

static final class PaddedAtomicReference <T> extends AtomicReference <T> { 

  // enough padding for 64bytes with 4byte refs 

  Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe; 

  PaddedAtomicReference(T r) { 

    super(r); 

  } 



public class AtomicReference <V> implements java.io.Serializable { 

  private volatile V value; 

  //省略其他代码 

} 


为什么追加64字节能够提高并发编程的效率呢? 因为对于英特尔酷睿i7,酷睿, Atom和NetBurst, Core Solo和Pentium M处理器的L1,L2或L3缓存的高速缓存行是64个字节宽,不支持部分填充缓存行,这意味着如果队列的头节点和尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头尾节点,当一个处理器试图修改头接点时会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾节点,而队列的入队和出队操作是需要不停修改头接点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。Doug lea使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头接点和尾节点加载到同一个缓存行,使得头尾节点在修改时不会互相锁定。 

那么是不是在使用Volatile变量时都应该追加到64字节呢?不是的。在两种场景下不应该使用这种方式。第一:缓存行非64字节宽的处理器,如P6系列和奔腾处理器,它们的L1和L2高速缓存行是32个字节宽。第二:共享变量不会被频繁的写。因为使用追加字节的方式需要处理器读取更多的字节到高速缓冲区,这本身就会带来一定的性能消耗,共享变量如果不被频繁写的话,锁的几率也非常小,就没必要通过追加字节的方式来避免相互锁定。