一文详解 volatile 关键字

1、volatile 的应用

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

1.1 为什么会产生可见性问题

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到高速缓存(L1、L2 或其他)后再进行操作, 对共享变量操作之后不知道会何时写入内存。于是其他处理器不能及时得到最新的值,造成处理器中的数据不一致。

1.2 volatile 的定义与实现原理

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

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

术语 术语描述
内存屏障 是一组处理器指令,用于实现对内存操作的顺序限制
缓冲行 缓存中可以分配的最小存储单位,处理器填写缓存线时会加载整个缓存线,需要使用多个主内存读周期
原子操作 不可中断的一个或一系列操作
缓存行填充 当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适当的缓存(L1、L1...)
缓存命中 如果进行高速缓存填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取
写命中 当处理器将操作数写回到一个内存缓存区域时,它首先检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存
写缺失 一个有效的缓存行被写入到不存在的内存区域

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 变量修饰的共享变量进行写操作时会多出第二行汇编代码,Lock 前缀的指令在多核处理器下会引发两件事情:

  1. 将当前处理器缓存行的数据写回到系统内存
  2. 这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效

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

下面具体讲解 volatile 的两条实现规则:

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

2)一个处理器的缓存回写到内存会导致其他处理器的缓存无效。例如 Intel 64 处理器使用 MESI (修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,Intel 64 处理器能嗅探到其他处理器访问系统内存和它们内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如在 Pentium 处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存无效,在下次访问相同内存地址时,强制执行缓存填充。

1.3 volatile 的使用优化

在 JDK7 的并发包里新增了一个队列集合类 LinkedTransferQueue,它在使用 volatile 变量时,用一种追加字节的方式来优化队列出队和入队的性能,LinkedTransferQueue 的代码如下:

/** 队列中的头部节点 */
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 使用一个内部类类型来定义队列的头结点(head)和尾节点(tail),而这个内部类 PaddedAtomicReference 相对父类 AtomicReference 只做了一件事情,就是将共享变量追加到64字节,我们计算一下,一个对象的引用占4字节,它追加了15个变量(共占60个字节),再加上父类的 value 变量,一个 64 个字节。

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

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

  • 缓存行非64字节宽的处理器
  • 共享变量不会被频繁地写,因为使用追加字节的方式需要处理器读取更多的字节到高速缓存中,这本书就会带来一定的性能消耗,如果共享变量不被频繁写的话,锁的几率也非常小,就没必要通过追加字节的方式来避免相互锁定。

2、volatile 的内存语义

2.1 volatile 的特性

理解 volatile 特性的一个好方法是把对 volatile 变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步,下面通过具体的示例来说明:

class VolatileFeaturesExample {
    volatile long vl = 0L; //使用 volatile 申明64位的long型变量
    public void set(long l) {
    	vl = l; // 单个volatile变量的写
    }
    public void getAndIncrement () {
    	vl++; // 多个volatile变量的读/写
    }
    public long get() {
    	return vl; // 单个 volatile 变量的读
    }
}

假设有多个线程分别调用上面程序的三个方法,这个程序在语义上和下面程序等价:

class VolatileFeaturesExample {
    long vl = 0L; // 64位的long型变量
    public synchronized void set(long l) { // 对单个的普通变量的写用同一个锁同步
    	vl = l;
    }
    public void getAndIncrement () { // 普通方法调用
        long temp = get(); // 调用已同步的读方法
        temp += 1L; // 普通写操作
        set(temp); // 调用已同步的写方法
    }
    public synchronized long get() { // 对单个的普通变量的读用同一个锁同步
    	return vl;
    }
}

如上面示例程序所示,一个volatile变量的单个读写操作与一个普通变量的读写操作都是使用同一个锁同步,它们之间的执行效果相同。

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

锁的语义决定了临界区代码的执行具有原子性,这意味着即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读写就具有原子性,如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。

总之,volatile变量自身具有下列特性:

  • 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量的最后写入。
  • 原子性:对任意单个volatile变量的读写具有原子性,但是类似于 volatile ++ 这种复合操作不具有原子性。

2.2 volatile 写-读建立的happens-before关系

说完了volatile变量自身的特性,对程序员来说,volatile 对线程的内存可见性的影响比volatile自身的特性更为重要。

从JDK5开始,volatile变量的读写可以实现线程之间的通信。

从内存语义的角度来说,volatile的读写与锁的是否获取有相同的内存效果,volatile写和锁的释放有相同的内存语义,volatile读与锁的获取有相同的内存语义。

volatile变量的示例:

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;
    public void writer() {
        a = 1; // 1
        flag = true; // 2
    }
    public void reader() {
        if (flag) { // 3
            int i = a; // 4
            ……
        }
    }
}

假设线程A执行write()方法之后,线程B执行reader()方法,根据 happens-before 规则,这个过程建立的happens-before关系可以分为3类:

  1. 根据程序次序规则,1 happens-before 2, 3 happens-before 4
  2. 根据volatile规则,2 happens-before 3
  3. 根据happens-before的传递性规则,1 happens-before 4

上述 happens-before 关系的图形化表示如下:

image-20220612154519158

在上图中,每一个箭头连接的两个节点,代表了一个happens-before关系,黑色箭头表示程序顺序规则;橙色箭头表示volatile规则,蓝色箭头表示组合这些规则后提供的happens-before保证。

这里 A 线程写一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之前的所有可见的共享变量,在B线程读同一个volatile变量,将立即变得对B线程可见。

2.3 volatile 写-读的内存语义

volatile 写的内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

以上面的程序为例,假设线程A首先执行write()方法,随后线程B执行reader()方法,初始时两个线程的本地内存中的flag和a都是初始状态。下图是线程A执行volatile写后,共享变量的状态示意图:

image-20220612165727366

线程A在写flag变量后,本地内存A中被线程A更新过的两个共享变量的值被刷新到主内存中,此时本地内存A和主内存中的共享变量的值是一致的。

volatile读的内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。

下图为线程B读同一个volatile变量后,共享变量的状态示意图:

image-20220612170023886

如图所示,在读flag变量后,本地内存B包含的值已经被置为无效,此时,线程B必须从主内存中读取共享变量,线程B的读取操作将导致本地内存B与主内存中的共享变量的值变成一致。

如果我们把volatile写和volatile读两个步骤综合起来看的话,在读线程B读一个volatile变量后,写线程A在写这个volatile变量之前的所有可见的共享变量的值都将立即变得对线程B可见。

对 volatile 写和volatile读的内存语义做个总结:

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(对其共享变量所做的修改)消息
  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前堆共享变量所做的修改)消息
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存像线程B发送消息。

2.4 volatile 内存语义的实现

上面说了volatile读写的内存语义,下面看看JMM如何实现volatile读写的内存语义。

我们知道重排序分为编译器重排序和处理器重排序,为了实现volatile内存语义,JMM会分别限制这两种类型的重排序规则,下表就是JMM针对编译器制定的volatile重排序规则表:

image-20220327113006324

表的解释:

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

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

III:当第一个操作是volatile写,第二个操作是volatile读时,不能重排序 。

为了实现内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略,可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义 。下面是基于保守策略的JMM内存屏障插入策略 :

1):在每个volatile写操作的前面插入一个StoreStore屏障

2):在每个volatile写操作的后面插入一个StoreLoad屏障

3):在每个volatile读操作的后面插入一个LoadLoad屏障

4):在每个volatile读操作的后面插入一个LoadStore屏障

图解:volatile写插入内存屏障后生成的指令序列示意图 :

为啥屏障会有重排序的作用呢:以StoreStore为例:StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存 。

image-20220327113021473

图解:volatile读插入内存屏障后生成的指令序列示意图 :

image-20220327113029154

注意:上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

posted @ 2021-10-24 15:04  Maple~  阅读(75)  评论(0编辑  收藏  举报