1. volatile的作用
下面这段代码,演示了一个使用 volatile 以及不使用volatile 这个关键字,对于变量更新的影响
package org.example.volatiletest;
public class VolatileTest {
public static /*volatile*/ boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
int i = 0;
while (!stop){
i++;
}
});
t.start();
System.out.println("start thread.");
Thread.sleep(1000);
stop = true;
}
}
使用volatile时,程序能够停止,不使用volatile时,可以看到程序无法停止
上面代码中,主线程对共享变量stop进行修改,子线程读取共享变量的值,但是实际上并未读取到主线程对stop变量的修改。加上volatile关键字就可以让程序停止,说明子线程读取到了主线程对变量stop的修改;
2. volatile如何保证可见性
多线程环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值。 可见性就是指,当一个线程修改一个共享变量时,另外一个线程能够读取到这个修改的值
- volatile的作用
volatile可以使得在多处理器环境下保证了共享变量的可见性 - volatile如何保证可见性
volatile使用lock指令锁定缓存行,将缓存行的数据立即写回到主内存中。
volatile使用内存屏障禁止指令重排序来保证有序性
安装java代码反汇编插件 hsdis-amd64.dll hsdis-amd64.lib到C:\Program Files\Java\jdk1.8.0_181\jre\bin\server ,然后在idea中配置jvm参数并指定jre路径:
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VolatileTest.*
启动运行,会看到,stop变量前面多了一个lock指令(不加volatile时,stop变量前面是不会有lock指令的):
3. volatile原理-Lock指令
要深入理解volatile背后的原理,先来看下与其原理相关的CPU术语:
Lock指令在多核处理器下回引发下面两件事情:
- 将当前处理器缓存行的数据写回到系统内存
- 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效
为什么要做这么两件事情呢?
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读取到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存,如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量锁在缓存行的数据写回到系统内存。 但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一直的,就会实现缓存一致性协议(所谓的MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作时,会重新从系统内存中把数据读取到处理器缓存里。
说了这么多,还是不知道为什么要这么做,本质上,这是由计算机硬件层面决定的,通过硬件层面来了解为什么有可见性的问题。
4. 可见性问题的本质
4.1 计算机设备速度差异
一台计算机中最核心的组件是 CPU、内存、以及 I/O 设备。在整个计算机的发展历程中,除了 CPU、内存以及 I/O 设备不断迭代升级来提升计算机处理性能之外,还有一个非常核心的矛盾点,就是这三者在处理速度的差异。CPU 的计算速度是非常快的,内存次之、最后是 IO 设备比如磁盘。而在绝大部分的程序中,一定会存在内存访问,有些可能还会存在 I/O 设备的访问。为了提升计算性能,CPU 从单核升级到了多核甚至用到了超线程技术最大化提高 CPU 的处理性能,但是仅仅提升CPU 性能还不够,如果后面两者的处理性能没有跟上,意味着整体的计算效率取决于最慢的设备。为了平衡三者的速度差异,最大化的利用 CPU 提升性能,从硬件、操作系统、编译器等方面都做出了很多的优化:
1.CPU 增加了高速缓存
2.操作系统增加了进程、线程。通过 CPU 的时间片切换最大化的提升 CPU 的使用率
3.编译器的指令优化,更合理的去利用好 CPU 的高速缓存然后每一种优化,都会带来相应的问题,而这些问题也是导致线程安全性问题的根源。
为了了解前面提到的可见性问题的本质,我们有必要去了解这些优化的过程
4.2 CPU高速缓存
线程是 CPU 调度的最小单元,线程设计的目的最终仍然是更充分的利用计算机处理的效能,但是绝大部分的运算任务不能只依靠处理器“计算”就能完成,处理器还需要与内存交互,比如读取运算数据、存储运算结果,这个 I/O 操作是很难消除的。 由于计算机的存储设备与处理器的运算速度差距非常大,所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中。
一级缓存和二级缓存:cpu各个core私有, 三级缓存:cpu多core共享
通过高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了一个新的问题: 缓存一致性。
CPU与内存交互:
读取流程:寄存器——>Load Buffer——>高速缓存(一级缓存——>二级缓存——>三级缓存)——> 内存
写入流程:寄存器——>Store Buffer——>高速缓存(一级缓存——>二级缓存——>三级缓存)—— >内存
高速缓存和内存之间通过内存一致性协议(比如MESI协议)保证数据一致性(可见性) CPU和高速缓存直接通过Load Buffer(Store Buffer)减少堵塞时间,提高性能.
4.3 缓存一致性(高速缓存导致缓存一致性问题)
有了高速缓存的存在以后,每个 CPU 的处理过程是,先将计算需要用到的数据缓存在 CPU 高速缓存中,在 CPU进行计算时,直接从高速缓存中读取数据并且在计算完成之后写入到缓存中。在整个运算过程完成后,再把缓存中的数据同步到主内存。在多 CPU 中每个线程可能会运行在不同的 CPU 内,并且每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题 。为了解决缓存不一致的问题,在 CPU 层面做了很多事情,主要提供了两种解决办法: 1. 总线锁 2. 缓存锁
- 总线锁与缓存锁
总线锁,简单来说就是,在多cpu下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的 。
如何优化呢?最好的方法就是控制锁的保护粒度,我们只需要保证对于被多个CPU缓存的同一份数据是一致的就行。在P6架构的CPU后,引入了缓存锁,如果当前数据已经被CPU缓存了,并且是要协会到主内存中的,就可以采用缓存锁来解决问题。 所谓的缓存锁,就是指内存区域如果被缓存在处理器的缓存行中,并且在Lock期间被锁定,那么当它执行锁操作回写到内存时,不再总线上加锁,而是修改内部的内存地址,基于缓存一致性协议来保证操作的原子性。
4.4 缓存一致性协议 MESI
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI等。最常见的就是MESI协议,MESI表示缓存行的四种状态,分别是:
- M(Modify) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
- E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
- S(Shared) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
- I(Invalid) 表示缓存已经失效
因此可见性本质是由于CPU高速缓存的出现使得多个CPU上缓存了同一个共享变量的数据,当一个cpu修改了自己缓存的数据,对其他的cpu不可见,造成的后果就是cpu对该数据进行操作时会读取到脏数据,造成不正确的结果。
4.5 MESI的优化——StoreBufferes
由于Store Bufferes的出现,使得CPU0把数据丢给storebuffer之后继续去执行其他的指令,这个地方就出现了指令重排序的问题。如下图所示:
假设cpu0去修改stop的值,写入之前,会先发起一个通知让其他cpu中的缓存失效,得到ACK回执前,一直处于阻塞状态,得到ACK之后,才会写入内存,虽然这个阻塞时间非常短暂,但是也是浪费了CPU的资源,因此, 引入了storebuffer进行优化,CPU0 只需要在写入共享数据时,直接把数据写入到 storebufferes 中,同时发送 invalidate 消息,然后继续去处理其他指令,当收到其他所有 CPU 发送了 invalidate acknowledge 消息时,再将 store bufferes 中的数据数据存储至 cache line中,最后再从缓存行同步到主内存。 1-4步骤中,cpu0可以执行其他指令,不存在阻塞等待,但是这样就带来一个新问题:指令重排序
4.6 指令重排序
MESI的优化存在两个问题:
1.数据什么时候提交是不确定的,因为需要等待其他 cpu给回复才会进行数据同步。这里其实是一个异步操作
2.引入了 storebufferes 后,处理器会先尝试从 storebuffer中读取值,如果 storebuffer 中有数据,则直接从storebuffer 中读取,否则就再从缓存行中读取
executeToCPU0(){
a=1;
b=1;
}
executeToCPU1(){
while(b==1){
assert(a==1);
}
}
引入Store Bufferes之后,就可能出现 b=1的判断返回true ,但是assert(a==1)返回false。
1.针对上图,假设cpu0修改a=0,但是它的缓存行中没有,a=0已经缓存在cpu1中,那么对cpu0对 a的修改会从CPU1的缓存行中发起读取及失效的操作(只有让CPU1的缓存行失效,cpu0修改a的值后,cPU1才能读取到正确的值)
2. CPU1将a放到失效队列
3. cpu0修改a的值: 先把a的值放到storebuffer中,得到其他cpu缓存行失效确认ACK信号后,再写入cpu0的缓存行,这个过程中,cpu0不需要等待,可以继续执行后续的指令b=1, 由于cpu0对b这个变量是独占,因此会直接修改缓存行
4.存在多个缓存行时,sotrebuffer的优化会使得CPU的操作变成异步化,这会导致对变量a的操作还没有结束,但是对变量b的操作已经结束了。这时候如果CPU1去执行assert(a= =1)这个操作,由于cpu1的缓存行还在,会读到a=0,因此这是cpu1判断结果是b= =1成立,a= =1不成立
整个操作相当于变成了cpu去执行 b = 1; a = 1;这样两个操作,跟预期的执行顺序完全不同。这就是指令重排序。
实际上,从java源代码到指令序列,编译器和处理器都会对指令做重排序,分三种类型:
1) 编译器优化的重排序
编译器(JIT编译器)在不改变单线程程序语义(as-if-serial)的前提下,可以重新安排语句的执行顺序;
as-if-serial语义:不管怎么重排序,单线程下,程序的执行结果不能被改变。编译和处 理器都必须遵循as-if-serial语义。如果操作之间没有数据依赖关系,就可以被重排序。
2)指令级并行的重排序
现代处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖,处理器可以改变语句对应机器指令的执行顺序
3)内存系统的重排序
由于处理器使用缓存和读、写缓冲区,使得加载和存储操作看上去可能是在乱序执行(处理器的重排序)
从java代码到最终实际执行的指令序列,会经历图中的三种重排序。2和3属于处理器重排序,这些重排序可能会导致多线程程序出现内存可见性问题。
指令重排序出发点是从硬件层面去提升CPU的利用率,对软件层面来说,如果能够禁止处理器对缓存行的优化,就可以避免指令重排序的问题, 于是,JMM提供了一种内存屏障的机制来避免指令重排序。
对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序,对于处理器重排序,JMM的处理器排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers)指令,通过内存屏障指令来禁止特定类型的处理器重排序。 JMM属于语言级的内存模型,它确保不同的编译器和不同的处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序,为程序提供一致的内存可见性的保证。
4.7 指令重排序的解决——内存屏障
内存屏障(Memory Barrier)与内存栅栏(Memory Fence)是同一个概念,不同的叫法。内存屏障是解决硬件层面的可见性与重排序问题。内存屏障是happen-before原则的实现。
X86的memory barrier指令包括lfence(读屏障) sfence(写屏障) mfence(全屏障)
- Store Memory Barrier(写屏障) ,告诉处理器在写屏障之前的所有已经存储在存储缓存(storebufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的
- Load Memory Barrier(读屏障) ,处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
- Full Memory Barrier(全屏障) ,确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作
volatile int a=0;
executeToCpu0(){
a=1;
//storeMemoryBarrier() 伪代码,加了volatile 之后,对a变量进行改写时,会自动生成一个写屏障,写入到内存
b=1;
// CPU层面的重排序会导致b=1先于a=1执行 ,通过插入一个写屏障,a变量的改变不会再写入storebuffer,而是直接写入内存中,并且不允许重排序
//b=1;
//a=1;
}
executeToCpu1(){
while(b==1){ //true
loadMemoryBarrier(); //伪代码,读屏障
assert(a==1) //false
}
}
上面伪代码的意图是:假设CPU正常执行a=1, b=1,然后在while中 判断b= = 1, 然后执行assert( a = = 1) ,如果前面执行时发生了指令重排序导致b=1先执行了,那么 while( b= = 1) 满足条件了,而a的值还未进行修改,实际的执行结果就和预期的不一致了。而通过volatile 修饰共享变量a, 则会在 a=1,b=1这两个操作之间产生一个内存屏障,让这两条指令顺序执行。
因此,volatile关键字实现共享变量可见性的本质就是通过内存屏障禁止了指令重排序 那么为什么前面生成的汇编代码并没有看到内存屏障指令呢? 这是由于不同的CPU架构问题,X86架构是一个强一致性架构,它会的#Lock指令的作用 等价于内存屏障。
5.Java内存模型JMM
不同的CPU架构,不同的操作系统,都会提供不同的内存屏障指令,因为产生可见性问题的根源不一样, Java代码想要实现一次编写,到处运行,就必须通过某种方式来屏蔽这种不同CPU,操作系统之间的内存屏障指令的差异,这种解决方式就是Java内存模型。
- Java内存模型抽象结构
在Java中,所有的实例,静态变量,数组元素都存在于对内存中,堆内存在线程之间共享 (所谓共享变量就是这三类变量),而局部变量,方法入参和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型影响。
Java线程之间的通信由Java内存模型(简称JMM)控制,JMM决定一个线程对共享变量的写入何时对另外一个线程可见。从抽象角度看,线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存(或者叫工作内存),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器,以及其他的硬件和编译器优化。Java内存模型抽象示意图如下:
如上图, 线程AB之间如果要通信的话,必须经历两个步骤:
(1)线程A把本地内存中更新过的共享变量刷新到主内存中去。
(2)线程B到主内存中去读取线程A之前已更新过的共享变量。
JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性的保证!但是JMM只是一个JVM层面的抽象概念,并没有实际解决可见性问题,而是在这个抽象模型中提供了一个高级指令进行统一的处理,比如通过volatile修饰共享变量, volatile在Java虚拟机中,会根据不同的硬件和操作系统生成不同的内存屏障指令,从而解决可见性的问题。 - JMM如何解决可见性和有序性问题
通过前面的内容可知,导致可见性问题有两个因素,一个是高速缓存导致的可见性问题,另一个是指令重排序。那JMM是如何解决可见性和有序性问题的呢?对于缓存一致性问题,有总线锁和缓存锁,缓存锁基于MESI协议。而对于指令重排序,硬件层面提供了内存屏障指令。而JMM在这个基础上提供了volatile、final等关键字,使得开发者可以在合适的时候增加相应相应的关键字来禁止高速缓存和禁止指令重排序来解决可见性和有序性问题。
6.volatile原理-JVM实现
在idea的target目录中找到VolatileTest.class, open in terminal, 然后执行 javap-v VolatileTest.class, 会看到VolatileTest中volatile的字节码:变量的声明标记中,多了一个ACC_VOLATILE
cpu在执行putstatic时,会执行下面的代码:cache会调用is_volatile()去判断是不是volatile修饰的(就是ACC_VOLATILE 的值是否为1,在accessQFlags.hpp中能看到),并且在最后会调用一个sotreload()方法去添加一个内存屏障!
OrderAccess.hpp 会根据不同的CPU架构和操作系统会做不同的内存屏障的实现,因此JVM层面加的storeload()方法就能够在不同平台之上都能实现内存屏障了。
查看 orderAccess_linux_x86.linline.hpp,可以看到它定义的四个内存屏障:
storeload()指令,又调用了fence()方法 ,这个方法在执行时,会在指令上面加上lock add 汇编指令,
7.著名的DCL(双重检查锁)问题
使用双重检查锁获取单例时,如果单例不加volatile关键字,可能会获取到不完整的对象。
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if(instance==null) {
synchronized(Singleton.class) {
if(instance==null) {
// new实例是三个指令,可能会发生指令重排序
instance = new Singleton();
}
}
}
return instance;
}
}
volatile作用:禁止instance = new Singleton()这条语句指令重排序,避免getInstance()方法第一个if判断为false,但instance实例没有初始化完成的问题。
instance = new Singleton()执行过程实际上是三个指令:
- 在堆中分配对象内存
- 填充对象必要信息+具体数据初始化+末位填充
- 将引用指向这个对象的堆内地址
第二步和第三步可能会发生重排序,导致instance引用不为null,但是实例没初始化完成。
8. Happens-Before模型
有了volatile关键字,是否所有情况下一定要使用volatile关键字呢? 并非如此,有些情况下我们不需要通过volatile关键字去保证可见性,某些操作天生就具备线程可见性。
JSR133使用happenes-before概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以在不同线程之间,因此JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a b两个操作在不同线程中执行,JMM会保证a操作对b操作可见)
JSR133对happens-before关系的定义如下:
(1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
(2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定顺序来执行,如果重排序之后的执行结果,与按照happens-before关系来执行的结果一致,那么JMM允许这种重排序。
JMM遵循一个原则,只要不改变程序的执行结果,编译器和处理器怎么优化都行。JMM这么做是因为,程序员对着这两个操作是否真的被重排序并不关心,程序员关心的是程序的执行结果不能被改变。因此,happens-before和 as-if-serial在语义上是一回事,区别在于,
- as-if-serial 语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变;
- as-if-serial 语义给编写单线程程序的程序员提供了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
happens-before和 as-if-serial 的目的都是为了在不改变程序执行结果的前提下尽可能地提高程序执行的并行度。
happens-before有下面几个规则:
- 程序顺序规则(as-if-serial语义):
不能改变程序的执行结果(在单线程环境下,执行的结果不变);依赖问题, 如果两个指令存在依赖关系,是不允许重排序 - 传递性规则:
如果a happens-before b ,且 b happens- before c 那么 a happens-before c - volatile变量规则:
volatile 修饰的变量的写操作,一定happens-before后续对于volatile变量的读操作 通过内存屏障机制来防止指令重排
对volatile变量规则,《并发编程的艺术》中有如下片段:
假设有如下代码:
public class VolatileExample{
int a=0;
volatile boolean flag=false;
public void writer(){
a=1; //普通变量的修改 操作1
flag=true; //volatile变量flag的修改 操作2
}
public void reader(){
if(flag){ //读取flag的值,volatile规则保证flag的值一定是true 操作3
int i=a; //读取普通变量i的值,一定是1 操作4
}
}
}
操作1 happens-before 2 是否成立? 是 (顺序规则,另外,第一个操作是普通变量读/写,第二个操作是volatile写时,JMM会禁止这两个操作的重排序,因此1一定 happens-before2)
3 happens-before 4 是否成立? 是 (顺序规则,重排序不能影响程序的逻辑)
2 happens -before 3 ->volatile规则, 对volatile变量flag的修改一定happens-before于 对变量flag的读
1 happens-before 4 ; (happens-before的传递性) i=1成立.
- 监视器锁规则:
对一个锁的解锁,happens-beofre于随后对这个锁的加锁;
int x=10;
synchronized(this){
//后续线程读取到的x的值一定12
if(x<12){
x=12;
}
}
假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12。
- start规则:(线程启动hb原则)
同一线程的start方法happens-beofre于 此线程的其它方法
假设有一个线程,线程里面去读取x的值,在线程启动之前,修改x的值,那么线程中一定能够读取到x修改后的值,这就是start规则
public StartDemo{
int x=0;
Thread t1 = new Thread(()->{
// 主线程调用 t1.start() 之前, 所有对共享变量的修改,此处皆可见
// 此例中,x==10
if(x==10) {// 这个判断一定是true
}
});
// 此处对共享变量 x 修改
x = 10;
// 主线程启动子线程
t1.start();
}
- join规则 调用join方法的线程,该线程的执行结果一定对于join方法后续的操作可见
package org.example;
public class JoinDemo {
// 意图: 在子线程修改i的值,通过调用子线程的join方法让主线程能够读取到这个修改后的值
private static int i=10;
public static void main(String[] args) throws InterruptedException {
Thread t=new Thread(()->{
i=30;
});
t.start();
//调用join方法的线程中的执行结果对于后续的main线程可见.
t.join(); //Happens-Before模型
//如果子线程不加睡眠,不调用t.join,那么i大部分情况下输出10,因为线程从启动到运行还需要一段不确定的时间
System.out.println("i:"+i);
}
}
上面这段代码,底层执行逻辑如下:
join代码如下,可以看到它调用了wait方法去阻塞线程:
而wait方法的调用,一定会有地方去唤醒被阻塞的线程。在线程run方法执行结束后,jvm底层最终会调用被阻塞线程的notify方法去唤醒被阻塞的线程:
- final关键字提供内存屏障的规则
- 对象创建hb原则:
一个对象的初始化完成happens-beofre于 finalize方法调用 - 锁的hb原则:
同一个锁的unlock操作happens-beofre于此锁的lock操作
注: 本文参考资料:
微信公众号艾编程的文章: 一线大厂架构师整理:java并发编程实践教程
机械工业出版社- 方腾飞《并发编程的艺术》