【转载】Java并发面试系列:彻底掌握 volatile 关键字原理

【转载】Java并发面试系列:彻底掌握 volatile 关键字原理

什么是 volatile

volatile 是 Java 中的一种轻量级同步机制的关键字,当一个变量被 volatile 修饰后,有两层含义:

  • 保证了该变量的修改对所有线程可见
  • 禁止指令重排序优化

另外,volatile 不保证原子性。

volatile 标准使用方法如下:

//定义共享变量为 volatile
private volatile boolean flag = false;

//其他程序根据 flag 的值作业务操作
while (flag){
    //执行业务逻辑
}

volatile 是 Java 中的一个关键,用于修饰共享变量,它是 Java 语言提供的一种稍弱的同步机制。它能确保将变量的更新操作通知到其它线程。

其典型的用法如上所示,即用来检查某个状态标识的值,这个状态标识必须声明为 volatile 的,否则该变量被其它线程修改时,执行判断的线程却发现不了该修改(可见性问题)。

之所以说“轻量级同步”或“稍弱的同步”这是相对于 synchronized 关键字加锁而言的,它的使用不会引起上线下文切换,因此在一些特定的场景,相比于 synchronized 关键字,使用 volatile 可以获得更好的性能。

img

什么是线程可见性问题

volatile 可以保证线程可见性,那么什么是线程可见性呢?

线程可见性是指一个线程对共享变量值的修改,其它线程能否及时的看到被更改后的值。

线程可见性的问题可先看下面这个例子,标识位 flag 是多个线程共享的,开启一个线 t1,不断轮询该标识位,这时另一个线程 t2(代码中主线程)修改了这个标识位:

public class VolatileDemo {

    public static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {
            //不断循环标识,标识位为 true 则 return
            while (flag) {
            }
            System.out.println("线程 t1 退出");
        });
        t1.start();

        //主线程等 5s
        Thread.sleep(3000);
        flag = false;
        System.out.println("flag 已更改");
    }
}

你认为 3s 后的输出结果是什么,会打印“线程 t1 退出”进而线程结束吗? 真正的答案是线程会一直轮循死循环,并不会结束!

这里第一眼看上去绝对是有违常理的,明明值 flag 的值被修改了,为什么循环没有结束。其实这也是内存可见性问题,也就是说线程 t2(主线程)对 flag 的修改对线程 t1“不可见”,线程 t1 拿到的 flag 值仍然是 true,因此发生了下面这种情况:

img

想要探究可见性产生的原因,就会牵扯到其它很多的问题,从最上层来说,Java 内存模型的定义会导致可见性问题,但这不是根本原因,可见性是更底层的 CPU 架构缓存原因导致,只不过 Java 内存模型允许可见性的存在,Java 内存模型只是抽象出来的规范,它的规范中并不保证一个线程对普通变量的更改后另一个线程立即可见,但它的规范中 volatile 是可以实现这种功能的。因此必须先要必须把 CPU 缓存体系、缓存一致性及 Java 内存模型等了解清楚。

img

CPU 缓存体系

在计算机中,CPU 通过总线(bus)从主内存中读取数据,通常用内部时钟速度来描述 CPU 可以多快的执行操作,这个可以看作为 CPU 内执行的速度:即处理器的处理能力,但当 CPU 与计算机其他组件通信就比较慢了,这称之为外部时钟速度,当内部时钟速度大于外部时钟速度时,意味着 CPU 要等待,而从主内存中读取数据就是这样,当然,要使其它组件运行能跟得上 CPU 速度,代价非常昂贵。

为了弥补 CPU 与主内存处理能力之间的差距,大多数现代 CPU 采用在主内存和处理器之间引入了高速缓存(cache memory),并且高速缓存是分级的,一级缓存 L1 和二级缓存 L2 属于每个核,三级缓存 L3 为核共享的,当然,越靠近 CPU 其制作成本越高,因此 L3、L2、L1 的容量逐次递减,但访问速度却是递增的。CPU 缓存体系如下图:

img

对于上面的多核结构,对于操作系统而言,就将相当于一个个独立的逻辑 CPU,因此就缓存这块而言,4 个核也可以理解为 4 个独立的 CPU,为了简单起见,之后简化为“CPU 下的高速缓存”。

下面是电脑的一个真实配置信息,可以看到 L2、L3 缓存的大小,由于 L1 集成在 CPU 中,因此并没有显示。

img

有了高速缓存后,在执行内存读写操作时并不直接与主内存交互,而是通过高速缓存进行,当读写数据时,都会去缓存中找一下,若缓存中的数据可用,则直接操作缓存,若找不到或缓存中的数据不可用,会将所需要的数据从主内存中加载并放入响应的缓存中,也就是高速缓存中会保存主内存部分数据的副本,这显著的缓解了处理器瓶颈的问题。

下面是处理器访问各部件的速度:

img

在写数据的时候一般采用 Write-back 的策略,是说写 cache 后,为了提高性能,并不立即写主内存而是等待一段时间将更新的值批量同步到主内存(还有一种方式是每次修改完都将数据同步到主内存,称之为 Write-Through,很少使用)。

img

缓存一致性协议

通常一个部件的引入可以解决一些问题,但同样会带来新的问题,在引入高速缓存后,并且处理器的高速缓存都各自保留主内存中数据的副本,若某一个处理器将数据更新后,其它处理器必须要感知到,并且将更新的新值加载到缓存中,否则就会出现数据不一致的情况。缓存一致性协议就是为了解决这个问题。

最常见的缓存一致性协议是 MESI 协议,这种协议通过状态的流转来维护缓存间的一致性,并且它针对读取同一个地址的变量操作是并发,但更新一个地址的写操作是独占的,因此同一个变量的写操作在任意时刻只能由一个处理器执行,它把缓存中的缓存行(高速缓存与主内存交互的最小单元,类似于 MySQL,加载一条数据会把一块的数据都加载出来)分为 4 个状态(M、E、S、I)来保障数据一致性。

img

MESI 通过定义一组消息用于协调各个处理器的读写内存操作,同时根据消息的内容会在上述 4 种状态间流转,CPU 在执行内存读写操作时通过总线发送特定的消息,同时其它处理器还会嗅探总线中由其它处理器发送的请求消息并会进行相应的回复,具体如下:

img

为了更好地理解 MESI,这里简述下一个处理器读取和写入数据的实现流程。假设有 processor1、processor2 两个处理器。

processor1 读取某个数据 V 会存在两种情况:

  • 如果 processor1 的缓存中存在数据 V 的缓存行,且状态为 E/S/M 的一种,则直接读取缓存数据,不会向总线发消息,因为处于这三种状态时意味着都是最新数据。
  • 另外一种情况是存在缓存但状态是 I,这种和缓存不存在是一回事,处理逻辑也相同,即向总线发送 Read 消息。

processor1 发送了 Read 消息后,该消息的回复可能来自 processor2,也可能来自主内存,这取决于 processor2 中是否存在数据 V 的缓存行,因此,当 processor2 嗅探到总线的 Read 消息后,这里又会有两种情况:

  • 如果 processor2 中存在 V 的缓存行但状态为 I 或不存在 V 的缓存行,此时响应消息将来自主内存,也就是从主内存中加载数据 V 到 processor1 的缓存中。
  • 如果 processor2 存在 V 的缓存行且状态为不为 I,说明 processor2 存在数据 V 的副本,此时 processor2 会构造消息进行回复,这里有种特殊情况,即 processor2 中 V 的缓存状态是 M,说明数据修改过,此时可能会将数据同步回主内存再回复 processor1 的 Read 请求,最后将状态置为 S(因为已有其它处理器共享了)。

总之 processor1 能拿到正确的值,因此修改过的数据采取前文所说的 Write-back 的策略异步的回写主内存,也不会存在不一致的情况。在 processor1 收到消息后将根据消息的来源将状态置为 E 或 S。

processor1 要写数据 V 会有三种情况:

  • 如果数据 V 在 processor1 的缓存中且状态为 E 或者 M,说明现在是独占状态,则会直接将数据写入缓存中,状态更新为 M,不会发送消息。
  • 如果数据 V 在 processor1 的缓存中且状态为 S,说明 processor2 中缓存了该数据,因此 processor1 往总线发 Invalidate 消息,processor2 将数据所在的缓存置为无效 I 状态后回复 Invalidate Acknowledge 消息,processor1 收到后将状态设为独占 E,接着再更新缓存的值后将状态置为 M
  • 如果数据 V 在 processor1 的缓存中且状态为 I 或不在缓存中,则需要发起 Read Invalidate 消息,在收到 Read Response 和 Invalidate Acknowledge 消息将状态设置为独占 E,再更新缓存后设置为 M。

注:由于消息都是经过总线,因此当有多个 Invalidate 消息时,冲突可以由总线来解决,即保证一个数据在同一时刻只能由一个处理器更新。

至此 CPU 缓存体系和缓存一致性已经清楚了,只要将数据写入高速缓存就会得到缓存一致性保证,如果是这样来看的话,MESI 完全可以保证一个线程对共享变量的修改对其它处理器可见。

img

写缓冲器和无效化队列

引入高速缓存解决了“处理器高速处理能力不能充分发挥”的问题,但却带来了多个缓存需要同步的问题,而缓存一致性协议解决这个问题,但却带来了新的问题。

在处理器写数据的时候,需要等待其它处理器将数据的缓存行设置为无效然后回复 Invalidate Acknowledge 消息后才能进行安全操作,这个过程可能会耗费较多时间,为了优化这一过程,又引入了写缓冲器 Store Buffer 和无效化队列 Invalidate Queue 这两个硬件缓冲区。

引入 Store Buffer 后,写数据时一方面发送 Invalidate 消息给其它处理器,同时将要写的数据放入 Store Buffer 中,此时 CPU 可以执行其它指令,等到所有处理回复 Invalidate Acknowledge 消息后,再将数据写入高速缓存。由此可见引入写缓冲器后将不再等待 Invalidate Acknowledge 消息,这样写可以看作是异步行为,这也减少了写操作的延迟。

img

Store Buffer 通常容量很小,多个写操作的结果到来后 Store Buffer 很容被填满,如果其它处理器不能尽快的回复 Invalidate Acknowledge 消息的话,就会有性能问题,因此引入 Invalidate Queue,当处理器接收到其它处理器发来的 Invalidate 消息后,并不立即无效化本地副本,而把消息放入 Invalidate Queue 后立即返回 Invalidate Acknowledge 消息,等处理器在合适的时间在处理 Invalidate Queue,这也相当于异步操作,同样减少了写操作的延迟。

当引入 Store Buffer 后,读数据也要经过 Store Buffer,因为考虑这样一个场景,一个处理器刚写了某个数据到 Store Buffer 还未刷到主内存,这时又要使用这个数据,那一定不能去缓存中拿,因为现在缓存的是旧值,最新的值在 Store Buffer 中,只有取到最新的值程序运行才不会出错,如下面的程序:

    a = 10;
    if(a==10) {
        //执行业务逻辑
    }

即先写入一个值马上使用,因此读取也要经过 Store Buffer,这种直接处理器直接从 Store Buffer 中获取数据被称之为存储转发(Store Forwarding)。

因此缓存的布局及操作可以抽象为下面这样:

img

img

可见性问题产生的根本原因

从上面引入的两个硬件缓冲区中可以看到,将发送消息 --> 等待回复 --> 写数据这个步骤都变成异步的了。这样虽然在性能上有很大优势,但无疑增加了复杂性。而 Store Buffer 正是导致可见性的硬件根源。之所以说是根源,是因为无效化队列、存储转发可以说都是因为引入 Store Buffer 而产生的。说 Store Buffer 是根源,具体来说:

首先,一个处理器写数据的时候,先会写到 Store Buffer 中,此时还没有写到高速缓存,自然不能利用缓存一致性使其它处理器周知,因此其它处理器持有的仍然是旧值,这就产生了可见性问题。

其次,当发送 Invalidate 消息到其它处理器时,由于消息无效化队列的存在,使得一个处理器会将消息暂放入无效化队列而立即回复 Invalidate Acknowledge 消息,此时处理器持有的仍然是旧值,只有将无效化队列中的消息处理完后才会将旧值置为 Invalidate。

最后,是存储转发,如果从在写入数据到 Store Buffer 后尚未同步到高速缓存,这时其它处理器修改了该数据,那么此时的处理器从 Store Buffer 中获取的仍然是旧值,这就也会产生可见性问题。

如果没有 Store Buffer,按照之前写数据直接操作高速缓存,缓存一致性协议保证一致性,所有操作都是按部就班同步处理,但引入 Store Buffer 后的这种异步处理方式,可见问题复杂了很多。

img

什么是重排序及重排序出现的原因

重排序是指程序运行中,编译器或处理器对指令或内存操作顺序做了调整,这是编译器或处理器为了提高性能而做的优化。重排序直观的理解是代码的书写顺序与实际执行的顺序不同,而实际上这里的重排序要更宽泛一些,比如一个处理器按顺序进行了一系列操作,但由于可见性的一些问题,另一个处理观察到的执行顺序可能和目标代码指定的顺序不一致,这种也称之为重排序。事实上,上面说的 Store Buffer 的延迟写入也是重排序的一种,称之为内存重排序(Memory Ordering),因为它没有按照既有的执行顺序,将数据同步回高速缓存进而同步到主内存。包括内存重排序在内,共有三种类型重排序:

  • 编译器重排序:编译器在不改变单线程程序语义的前提下,对于没有先后依赖的语句,编译器可能会直接调整语句的执行顺序。
  • CPU 指令重排序:具体到指令级别,现代的处理器对没有依赖的关系的指令可以并行执行,这也称之为指令并行技术。
  • 内存重排序:如上介绍的指令执行的顺序和写入内存的顺序不完全一致,这也是可见性问题的主要根源, 也是最难理解的。

编译器重排序

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。下面图中展示了一个 Java 代码从编译到运行的过程:

img

编译器(包括静态和动态)可能对代码顺序进行调整,比如在可见性一节举的例子中:

    //不断循环标识,标识位为 true 则 return
    while (flag) {
    }

这里 JIT 编译器会认为变量 flag 只有一个线程访问,为了避免重复读取 flag 变量而把其优化成这样下面:

    //不断循环标识,标识位为 true 则 return
    if(flag){
        while (true) {
        }
    }

这种优化导致了死循环,这也算是一种重排序,同时也导致了可见性问题。因此重排序的第一种,编译器重排也是导致可见性的一个原因。

还有一种编译器重排的典型例子是对象的创建过程:

class Test{
    int a = 1;
}
Test t= new Test()

Test t= new Test() 在实际的创建对象过程中会有以三个步骤:

  1. 分配一块内存
  2. 在内存上初始化成员变量
  3. 把 t 引用指向内存

在这三个操作中,操作 2 和操作 3 在 JIT 编译器动态生成汇编码时,可能会被重排序,即先把 t 指向内存,再初始化成员变量等操作,因为二者并没有先后的依赖关系。此时,另外一个线程可能拿到一个未完全初始化好的对象,也就是说这个对象被部分构造了,这时使用该对象时就可能出错。

CPU 指令重排序

以下是一个处理器乱序执行的直观例子,指令 1 是一个读取内存数据的耗时指令操作,如果此时指令 2 要做的事情不依赖指令 1,那它完全可以优先来执行:

img

以上两种都是对指令的顺序进行了重排优化,而接下来的内存重排序却不是这样。

内存重拍序

内存重排序并不像指令重排序那样,是真的将指令顺序进行了调整,它是因为引入了写缓冲器,无效化队列这些硬件缓冲区,而导致内存操作的结果与按照指令的执行顺序看来不一致,它是一种现象而非动作,即一个线程按照指令执行,另一个线程感知到内存中一些值就像其读写指令被调整过一样,通过以下一个实际而又简单的例子来解释:

//线程 t1:
result = showDown();  //(1)
hasShutdown = true;   //(2)
//线程 t2
if (hasShutdown) {     //(3)
    result.relase();   //(4)
}

这两段代码相信大家在日常开发中看到或用到过,即对一个操作的结果设置标识位,另一个线程根据标识位继续处理,上面的例子中是线程 t1 先调用 showDown() 关闭得到结果 result,然后将关闭标识位置为 true,线程 t2 根据标识位的结果再对 result 做操作。将上面的代码再简化一个下:

//线程 t1:
result = 1;           //(1)
hasShutdown = true;   //(2)
//线程 t2
if (hasShutdown) {                 //(3)
    System.out.println(result);;   //(4)
}

给人的第一直觉是程序这样没问题,如果 hasShutdown=true,那么打印出来的 result 一定是 1。然而事实上是可能不是 1,即线程 t1 在指令没有发生任何重排的情况下,线程 t2 得到了与 t1 指令不一致的的结果,这就是内存重排序导致的。result 和 hasShutdown 赋值指令虽然没有发生变化,这两个赋值结果先被依次写入到写缓冲器 Store Buffer 中,但有些处理器是不保证将 Store Buffer 结果先入先出的写入高速缓存,也就是说 hasShutdown 可能先被写到高速缓存,这样其它线程就先感知到了 hasShutdown 的值,即 result 被重排到了 hasShutdown 之后,在其它线程看来就是重排序了,这样其它线程的处理逻辑就会和预期的有出入。

如果把引入写缓冲器和无效化队列这些组件所导致的问题称之为内存重排序的话,那也可以这样说:内存重排序是导致可见性的主要原因,再加上指令优化导致可见性问题,因此“重排序导致可见性”这种说法,也是正确的,如果完全禁止了重排序,自然不存在可见性的问题了。

综上程序执行过程中可能经过的重排序如下:

img

img

as-if-serial 和 happens-before

在了解了可见性、重排序这些出现的原因及可能导致出现问题后,似乎打破了以往编程中的一些思维,即感觉实际上我们在日常开发中的一些简单的场景时也没有出现过问题。当然,如果随便地进行重排序那程序的执行一定会出问题,因此重排序也是有限制,这里的限制主要分成两部分。

单线程 as-if-serial 语义

首选规定重排序要满足 as-if-serial 语义,即“貌似串行”语义,这主要针对单线程环境执行来说的,是说不论如何重排序单线程程序的执行结果不能改变,因此代码看起来是串行执行。

抛开虚拟机规范来说,这条语义其实适用于任何的语言,如果一种语言在单线程内因发生重排导致每次执行的结果不一致,那这门语言在很多场景都是不可用的,因为我们的代码是有逻辑的,有逻辑就一定要有顺序性。因此由于 as-if-serial 语义的存在,从编译器到处理器都会遵循一些规则(如有依赖关系的不能重排)来保证 as-if-serial,即在单线程中,可以重排,但不管怎么重排,最终运行结果不能影响程序的正确性。当然,这会给人一种假象-单线程程序都是按代码顺序执行,这也是貌似串行语义中“貌似”的含义。这个不需要开发者来操心,开发者完成感知不到,从编译器到处理器都会实现 as-if-serial 语义,因此我们在单线程中不用考虑重排序对程序的影响,但多线程就要复杂些。

多线程环境的 happens-before

对于多线程相互读取复杂的情况,就需要更上层来规定,因为线程之间的数据依赖太复杂,编译器和处理器无法正确的做出合理优化,也就是不同语言要定义自己的内存模型和规则,由上层来告诉编译器和处理器在什么场景下可以重排、什么场景下不可以重排,因此不同的语言在单线程的执行结果一般都相同,但在多线程下的表现结果可能是完全不同的。

Java 的内存模型中(JMM 稍后介绍),通过 happens-before 原则(先行发生原则)来规定哪些场景不能重排,也就是用来阐述多线程操作间的可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那这两个操作之间必须要存在 happens-before 关系,也就说两个操作间具有 happens-before 关系,则保证前一个操作的执行结果对后一个操作可见。JMM 作了这个规范后,当两个操作存在 happens-before 关系时,并不会要求在编译器和处理器角度对指令按照 happens-before 关系指定的顺序执行,如果重排序的后的执行结果和按照 happens-before 关系执行后的结果一致,那重排也是允许的。因此 happens-before 被看作是对开发者的承诺,即程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序的执行结果要有确定性,无论怎么重排,最后结果和所承诺的必须一致,从这一点上来看,happens-before 本质和 as-if-serial 语义是一样的,只是前者对多线程环境在某些情况下执行结果不能因重排序而改变。

那 happens-before 到底做了哪些规定?

首先 happens-before 的基本原则,如果操作 A happens-before 操作 B,即 A 先行发生于操作 B,那 A 的执行结果必须对 B 可见。基于这个原则, JMM 定义了很多先行发生关系,这里只分析其中的比较重要的几条:

  • 可传递性,若操作 A happens-before 操作 B,同时 B happens-before C,则 A 的执行结果也一定对 C 可见。
  • 一个线程中的每个操作 happens-before 于该线程中的任意后续操作,但多个线程没有这种关系。
  • ……

根据 happens-before 基本原则再结合上述规定,可得出结论单线程中前面的代码执行结果对后面的可见。

int x;
int y;
以下操作在线程 1 执行
x=10; //A
y=20; //B
z=x*y;//C

如上示例,根据第一个条规定,线程 1 的前一个操作 happens-before 于后续操作,即:

  • A happens-before 于 B
  • B happens-before 于 C
  • A happens-before 于 C(根据传递性)

因为 A happens-before B,所以 A 的结果对 B 操作可见,但 A B 仍然可以重排,因为虽然对 B 可见,但 B 并不依赖于 A,A happens-before C,所以 A 的结果对 C 操作一定可见,也就是 C 中的 x 值一定是 10,如果此时发生重排导致 A 重排到 C 后,那值就不是 10,也就不满足 A 的结果对 C 操作一定可见,因此这两个不能重排。

再看下面的多线程的场景下,若操作 D 在线程 2 中,且出现在操作 B 和操作 C 之间,那么 z 的值是不确定的,即 1 和 200 都有可能,因为操作 D 与 C 并没有先行发生关系,所以线程 2 对 z 的影响可能不会被线程 1 观察到。

int x;
int y;
以下操作在线程 1 执行
x=10; //A
y=20; //B
z=x*y;//C

以下操作在线程 2 执行线程
z = 1;//D

另外一条规定是针对 volatile 变量的规则:volatile 变量的写入 happens-before 对应后续该变量的读取。

从这里我们可以看出 JMM 规定 volatile 的变量可以打破内存可见性的问题,也就是 volatile 变量不能重排序。当然,JMM 本身只是规范,要做到这些规范都要要靠具体的虚拟机去实现。

img

JMM - Java 内存模型怎么理解

JMM,即 Java 内存模型,是控制 Java 线程之间通信的,它描述的是一组规定或者说是规范,因此它是一个抽象模型概念,用来屏蔽掉 Java 程序在各种不同的硬件和操作系统对内存的访问的差异。所屏蔽的正是高速缓存、写缓冲器这些。因此,如果直接理解 Java 内存模型其实是非常简单的,它把底层的信息都屏蔽掉了,给开发者呈现的是一个简单的内存模型。

JMM 定义了线程和主内存的抽象关系如下,首先所有的变量都存储在主内存中,这是所有线程共享的内存区域,其次每条线程有自己的工作内存用于存储线程私有数据,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量,所以要将变量从主内存拷贝的自己的工作内存空间,也就是生成变量的副本,然后再对变量进行操作,操作完成后再在合适的时机将变量写回主内存,如下图所示:

img

如果抽象成这样,那理解起来比具有高速缓存、缓存一致性、写缓冲器的真是内存模型要简单的多。比如针对可见性问题,就会非常好理解,假如两个线程同时将主内存中的变量加载到自己的工作内存中,一个线程 t1 将变量更改,通常不会立即同步到主内存,而是在某个合适的时机将更改后的变量同步到主内存,正是因为这个更改还没有 flush 到主内存中,因此线程 t2 持有的始终是旧值,自然产生了可见性的问题。JMM 也对主内存和工作内存之间具体的交互协议是有详细严谨定义的,首先是定义了 8 个内存间的交互原子操作:

  • read(读取):从主内存读取数据,经过总线将数据取出来
  • load(载入):将主内存读取到的数据写入工作内存
  • use(使用):从工作内存读取数据来计算
  • assign(赋值):将计算好的值重新赋值到工作内存中
  • store(存储):将工作内存写入主内存
  • write(写入):将 store 过去的变量赋值给主内存中的变量
  • lock(锁定):将主内存变量加锁,标识位线程独占状态
  • unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量

这个 8 个操作对应图是这样:

img

通过上面的图可以看出想要使用一个变量,会经过 read->load->use。

Java 内存模型对上述 8 种操作还会有一系列的约束,有了这些约束就能准确的描述出 Java 程序中哪些操作内存方法在并发下才是安全的。比如,其中一条规定是变量在工作内存中改变后必须把该变化同步回主内存。但同步的时机没有具体规定,可能在 CPU 空闲时、可能在缓存不够用时等等,这也是从 Java 内存模型的角度来看,导致多线程会存在内存可见性的原因。为了解决这个问题,必须要对同步时机进行干预,这也是 volatile 产生的原因。JMM 针对 volatile 修饰的比变量,专门定义了一些特殊访问规则,即一个被声明为 volatile 的变量被修改后就立即同步到主内存,当读取时也会从主内存中读取,这样就也解决了可见性问题,当然,具体的是 java 虚拟机为我们做了这个实现。

img

可见性、重排序有什么影响

之前的内容中介绍可见性及重排序时顺带的提出了一些示例,这里汇总一下可见性和重排序对开发的具体影响。

1. 多线程根据共享变量状态来处理逻辑

如下面这个场景,线程 t1 调用关闭方法 showDown() 后,将关闭标识位设置为 true,线程 t2 根据标识位拿到关闭结果做释放清理工作,如果 (1) 和 (2) 进行重排序,那么线程 t2 就会出错了。出现这种问题可以说是因为重排序导致,也可以说是可见性导致。

//线程 t1:
result = showDown();  //(1)
hasShutdown = true;   //(2)
//线程 t2
while (hasShutdown) {  //(3)
    result.relase();   //(4)
}

所以我们要对 hasShutdown 做 volatile 修饰:

private volatile hasShutdown

2. 单例模式 DCL

懒汉式 + 双重检查加锁是单例的一种实现方式,简称 DCL,单例模式就是一个类只有一个实例,懒汉式就是第一次获取这个实例时把单例对象初始化并返回,对应的饿汉式就是一开始就把单例对象初始化(如通过静态常量或静态代码块),等需要使用时直接提供已初始化好的对象,下面的懒汉模式加锁就是防止多个线程进行初始化操作。

public class Singleton {

    private static Singleton instance;

    public static Singleton getInstance() {
        if (null == instance) {
            //防止其他线程锁住
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

上述的代码实在 singleton = new Singleton() 这块是有问题的,根据前面提及的创建对象时底层会分为三个操作,即在 new Singleton() 时,可能会发生重排序,即先把 singleton 指向内存,再初始化成员变量等操作,因为二者并没有先后的依赖关系。此时,另外一个线程可能拿到一个未完全初始化好的对象使用而出错。解决办法也是为 singleton 变量加上 volatile 修饰。

饿汉模式一开始就把实例初始化了,通常在类装载的时候,其缺点如果从始至终从未使用过这个实例,则会造成内存的浪费。懒汉模式双重检查并加锁较麻烦,当然还有更好的方式是采用静态内部类的方式。

3. 64 位写入的原子性

在 Java 中,对 long 或者 double 类型并不要求写入是原子的,但除了这两个类型外,其它任何类型的写操作都是原子的。因此在某些虚拟机上,对 64 位的 long 或 double 变量进行多线程读写时,可能会读取到一半的值,这是因为一个 64 位变量的写入可能被拆分成两个 32 位写操作来执行。如下面的例子,当线程 1 写入数据的时候,线程 2 可能会获取到一个初始化一半的值:

public class HalfWriteDemo {

    private long result = 0;

    //线程 1 调用
    public void setResult(long result) {
        this.result = result;
    }

    //线程 2 获取
    public long getResult() {
        return result;
    }
}

解决办法同样是用 volatile 来修饰,因为 Java 语言中规定对于 volatile 修饰的 long/double 类型的变量的写操作具有原子性。

img

volatile 是如何解决可见性和重排序的

以上是介绍了可见性和重排序出现的原因,从 JMM 角度看可见性问题,和从底层处理器角度看可见性和有序性问题是不同的。Java 语言上层也规定了如果用 volatile 修饰的话,这些问题都可以解决。JMM 只是做了这个规定,具体是如何实现的呢?

对于重排序中的指令重排在编译器这一块是好实现,只要识别出拥 volatile 修饰就不优化、不重排即可。

从最底层 CPU 的角度来看,要实现有序性和可见性,它会提供处理器的原语支持,也就是一些指令,而能实现这些功能的这些指令被称之为内存屏障(Memory Barrier)。Java 内存模型是抽象出来用于屏蔽硬件和操作系统内存读取差异的,因此它提供 volatile 从语言层面给出的保证,而具体的实现是由虚拟机替我们和这些指令打交道。

volatile 语义的实现,Java 虚拟机在底层是借助了内存屏障。内存屏障被插入两个指令之间使用的,它像一堵墙一样使两侧的指令无法重排,且内存屏障还会执行冲刷处理器缓存操作来保证可见性。

img

首先来分析下产生可见性和有序性的原因,之前的分析中导致可见性和有序性问题的主要根源是 Store Buffer 和 Invalidate Queue,那么就需要从这两个组件上着手。

由于 Store Buffer 中的内容可能不会立即写入到高速缓存,并且后写入的可能会先刷到高速缓存上,这样造成了其它线程的可见性和有序性问题,因此一定要将写缓冲器 Store Buffer 中的内容及时的、按顺序的写到高速缓存(冲刷处理器缓存),而编译器及底层系统正可以借助内存屏障的指令来实现这个功能。具体的是加入内存屏障后,会将 Store Buffer 中的数据进行标记,之后的写入操作写入 Store Buffer 中,再及时的同步到高速缓存中,在同步的过程中,要将标记的数据先同步,这样也保证了有序性。

其次,能及时的按顺序处理 Store Buffer 后只是解决了一半的问题。虽然数据到达高速缓存后可以利用缓存一致性,但因为无效化队列 Invalidate Queue 的存在,其它处理器读到的值仍可能是旧值,因此还要及时的将 Invalidate Queue 中的内容进行处理,将失效的缓存条目置为 Invalidate,而内存屏障同样可以实现该功能。

因此内存屏障要进行“搭配”使用,写数据要将更新及时的从写缓冲器冲刷到高速缓存,而读数据要将无效化队列中的内容应用到高速缓存上。

内存屏障在 CPU 底层是提供了原语支持的,如下面的内存屏障指令:

  • sfence:将 Store Buffer 刷新到高速缓存,同时规定禁止 store 指令与 sfence 后面的指令重排序,即禁止了写的重排序。
  • lfence:将 Invalidate Queue 中的内容应用到高速缓存,并规定 load 指令不能 lsfence 后面的指令重排序,即禁止了读的重排序。
  • mfence:既将 Store Buffer 刷新到高速缓存又将将 Invalidate Queue 中的内容应用到高速缓存,并禁止 store/load 与 mfence 后面的指令重排,即禁止所有重排序。

从所有重排序类型的角度来看,重拍序可以有 4 种,即 load 和 store 的全排列:loadload、loadstore、storestore、storeload。而我们常用的 x86 CPU 本身是不支持前三种重排序的,因此在 x86 CPU 只需要处理 storeload 这种重排序就可。当然,Java 是一种跨平台的语言,它需要考虑所有重排序的场景,因此在 JSR 规范中是定义了 4 种内存屏障,针对不同的处理器可以有不同的实现。这 4 中内存屏障如下:

  • LoadLoad 屏障:对于这样的语句 Load1;LoadLoad;Load2; 在 Load2 及后续读取操作要读取数据被访问前,保障 Load1 被要读取完毕。这里可以通过清空无效化队列实现,即将无效化队列中的失效的缓存应用到高速缓存。
  • StoreStore 屏障:对于这样的语句 Store1;StoreStore;Store2,在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其它处理器可见。这里可以通过将写缓冲器中的条目进行标记来实现,将标记的条目先提交进而禁止 StoreStore 重排序。
  • LoadStore 屏障:对于这样的语句 Load1;LoadStore;Store2,在 Store2 及后续写入操作执行前,保证 Load1 要读取的数据被读取完毕。
  • StoreLoad 屏障:对于这样的语句 Store1;StoreLoad;Load2,在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见。这种内存屏障通常作为基本内存屏障,它即会清空无效化队列,又会将写缓冲器冲刷到高速缓存中,因此它的开销也是最大的。

在 Java 中对于 volatile 修饰的变量,编译器生成字节码时,会在 volatile 的读写前后插入对应的内存屏障,要插入何种内存屏障才能满足 volatile 的语义如下图所示:

img

由表格中可以看出,内存屏障的选择与变量的前一个操作是否是 volatile 变量有关系,该图出自 Doug Lea 的文章:

http://gee.cs.oswego.edu/dl/jmm/cookbook.html

下面是实现 volatile 语义的一种常规做法:

img

这里要着重说明一下,关于 volatile 内存屏障的插入方式,不同的平台是不同的,因此关于具体插入哪种内存屏障也出现了很多不同说法,但从 volatile 语义来说,若要支持所有平台,就要采用悲观策略,即该有的屏障都要插入。但很多处理器没有 Invalidate Queue,因此在 volatile 的读前面可以不插入屏障,因为写后已经插入 storeload 屏障保障数据同步到高速缓存,进而其它处理器就能获取到最新值,另外对于写之前按理说应加入 LoadStore 屏障,但 volatile 写与前面普通变量的读即使重排序也不会影响程序正确性,因此写之前的 LoadStore 屏障也可以被省略了。

JMM 本身对于 volatile 变量在编译器级别的重排序也制定了相关的规则,即提示编译器不要做一些优化而导致可见性问题,如之前的 while 死循环的例子,就可以用 volatile 修饰而避免出现问题。由此可见从编译器到虚拟机,再到处理器都是支持内存屏障的。

这是从底层来解释了 volatile 语义的实现,如果从 Java 内存角度模型来看,其实没这么麻烦,因为 Java 内存模型并不涉及缓存一致性协议、硬件缓冲器等等,因此可以简单理解为,写一个 volatile 修饰的变量会从工作内存同步到主内存,同时其它处理器工作内存中存储的该变量副本会失效,因此读取该变量时会重新从主内存中加载。

再说回到内存屏障的实现,JMM 层面的内存屏障就是对 CPU 层面的内存屏障指令做的包装,不同硬件实现内存屏障的方式不同。之前提到过,在 x86 平台上,由于只支持 StoreLoad 的重排序,因此只需要在 volatile 的写操作后加入 StoreLoad 屏障即可,而 Hotspot 虚拟机在 x86 平台实现 volatile 依赖的是一条 CPU 指令:lock addl $0x0,(%rsp),lock 前缀指令理论上说不是一种内存屏障指令,但它可以完成类似内存屏障的功能,也就是说起到了 StoreLoad 屏障的作用。而在其处理器上,会根据对重排序的支持情况在 volatile 的前后插入相应的内存屏障指令。

由于需要保证可见性和禁止指令重排,x86 CPU 规定 load、store 不能与 lock 指令重排序,这就达到了禁止指令重排的要求。对于可见性的问题,lock 指令会触发冲刷处理器 Store Buffer 到高速缓存,这样保 lock 指令前面内容的可见性。由于 x86 处理器并没有使用 Invalidate Queue,因此只需要在写 volatile 变量后插入 StoreLoad 类型的屏障,即一条 lock 指令就可以了。lock 指令同时会锁住要操作的内存地址,直到读完/写完,因此也保证 64 位变量写的原子性。

img

volatile 的原子性问题

原子性是指对共享变量的访问是一个不可分割的整体,volatile 可以使 long/double 类型的写具有原子性,但它并不保证其它操作的原子性。

另外 volatile 能保证可见性,意味着一个线程操作了 volatile 修饰的变量,其它线程立即可以感知到新的值,但这不足以保证原子性,考虑下面的场景:多线程竞争执行 volatile 修饰变量 a++ 这种操作时, 其底层是分为 4 步执行

  1. move a 寄存器
  2. add 寄存器 1
  3. move 寄存器 a
  4. add StoreLoad Barrier

首先将 a 的值赋给寄存器,然后寄存器加 1,再将寄存器的值赋给 a,最后一步是内存屏障,代表最新值会对其它线程其可见,这里的第三步是可以保证原子的,但发生第 2 步的时候,其它线程如果也自增了 a 值后并同步回主内存,此时执行第三步,即使当前线程嗅探到 a 值的变换,并重新从主内存中加载,但第三步执行又将自增的结果赋给 a,之后同步回主内存,这样就出现了 a 本应该自增了两次,真实结果确只加了 1,这样就导致数据不一致了,要保证原子性仍然要加锁。

可以通过下面的代码来验证 volatile 的原子性问题:

public class VolatileTest {

    private static volatile int a = 0;

    public static void main(String[] args) throws InterruptedException {

        //自增 5000
        Runnable runnable = () -> {
            for (int i = 0; i < 5000; i++) {
                a++;
            }
            System.out.println("线程执行结束");
        };

        //开启两个线程执行
        new Thread(runnable).start();
        new Thread(runnable).start();
        Thread.sleep(5000);
        System.out.println(a);
    }
}

在程序中用两个线程来实现 volatile 变量的自增操作,每个线程自增 5000,如果是原子的话,输出结果应该是 10000,而实际的输出结果如下:

线程执行结束
线程执行结束
7242

img

volatile 用在哪个地方或哪些场景

1. DCL 单例模式,通常要将其成员变量设置为 volatile,禁止指令重排,这样不会拿到一个初始化一半的对象。

public class Singleton {

    private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (null == instance) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

2. 使用 volatile 变量作为状态标识。如果一个共享变量被某个线程设置,而另一个线程要根据该变量的状态做业务逻辑时,要设置为 volatile,这相当于一个同步机制,即一个线程能够通知另一个线程某些事件的发生。如可以看下 RocketMQ 的源码中,有很多用到 volatile 的例子:

 private volatile boolean hasShutdown = false;

 private volatile boolean cancelled = false;

 private volatile boolean stopped = false;

这些用 volatile 修饰的变量有很多都是 boolean 值,这也很好理解,就是可能有多个线程对这些 boolean 值操作时,某个一个线程成功了,其它线程立即就感知到。

如多个线程进行 showdown,某个线程执行成功将 hasShutdown 置为 true,这样其它线程再进行 showdown 时,检测到 hasShutdown 标识位已经是 ture,则不会再进行 shutdown。如果未声明未为 volatile,则一个线程修改,另一个线程检测仍然是 false,继续进行关闭操作可能会出错。或者某个线不断循环 showdown 标识位,当检测到关闭时需要及时做一些清理工作,同样需要将标识位声明为使用这种方式的场景 。伪代码如下:

//定义状态量标记
volatile boolean hasShutdown = false;


//关闭方法
void showdown(){
  if(showdown){
    returen
  }
  start = true;
  //……
}

//清理工作
void clean(){
  while(hasShutdown){
      //
    }
}

3. 多线程读写 long/double 类型的变量时,需要用 volatile 修饰,如下面代码所示:

public class HalfWriteDemo {

    private volatile long result = 0;

    //线程 1 调用
    public void setResult(long result) {
        this.result = result;
    }

    //线程 2 获取
    public long getResult() {
        return result;
    }
}

img

转载地址

Java并发面试系列:彻底掌握 volatile 关键字原理

posted @ 2023-02-15 10:08  hongdada  阅读(113)  评论(0编辑  收藏  举报