volatile,还可以有这么硬的理解

555 阅读14分钟

volatile 关键字作为Java虚拟机提供的轻量级同步机制,在Java并发编程中占据着重要的地位,但是深入理解volatile可不是一件简单的事,了解volatile的同学都知道,volatile变量保证了可见性,而可见性又与Java内存模型息息相关,所以本文先简单介绍内存模型相关概念,然后再从Java虚拟机层面剖析分析volatile变量,接着从硬件层面出发,带你层层深入了解volatile及其背后的故事。

1、计算机内存模型与Java内存模型的关系

由于现代计算机处理器与存储设备的运算速度存在几个数量级的差异,所以现代计算机都会在处理器与主内存之间加上高速缓存作为缓冲:将处理器计算所需数据复制到高速缓存,处理器直接从高速缓存中获取数据计算,同时处理器将计算结果放入缓存,再由缓存同步至主内存。

Java虚拟机为了达到“一次编译,到处运行”的目的,也有自己的内存模型,即Java内存模型(JMM)。Java内存模型作为一种规范,屏蔽了各种操作系统和硬件的内存访问规则,是计算机内存模型的一种逻辑抽象。它规定所有的变量都必须存在主内存中,每个Java线程都有自己的工作内存,工作内存中存放了所需变量的副本,Java线程对变量的操作必须在工作内存中,而不能直接操作主内存。


如上图所示,虽然这两种内存模型都能够解决运算速度不匹配的问题,但随之而来就是缓存不一致问题:多个处理器都有自己的高速缓存,但他们又共享同一主内存,从而造成了变量修改不可见问题。为了解决缓存不一致问题,需要处理器在处理缓存时满足缓存一致性协议,例如MESI协议。既然有缓存一致性协议的存在,为什么还需要volatile关键字来保证变量的可见性呢?

2、volatile变量特征

首先我们来说一下volatile变量具备以下特征:

  • 可见性 ,对于volatile变量的读,线程总是能读到当前最新的volatile值,也就是任一线程对volatile变量的写入对其余线程都是立即可见;

  • 有序性,禁止编译器和处理器为了提高性能而进行指令重排序;

  • 基本不保证原子性,由于存在long/double 非原子性协议,long/double在32位x86的hotspot虚拟机下允许没有被volatile修饰的变量读写操作划分为两次进行。但是从JDK9开始,hotspot也明确约束所有数据类型访问保持原子性,所以volatile变量保证原子性可以基本忽略。

那么,volatile变量是怎么保证变量的可见性和有序性的?

3、深入剖析volatile变量

从Java内存模型层面来说: Java内存模型保证了volatile变量的可见性,也就是说JMM保证新值能马上同步到主内存,同时把其他线程的工作内存中对应的变量副本置为无效,以及每次使用前立即从主内存读取共享变量,那JMM又是如何达到这个目的呢?

有序性,编译器和处理器为了提高运算性能都会对不存在数据依赖的操作进行指令重排优化,在Java内存模型中,通过as-if-serialhappens-before(先行先发生) 来保证从重排的正确性,同时对于volatile变量有特殊的规则:对一个变量的写操作先行发生于后面对这个变量的读操作,那么Java内存模型底层是如何实现这一特殊规则的呢?答案就是内存屏障(Memory Barrier)。在Java内存模型中,主要有以下4种类型的内存屏障:

  • LoadLoad屏障: 对于Load1,LoadLoad,Load2这样的语句,在Load2及后续读取操作前要保证Load1要读取的数据读取完毕;

  • LoadStore屏障: 对于Load1,LoadStore,Store2这样的语句,在Store2及后续写入操作前要保证Load1要读取的数据读取完毕;

  • StoreStore屏障: 对于Store1,StoreStore,Store2这样的语句,在Store2及后续写入操作前要保证Store1的写入操作对其他处理器可见;

  • StoreLoad屏障: 对于Store1,StoreLoad,Load2这样的语句,在Load2及后续读取操作前,Store1的写入对所有处理器可见。

    如下图所示:

    • 对于volatile变量读,JMM会在读操作后面插入一个LoadLoad屏障、一个LoadStore屏障;

    • 对于volatile变量写,JMM会在写操作前加一个StoreStore屏障,在写操作后加一个StoreLoad操作。

    image-20210109195819646

到这里是不是可以发现:JMM对于volatile变量的可见性及有序性都是通过内存屏障来实现的

接着,深入分析volatile底层原理,从机器码的层面看看,对于volatile变量的特性是怎么实现的,首先我们先看一段代码如下:

public class VolatileTest {
    public static volatile int race = 0;
    public static int value = 0;
    public static void increase() {
        race++;
        value++;
    }
    private static final int THREAD_COUNT = 20;
    public static void main(String[] args) {
        Thread[] threads = new Thread[THREAD_COUNT];
        for (int i = 0; i < THREAD_COUNT; i++) {
            threads[i] = new Thread(() -> {
               for (int j = 0; j < 10000; j++) {
                   increase();
               }
            });
            threads[i].start();
        }
        while (Thread.activeCount()> 1) {
            Thread.yield();
        }
        System.out.println("race: " + race + " value: " + value);
    }
}

上述程序用20个线程对volatile变量race进行累加,每个线程累加10000次,如果能正确的并发执行的话应该是200000才对,最后多次运行结果都是一个小于200000的数字

image-20210108152530761

从这里也能看出,volatile变量并不能保证原子性,将上面的代码经过JITWatch工具得到汇编语句如下:

image-20210108194550071

通过汇编指令可以看出,被volatile修饰有一个lock指令前缀,lock指令的作用是将本地处理器的缓存写入内存,同时将其他处理器的缓存失效,这样其他处理需要数据计算时,必须重新读取主内存的数据,从而达到了变量的可见性的目的;对于禁止指令重排序,同样也是通过整条lock指令(lock add1$0x0, (%rsp))形成一条内存屏障,来禁止指令重排。

到此,我们已经分析了volatile变量具有的特性,以及JMM是怎么来实现volatile变量的特性。但是对于文章开头提出的,既然有缓存一致性协议来保证缓存的一致性,为什么还需要由volatile来保证变量的可见性这个问题好像还是没有答案。接下来将是本文的重点,从硬件层面出发,带你了解高速缓存、MESI协议等原理,层层深入,看完以后一定会对volatile变量有更加深入的理解。

4、高速缓存结构与MESI协议分析

首先高速缓存的内部结构如下所示: image-cache-struct

高速缓存内部是一个拉链散列表,是不是很眼熟,是的,和HashMap的内部结构十分相似,高速缓存中分为很多桶,每个桶里用链表的结构连接了很多cache entry,在每一个cache entry内部主要由三部分内容组成:

  • tag: 指向了这个缓存数据在主内存中的数据的地址

  • cache line: 存放多个变量数据

  • flag: 缓存行状态

    CPU访问内存时,会通过地址解码得到三个数据:index,用于定位数据在哪一个桶中;tag,通过tag确定在哪一个cache entry中;offset,通过偏移量来获取对应的数据。

    flag一共有四种状态分别是:

    • M(修改,Modified): 表示该cache line有效,且刚刚被修改过,与内存及其他高速缓存中数据不一致

    • E(独占,Exclusive): 表示该cache line有效,且正在被独占进行修改,其他处理器不能对它进行修改

    • S(共享,Shared): 表示该cache line有效,且数据与内存及其他高速缓存中一致

    • I(无效,Invalid): 表示该cache line无效

由此引出了MESI缓存一致性协议,MESI协议对所有处理器有如下约定:

各个处理器在操作内存数据时,都会往总线发送消息,各个处理器还会不停的从总线嗅探消息,通过这个消息来保证各个处理器的协作

同时MESI中有以下两个操作:

  • flush操作: 强制处理器在更新完数据后,将更新的数据(可能写缓冲器、寄存器中)刷到高速缓存或者主内存(不同的硬件实现MESI协议的方式不一样),同时向总线发出信息说明自己修改了某一数据

  • refresh操作: 从总线嗅探到某一数据失效后,将该数据在自己的缓存中失效,然后从更新后的处理器高速缓存或主内存中加载数据到自己的高速缓存中

接下来我们来说明在两个处理器情况下,其中一个处理器(处理器0)要修改数据的整个过程。假定数据所在cache line在两个高速缓存中都处于S(Shared)状态。

cpu_process

1、处理器0发送invalidate消息到总线;

2、处理器1在总线上进行嗅探,嗅探到invalidate消息后,通过地址解析定位到对应的cache line,发现此时cache line的状态为S,则将cache line的状态改为I,同时返回invalidate ack消息到总线;

3、处理器0在总线在嗅探到所有(例子中只有处理器1)的invalidate ack后,将要修改的cache line状态置为E(Exclusive),表示要进行独占修改,修改完以后将cache line状态置为M(Modified),同时可能将数据刷回主内存。

在这个过程中,如有其他处理器要修改处理器0中的cache line状态将会被阻塞。

同时,假如此时处理器1要读取相应的cache line数据,则会发现状态为I(Invalid)。于是处理器1向总线中发出read消息,处理器0嗅探到read消息后,将会从自己的高速缓存或者主内存中将数据发送到总线,并将自身对应的cache line状态置为S(Shared),处理器1从总线中接收到read消息后,将最新的数据写入到对应的cache line,并将状态置为S(Shared)。由此处理0与处理器1中对应的cache line状态又都变成了S(Shared)。

更新和读取数据的过程如下所示:

image-20210109211606795

image-20210109211645122

MESI协议能保证各个处理器间的高速缓存数据一致性,但是同样带来两个严重的效率问题:

  • 当处理器0向总线发送invalidate消息后,要等到所有其他拥有相同缓存的处理器返回invalidate ack消息才能将对应的cache line状态置为E并进行修改,但是在这过程中它一直是处于阻塞状态,这将严重影响处理器的性能

  • 当处理1嗅探到invalidate消息后,会先去将对应的cache line状态置为I,然后才会返回invalidate ack消息到总线,这个过程也是影响性能的。 基于以上两个问题,设计者又引入了写缓冲器无效队列。 在上面的场景中,处理器0,先将要修改的数据放入写缓冲器,再向总线发出invalidate消息来通知其他有相同缓存的处理器缓存失效,处理器0就可以继续执行其他指令,当接收到其他所有处理器的invalidate ack后,再将处理器0中的cache line置为E,并将写缓冲器中的数据写入高速缓存。处理器1从总线嗅探到invalidate消息后,先将消息放入到无效队列,接着立刻返回invalidate ack消息。这样来提高处理的速度,达到提高性能的目的。

    加入写缓冲器无效队列后,高速缓存结构如下图所示:

    image-20210110143559471

写缓冲器无效队列带来的问题:

写缓冲器无效队列提高MESI协议下处理器性能,但同时也带来了新的可见性与有序性问题如下: image-20210110150401017 如上图所示:假设最初共享变量x=0同时存在于处理0和处理1的高速缓存中,且对应状态为S(Shared),此时处理0要将x的值改变成1,先将值写到写缓冲器里,然后向总线发送invalidate消息,同时处理器1希望将x的值加1赋给y,此时处理器1发现自身缓存中x=0状态为S,则直接用x=0进行参与计算,从而发生了错误,显然这个错误由写缓冲器和无效队列导致的,因为x的新值还在写缓冲器中,无效消息在处理1的无效队列中。

为了解决这个问题出现了写屏障(Store Barrier)和读屏障(Load Barrier)两种内存屏障。

  • 写屏障:强制将写缓冲器中的内容写入到高速缓存中,或者将屏障之后的指令全部写到写缓冲器直到之前写缓冲器中的内容全部被刷回缓存中,也就是处理0必须等到所有的invalidate ack消息后,才能执行后续的操作,相当于flush操作;

  • 读屏障:处理器在读取数据前,必须强制检查无效队列中是否有invalidate消息,如果有必须先处理完无效队列汇总的无效消息,再进行数据读取,相当于refresh操作。

通过加入读写屏障保证了可见性与有序性。之所以说保证了有序性,是因为指令乱序现象就是写缓冲器异步接收到其他处理器中的invalidate ack消息后,再执行写缓冲器中的内容,导致本应该执行的指令顺序发生错乱。通过加入写屏障后保证了异步操作之后才能执行后续的指令,保证了原来的指令顺序。

在分析JMM保证volatile变量的有序性和可见性问题时,同样我们也说到是通过四种内存屏障的来实现的,那么上面的读/写屏障和JMM中四种内存屏障有什么关联呢?

  • 写屏障与(StoreStore、StoreLoad)屏障的关系:在volatile变量写之前加入StoreSore屏障保证了volatile写之前,写缓冲器中的内容已全部刷回告诉缓存,防止前面的写操作和volatile写操作之间发生指令重排,在volatile写之后加入StoreLoad屏障,保证了后面的读/写操作与volatile写操作发生指令重排,所以写屏障同时具有StoreStore与StoreLoad的功能
  • 读屏障与(LoadLoad、LoadStore)屏障的关系:在volatile变量读之后加入LoadLoad屏障保证了后面其他读操作的无效队列中无效消息已经被刷回到了高速缓存,在volatile变量读操作后加入LoadStore屏障,保证了后面其他写操作的无效队列中无效消息已经被刷回高速缓存。读屏障同时具有了LoadLoad,LoadStore的功能。

到这里,对于文章开头提出:既然存在MESI缓存一致性协议为什么还要volatile关键字来保证可见性和有序性的问题是不是就很清楚了呢?

参考:
[1]: 中华石衫
[2]: 深入理解Java虚拟机

更多精彩内容,请关注我的个人公众号,扫描下方二维码或者微信搜索:肖说一下