volatile可见性、原子性、禁止指令重排序简介

在多线程并发编程中synchronized和volatile都扮演着重要的角色,volatile是轻量级的synchronized,他在多处理器开发中保证了共享变量的可见性。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比synchronized是使用和执行成本更低,因为它不会引起线程上下文的切换和调度。

1.volaile的定义与实现原理

Java语言规范第3版中对volatile的定义如下:

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

在了解volatile实现原理之前,我们先来看下与其实现原理相关的CPU术语和说明:

术语英文单词术语描述
内存屏障memory barriers是一组处理器指令,用于实现对内存操作的顺序限制
缓冲行cache lineCPU高速缓存中可以分配的最小存储单位。处理器填写缓存行时会加载整个缓存行,现在CPU需要执行几百次CPU指令
原子操作atomic operations不可中断的一个或一系列操作
缓存行填充cache line fill当处理器识别到内存中读取操作是可缓存的,处理器读取整个高速缓存行到适当的缓存(L1,L2,L3或所有)
缓存命中cache hit如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是内存读取
写命中write hit当处理器将操作数写回到一个内存缓存的区域时,它会首先检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中
写缺失write misses the cache一个有效的缓存行被写入到不存在的内存区域

首先看下volatile如何保证可见性的。通过工具获取JIT编译器生成的汇编指令来查看对volatile进行写操作时,CPU会做什么。

Java代码:

public class VolatileTest {

    static volatile int num = 0;
    
    public static void test() {
    	for(int i = 0; i < 1000; i++) {
    		num++;
    	}
    }
    
	public static void main(String[] args) {
		for(int i = 0; i < 200; i++) {
			test();
		}

		System.out.println(System.currentTimeMillis());
	}

}

jitwatch-run
1号红线对应的是java代码的位置
2号红线对应的是字节码的位置
3号红线对应的是汇编指令的位置
4号位置是可以选择方法
5号位置就是操作volatile修饰的变量会有lock指令

如果使用的是JDK8,那么需要确保你写的Java方法被调用的次数足够多,以触发C1(客户端)编译,并大约10000次触发C2(服务器)编译器并打开高级优化。换句话说,你要想查看汇编代码,你写的Java源代码文件不能太过于简单,要足够复杂。

有volatile修饰的共享变量进行写操作的时候会出现Lock前缀,通过查IA-32架构软件开发者手册可知,Lock前缀的指令在多核处理器下会引发两件事:

  1. 将当前处理器缓存行的数据写回系统内存
  2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效
    为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2,L3或其他)后再进行操作,但操作完不知道何时回写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回内存,如果其他处理器缓存的值还是旧的,在执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
    为什么要使用L1,L2,L3缓存行呢?我们可以看下各级缓存行和内存的读取速度对比
    CPU各级缓存速度对比
    从上图可以看出读取内存和读取缓存行的效率相差很大。

我们来具体看下volatile的两条实现原则:

  1. Lock前缀指令会引起处理器缓存回写到内存。
    Lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存,因为它会锁住总线,导致其他CPU不能访问总线,不能访问总线就意味着不能访问系统内存。但是,在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销比较大。它会锁定这块内存区域的缓存并且回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为 缓存锁定,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效
    IA-32处理器和Intel64处理器使用MESI(修改modify、独占exclusive、共享share、无效invalid)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32和Intel64处理器能嗅探其他处理器访问系统内存和他们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如,在Pentinum和P6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址的时候,强制执行缓存行无效。在下次访问相同内存地址时,强制执行缓存行填充。

2.volatile的优化

著名的Java并发编程大师Doug lea在JDK7的并发包里新增一个队列集合类Linked-TransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能
Java代码:

//队列中的头部节点
private transient final PaddedAtomicReference<QNode> head;
//队列中的尾部节点
private transient final PaddedAtomicReference<QNode> tail;
static final class PaddedAtomicReference<T> extends AtomicReference<T>{
    //用很多个4字节的引用追加到64个字节
    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;
    //省略其他代码
}

追加字节能性能优化?这种方式看起来很神奇,但如果深入理解处理器架构就能理解其中的奥妙。让我们先来看看LinkedTransferQueue这个类,它使用一个内部类类型来定义队列的头节点和尾节点,而这个内部类PaddAtomicReference相对于父类的AtomicReference
只做了一件事,就是将共享变量追加到64字节。我们可以来计算下,一个对象的引用占4字节,它追加了15个变量(共60字节),再加上父类的value变量,一共64字节。
为什么追加到64字节能够提高并发编程的效率呢?
因为对于一部分处理器的L1、L2或L3缓存的高速缓存行是64字节宽,不支持部分填充缓存行,这意味着,如果队列的头节点时和尾节点都不足64字节的话,处理器会将他们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头、尾节点。当一个处理器试图修改头节点时,会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。Doug lea 使用追加到64节点的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,使头、尾节点在修改时不会互相锁定。
那么是不是在使用volatile变量时都应该追加到64字节呢?
不是的,在两种场景下不应该使用这种方式:

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

3.禁止指令重排序

当一个变量被定义为volatile之后,它将具备两项特性:
1.保证此变量对所有线程可见性
2.禁止指令重排序优化
普通的变量仅会保证在该方法执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因此在同一个线程的方法执行过程中无法感知到这点,这就是Java内存模型中描述的所谓“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)

那为什么说它禁止指令重排序呢?从硬件架构上讲,指令重排序只是处理器采用了允许将多条指令不按程序规定的顺序分开发送给个响应的电路单元进行处理。但并不是说指令任意重排,处理器必须能正确处理指令依赖情况保障程序能得出正确的执行结果。譬如指令1把地址A中的值加10,指令2将地址A中的值乘以2,指令3把地址B中的值减去3,只是指令1和指令2是有依赖的,他们之间的顺序不能重排,(A + 10) * 2与A * 2 + 10 显然不相等,但指令3可以重排到指令1、2之前或者中间,只要保证处理器执行后面依赖到A、B值的操作时能获取正确的A和B的值即可。所以在同一个处理器中,重排序过的代码看起来依然是有序的。因此,lock add1$0x0,(%esp)指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了 “指令重排序无法越过内存屏障”的效果。
我们再来看看在众多保障并发安全的工具中选用volatile的意义,它能让我们的代码比使用其他的同步工具更快嘛?在某些情况下,volatile的同步机制的性能确实要优于锁,但是由于虚拟机对锁实行的许多消除和优化,使得我们很难确切的说volatile就会比synchronized快上多少。如果让volatile自己与自己比较,那可以确定一个原则:volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作上则可能会慢上一些,因为她需要在本地代码中插入很多内存屏障指令来保证处理器不发生乱序执行。不过即便如此,大多数场景下volatile的总开销仍然要比锁来的更低。我们在volatile与锁中选择的唯一判断依据仅仅是volatile的语义能否满足使用场景的需求。
最后,我们再来看看Java内存模型中volatile变量定义的特殊规则的定义。
假定T表示一个线程,V和W分别表示两个volatile变量,那么在进行read(读取)、load(载入)、use(使用)、assign(赋值)、store(存储)、和write(写入)操作时需要满足如下规则:

  1. 只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且,只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行load动作。线程T对变量V的use动作可以认为是和线程T对变量V的load、read动作相关联的,必须连续且一起出现
  • 这条规则要求在工作内存中,每次使用V都必须要先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改
  1. 只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store操作;并且,只有当线程T对变量V执行的后一个动作是store时,线程T才能对变量V执行assign动作。线程T对变量V的assign动作可以认为是和线程T对变量V的store、write动作相关联的,必须连续且一起出现。
  • 这条规则要求在工作内存中,每次修改V后都必须立即同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改。
  1. 假定动作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。
  • 这条规则要求volatile修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同。

4.思维导图

volatile简介


版权声明:本文为d303577562原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。