一、前言
本文意在讲解java中volatile关键字的作用以及原理,因为该关键字可以说是JMM模型封装底层原语而提供出来的API,他的实现涉及到cpu的指令以及线程内存与主存间的交互过程,因此本文会从cpu到缓存内存,再到线程以及JMM,通过简单的介绍线程、缓存、cpu的大致流程后,在此基础之上讲解volatile的作用以及原理,以便于更加深刻的理解volatile关键字的含义。
二、计算机内存模型
cpu组成结构?cpu是计算机的核心,也称作中央处理单元(Central Processing Unit),它支配着整个计算机的运行,可以进行复杂的运算以及任务的调度。为了便于理解cpu以及本文中心,本文中忽略cpu其他部分,我们仅提出cpu中最重要的两个单元:Processing Unit(运算逻辑单元 PU)、Architectual State(架构状态单元 AS),PU主要负责运算,AS主要负责控制、调度、内存访问等。
超线程概念?按照上面的结构描述,理论上来讲cpu每个核心都是由一个PU和一个AS所构成的,也就是说一个核心同时只能处理一个线程(同一时刻PU只能进行单运算),为了增加cpu中PU工作效率,出现了超线程的概念,其大概实现方式是增加AS的数量,也就是说一个核心有一个PU,在一个AS基础上再增加一个AS但单元,可以理解为,本来一个cpu核心处理一个任务队列中的任务,现在一个内核对应着两个任务队列,实现的主要思想就是合理范围内压榨cpu,让其尽可能发挥运算能力,减少停滞的时间。在我们的计算机中会发现关于cpu核心的描述,2核4线程、4核8线程,这种描述可以理解为是超线程的实现的一个描述:
高速缓存产生?因为只有一个PU中心,因此我们依然认为一个核心同时刻只能处理一个线程。每当cpu进行运算时,所需要的数据信息需要从内存获取,但是随着cpu迅速的发展,内存数据提取的速度已经远远限制了cpu运算速度,在这种背景下,催生出了高速缓存的概念,作为主存和cpu之间的缓冲地带,其读取速度是远远大于内存的读取速度的,可以把数据从主存拿出来后放到高速缓存,这样下次访问就可以通过高速缓存,弥补内存和cpu之间的速度差异,这样可以尽可能避免内存读取,且尽可能的让cpu发挥其运算能力。
随着cpu的发展,一层缓存效果逐渐下降,就应运而生了多级缓存,目前高速缓存大致分为三层,L1、L2、L3(有的处理器没有L3),缓存级别越高,成本越高,缓存容量越小,简易的核心缓存和线程结构如下(不同cpu缓存级别不同):
对我们程序而言会产生什么问题?通过上图可以了解到,多核心的cpu,其每个核心有自己的L1、(L2),共享L2、(L3),因为核心之间的高速缓存中缓存着内存中的共享变量,而核心之间的高速缓存又因为隔离性互相不可知,只能通过主存方式进行交互,这种内存交互方式可能会引发缓存一致性的问题。
处理器优化、指令重排?除了缓存一致性问题之外,cpu有时候为了充分利用资源,会根据情况进行指令优化的操作,也就是说可能会对输入的代码按照非输入顺序执行,也就是所谓的处理器优化,虽然在单线程内不会影响指令段的结果,但是多心线程的操作下有可能会引发问题,下面会通过实例讲解。另外,cpu以及一些编译器会对代码进行指令的优化处理,发生指令重排。比如jvm编译器在运行时可能发生此操作,此刻new一个对象,因为初始化内存是比较耗时的,所以可能会先把对象指针指向该内存地址,然后在初始化对象。以上两种情形,发生的必要条件是单线程内,指令顺序改变不会影响该线程程序的执行结果。
我们知道并发编程中有三个特点属性,且我们上述提到的三个问题,也正是破坏这三个要素的相应体现:
可见性:线程之间对共享变量的修改,互相可知;(缓存一致性)
原子性:一个或者多个操作指令要么全部执行成功,要不都不执行;(处理器优化)
顺序性:程序的执行按照代码的顺序进行;(指令重排)
三、JMM模型
上面提到的几个问题,在jvm中提供了相对应的解决方案(底层通过调用cpu指令),定义了一套关于jvm中线程工作内存和主存的通信规范,以及封装了一些源语,在java中以关键字等方式提供出来,例如关键字volatile等,这个方案就是JMM模型。下图展示了jvm中线程通信的方式,以及jmm模型的工作范围:
可以看到JMM主要的工作区域是工作内存和主存之间的交互通信,包括jvm里这种工作内存-主存的内存架构方式也是JMM模型定义的一种规范。每个线程都有自己的工作内存,不可以直接在内存中直接操作变量,需要从主存取出放到自己的工作内存,然后在刷入主存,线程之间的工作内存互不可见,相互隔离。
JMM模型如何解决破坏并发三要素的问题?也就是JMM提供了哪些方式,让我们可以保证并发编程原子性、可见性、顺序性呢?
原子性:保证命令的执行原子性,我们可以使用java中提供的synchronized关键字,这个关键字实际上是封装了底层的原语而暴露于java中的一个关键字,可以使用在加载方法、代码块上。两种方式在底层的实现上是有一些差别的,使用了不同的字节码命令,前者是给方法加了一个ACC_SYNCHRONIZED标志,访问此方法的线程需要先获取锁,后者则是在同步代码块的前加monitorenter(加锁),后加monterexit命令(释放锁)。
可见性:上面提到JMM模型定义的java内存交互是以工作内存和主存的方式来进行信息交互的,如果要保持变量的可见性,那么就需要各个线程间的共享变量的工作内存副本互相知道变化,java中的volatile关键字可以实现可见性,一个线程操作完共享变量,立刻刷进主存,且使其他线程工作内存中该变量的缓存失效,从而每次读主存,保证可见性;除了volatile关键字可以保证可见性以外,synchronized和final关键字也可以保证此功能;
有序性:可以使用volatile关键字保证有序性,该关键字会建立内存屏障,使用jvm的字节码指令lock,保证前后的顺序,禁止指令重排序,下面会详细介绍关键字volatile;
四、volatile关键字
刚刚上文提到volatile可以保证可见性和以及有序性,下面我们详细的介绍一下该关键字实现的原理
作用?当我们用volatile关键字修饰一个变量后,会产生两个作用:
1.当有线程修改该变量后,会立即将该变量刷回主存,同时,会使其它线程工作内存的该变量副本缓存失效;
2.会保证该变量的有序性,避免编译器处理器的指令重排,该变量前后的指令顺序有序;
可见性实现原理?下面从实例方面我们看一下volatile关键字的的效果:
package com.ldy.com.test;
public class VolatileTest {
private static int i = 0;
static class MyRunnable implements Runnable{
@Override
public void run() {
try {
Thread.sleep(10L);//避免快于主线程
} catch (InterruptedException e) {
e.printStackTrace();
}
i++;
}
}
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start();
while(i == 0){//检测I值变化
}
System.out.println("over !");
}
}
那么程序的运行结果是什么呢?也许你可以想到,那就是main函数一直在while循环,尽管线程thread进行了i++操作,因为按照JMM内存模型的约定,线程的工作内存互相不可见,也就是说子线程thread虽然进行了i++操作,但是并没有主动通知主线程,告诉他工作内存中的i已经变更了,因此会一直进行while循环,可以看到thread线程睡了10ms,目的是为了将i的变更延迟到主线程读取i的时间点,这样才能验证该结论,否则主线程可能比thread线程执行的快。
这个时候,我们用volatile关键字对i进行修饰,可以知道执行结果,最终会退出:
这个实例可以验证volatile的第一个作用, 当有线程修改该变量后,会立即将该变量刷回主存,同时,会使其它线程工作内存的该变量副本缓存失效。因此当子线程修改了volatile关键字修饰的i后,会立刻将该值刷进主存,同时会使其他线程工作内存中的i的缓存失效,使得他们从主存获取最新的值,因此主线程检测到i值改变后,退出循环而结束。
lock前缀关键字?底层的原理在上面其实已经提及到了一些,本质上是命令前添加了一个指令前缀,也就是汇编语言中的lock前缀,该指令前缀有保证可见性以及有序性的作用。在cpu的架构中,多个cpu核心是通过总线进行内存操作,他们有各自有各自的高速缓存,但是都是通过总线进行传递信息的,因此可以各个核心可以通过“嗅探”总线上的消息,监听到他们的内存副本关联是否变更,从而判定缓存是否失效,再次读取该变量时则重新去主存读取。
当一个lock前缀修饰的指令进行内存修改操作时,处理器会发出一个lock信号,会进行一个明显的总线锁定操作。一般该锁定是由高速缓存锁或者总线锁来做。如果该内存访问有高速缓存并且只影响高速缓存的一行,则会使用缓存锁定,而系统总线和系统内存不会锁定,并且其他处理器会立即回写已经修改的数据,同时使他们的高速缓存失效;如果本次内存访问没有高速缓存或者占据了高速缓存不仅仅一行,那么就会使用总线锁定,此时总线不会响应其他处理器的总线控制请求,也就是阻塞其他处理器的内存操作,这种锁定效率是非常低下的,因此现在一般处理器都会采用缓存一致性的方式来保证缓存的数据的可见一致性。
时下比较常用的缓存一致性协议是MESI协议,工作机制使用刚刚上面提到的“嗅探”机制,所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线:缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个CPU缓存可以读写内存)。
CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器一写内存,其它处理器马上知道这块内存在它们的缓存段中已失效。
根据此结论,可以分析上述实例的执行过程,可以看到上面的实例中一共有两个线程,主线程和手动创建的子线程,
1.当主线程执行到while判断的时候,会去内存中取i的值,第一次从内存中取的i=0,放入工作缓存中,继续while判断;
2.子线程沉睡时间已过,当子线程修改volatile修饰变量i值后,子线程发出lock前缀指令,处理器接收到该指令后锁总线或锁定缓存,并将修改的i的最新值刷到主存;
3.主线程在cpu运行期间,嗅探到i值缓存的变化,使得高速缓存失效,需要到内存中获取最新值,此时i=1,退出while循环。
有序性实现原理?volatile使用内存屏障(Memory Barrier)来保证代码的顺序性,也就是禁止指令重排序,内存屏障的含义如名字所示,加了一道屏障,是不可以逾越的,强制保证某种顺序关系,在介绍内存屏障前,我们了解一下jmm的8种内存操作类型:
1.lock:锁定,作用于主存,标记一个变量为线程独占状态;
2.read:读取,作用于主存,将主存的变量值传递到工作内存;
3.load:加载,作用于工作内存,将load进来的变量存入工作内存变量副本;
4.use:使用,作用于工作内存,将该变量副本传给执行引擎,每当虚拟机需要使用一个变量的时候,都会出现该指令;
5.assign:赋值,作用于工作内存,将引擎出来的变量值赋值到工作内存中的变量副本,每当执行引擎需要赋值时使用;
6.store:存储,作用于工作内存,将工作内存中的变量传递到主存,以供write使用;
7.write:写入,作用于主存,将传递进来的变量值写入主存;
8.unlock:解锁,解除一个变量的锁定状态,其他线程可以锁定;
这些指令使用有一些注意事项,比如read和load、store和write两对指令必须成对的出现,不允许单一指令出现,但是指令之间可以插入其他的指令;一个变量在同一时刻只能被一个线程lock,但是可以被lock线程多次,unlock也要执行对应次数才能释放所有的锁;新的变量只能从主存中产生,也就是说assign、store、write的变量必然经历了load、use的过程;
int i = 1; //1
int j = 2; //2
x = i + j; //3
int y = 3; //4
图示的代码,x依赖于i和j的值,y无依赖,因此3肯定发生于1和2之前,但是4可能发生于3之前,这个包括编译器以及处理器的优化,假如处理器计算x的值时,发现j的值还没准备好,那么为了提高cpu的利用率,在不影响该线程计算结果的情况下,可能会优先执行4,然后回过头在执行3。
假设有如下代码片段,其中x是volatile修饰的片段:
i = 1; //1
j = 2; //2
x = true; //3 volatile
i = 3; //4
j = 4; //5
x = true是写操作,因此volatile关键字会在该行代码前后加上内存屏障,保证在执行该volatile变量的写指令时,前面都是完成的。
jmm定义了四种内存屏障指令:
LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的,会把缓存中的变量刷入主存。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
在我们jmm中按照保守的策略插入屏障规则如下:
在每一个写操作前面插入storestore指令;
在每一个写操作后面插入storeload指令;
在每一个读操作后面插入loadstore指令;
在每一个读操作后面插入loadload指令;
因此在上面的例子中,x = true写操作,前面插入storestore保证其他处理器可见,后面插入storeload刷新脏缓存到主存,且其他处理器该变量缓存失效。
其实内存屏障是硬件层的概念,在不同的硬件平台中,实现内存屏障的手段并不是一样,java通过屏蔽这些差异,统一由jvm来生成内存屏障的指令,在根据相应规则插入屏障,Lock是软件指令。
五、资源地址
文档:《Thinking in java》jdk1.8版本源码