volatile 关键字作为Java虚拟机提供的轻量级同步机制,在Java并发编程中占据着重要的地位,但是深入理解volatile可不是一件简单的事,了解volatile的同学都知道,volatile变量保证了可见性,而可见性又与Java内存模型息息相关,所以本文先简单介绍内存模型相关概念,然后再从Java虚拟机层面剖析分析volatile变量,接着从硬件层面出发,带你层层深入了解volatile及其背后的故事。
由于现代计算机处理器与存储设备的运算速度存在几个数量级的差异,所以现代计算机都会在处理器与主内存之间加上高速缓存作为缓冲:将处理器计算所需数据复制到高速缓存,处理器直接从高速缓存中获取数据计算,同时处理器将计算结果放入缓存,再由缓存同步至主内存。
Java虚拟机为了达到“一次编译,到处运行”的目的,也有自己的内存模型,即Java内存模型(JMM)。Java内存模型作为一种规范,屏蔽了各种操作系统和硬件的内存访问规则,是计算机内存模型的一种逻辑抽象。它规定所有的变量都必须存在主内存中,每个Java线程都有自己的工作内存,工作内存中存放了所需变量的副本,Java线程对变量的操作必须在工作内存中,而不能直接操作主内存。
image
如上图所示,虽然这两种内存模型都能够解决运算速度不匹配的问题,但随之而来就是缓存不一致问题:多个处理器都有自己的高速缓存,但他们又共享同一主内存,从而造成了变量修改不可见问题。为了解决缓存不一致问题,需要处理器在处理缓存时满足缓存一致性协议,例如MESI协议。既然有缓存一致性协议的存在,为什么还需要volatile关键字来保证变量的可见性呢?
首先我们来说一下volatile变量具备以下特征:
那么,volatile变量是怎么保证变量的可见性和有序性的?
从Java内存模型层面来说: Java内存模型保证了volatile变量的可见性,也就是说JMM保证新值能马上同步到主内存,同时把其他线程的工作内存中对应的变量副本置为无效,以及每次使用前立即从主内存读取共享变量,那JMM又是如何达到这个目的呢?
有序性,编译器和处理器为了提高运算性能都会对不存在数据依赖的操作进行指令重排优化,在Java内存模型中,通过as-if-serial和happens-before(先行先发生) 来保证从重排的正确性,同时对于volatile变量有特殊的规则:对一个变量的写操作先行发生于后面对这个变量的读操作,那么Java内存模型底层是如何实现这一特殊规则的呢?答案就是内存屏障(Memory Barrier)。在Java内存模型中,主要有以下4种类型的内存屏障:
到这里是不是可以发现: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变量有更加深入的理解。
首先高速缓存的内部结构如下所示:
image-cache-struct
高速缓存内部是一个拉链散列表,是不是很眼熟,是的,和HashMap的内部结构十分相似,高速缓存中分为很多桶,每个桶里用链表的结构连接了很多cache entry,在每一个cache entry内部主要由三部分内容组成:
由此引出了MESI缓存一致性协议,MESI协议对所有处理器有如下约定:
各个处理器在操作内存数据时,都会往总线发送消息,各个处理器还会不停的从总线嗅探消息,通过这个消息来保证各个处理器的协作。
同时MESI中有以下两个操作:
接下来我们来说明在两个处理器情况下,其中一个处理器(处理器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协议能保证各个处理器间的高速缓存数据一致性,但是同样带来两个严重的效率问题:
写缓冲器和无效队列带来的问题:
写缓冲器和无效队列提高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)两种内存屏障。
通过加入读写屏障保证了可见性与有序性。之所以说保证了有序性,是因为指令乱序现象就是写缓冲器异步接收到其他处理器中的invalidate ack消息后,再执行写缓冲器中的内容,导致本应该执行的指令顺序发生错乱。通过加入写屏障后保证了异步操作之后才能执行后续的指令,保证了原来的指令顺序。
在分析JMM保证volatile变量的有序性和可见性问题时,同样我们也说到是通过四种内存屏障的来实现的,那么上面的读/写屏障和JMM中四种内存屏障有什么关联呢?
到这里,对于文章开头提出:既然存在MESI缓存一致性协议为什么还要volatile关键字来保证可见性和有序性的问题是不是就很清楚了呢?
作者:肖说一下
领取专属 10元无门槛券
私享最新 技术干货