JDK 源码阅读007:volatile 关键字
1 引言
多个线程共用一个共享变量,会遇到并发读写的问题,volatile 关键字就是来解决这个问题的。
本文将会从以下几个方面来讲解 volatile:
- cpu 缓存模型
- Java 内存模型
- 原子性、可见性、有序性
- volatile 的作用
- volatile 的底层原理
2 CPU 缓存模型
现代的计算机技术,内存的读写速度没什么突破,cpu如果要频繁的读写主内存的话,会导致性能较差,计算性能就会低,不适应现代计算机技术的发展,于是又在 CPU 中加了几层缓存,如下图所示:
这样 CPU 可以直接操作自己对应的高速缓存,不需要直接频繁的跟主内存通信,这样可以保证 cpu 的计算效率。
但是这样会产生并发问题:假设某个时刻 CPU a 更新了本地缓存的 flag,但此时还没更新到主内存,CPU b 此时读取到的值还是旧值。这便产生了一致性的问题。
其实上述场景只是并发问题中的一个,本质上都是因为各个 CPU 的本地缓存跟主内存之间没有同步,一个数据,在各个地方,可能都不一样,这样就导致了数据的不一致。
3 MESI
对于这个缓存不一致的问题,在计算机上古时期,采用了一种总线加锁机制。简单来说就是,某个cpu如果要修改一个数据,会通过一个总线,对这个数据加一个锁,其他的cpu就没法去读和写这个数据了,只有当这个cpu修改完了以后,其他 cpu 才可以读到最新的数据。
但这种总线加锁机制显然太过粗暴,可以想象的是,如果并发数比较大,那么效率肯定很低。
现在计算机流行的是 MESI 协议:
缓存行有4种不同的状态:
-
已修改Modified (M)
缓存行是脏的(dirty),与主存的值不同。如果别的CPU内核要读主存这块数据,该缓存行必须回写到主存,状态变为共享(S).
-
独占Exclusive (E)
缓存行只在当前缓存中,但是干净的(clean)--缓存数据同于主存数据。当别的缓存读取它时,状态变为共享;当前写数据时,变为已修改状态。
-
共享Shared (S)
缓存行也存在于其它缓存中且是干净的。缓存行可以在任意时刻抛弃。
-
无效Invalid (I)
缓存行是无效的
线程修改了缓存行,会刷到主内存中,CPU 会采用嗅探机制,将其他 CPU 的对应缓存行设置为无效状态,强制其他 CPU 从主内存中读取最新的值。
通过这样的机制,我们才能保证线程工作内存和主内存是一致的。
4 Java 内存模型
Java内存模型是跟cpu缓存模型是类似的,基于cpu缓存模型来建立的java内存模型,只不过java内存模型是标准化的,屏蔽掉底层不同的计算机的区别。
首先我们来看看几个概念:
-
read:从主存读取
-
load:将主存读取到的值写入工作内存
-
use:从工作内存读取数据来计算
-
assign:将计算好的值重新赋值到工作内存中
-
store:将工作内存数据写入主存
-
write:将store过去的变量值赋值给主存中的变量
可以参考下图来记忆:
5 可见性、原子性、顺序性
-
可见性:前述问题讲述了主内存和工作内存中最新修改的值不可见的问题,其实就是可见性的问题,voilatile 可以解决此问题。
-
原子性:volatile 一般意义上并不能保证原子性(i++ 的操作不是原子性操作)。
-
有序性:还有一个问题是指令重排序,编译器和指令器,有的时候为了提高代码执行效率,会将指令重排序,就是说比如下面的代码
flag = false;
// 线程1:
prepare(); // 准备资源
flag = true;
//线程2:
while(!flag){
Thread.sleep(1000);
}
execute(); // 基于准备好的资源执行操作
重排序之后,让flag = true
先执行了,会导致线程2直接跳过while等待,执行某段代码,结果prepare()方法还没执行,资源还没准备好呢,此时就会导致代码逻辑出现异常。
6 happens-before 原则
上文说过,编译器、指令器可能会对代码进行重排序,但是不能乱排,要遵守一定的规则,这个规则就是 happens-before 原则,只要符合 happens-before 的原则,那么就不能胡乱重排,反之,那就可以进行重排序。
happens-before原则:
-
程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
-
锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
-
volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。volatile变量写,再是读,必须保证是先写,再读。
-
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
-
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
-
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
-
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
-
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
7 volitile 底层实现原理:lock 指令以及内存屏障
前文已经讲述过,volatile 可以保证可见性和有序性,那么留给我们的就有两个问题:
- volatile 是如何保证可见性的?
- volatile 是如何保证有序性的?
7.1 volatile 是如何保证可见性的
对 volatile 修饰的变量,执行写操作的话,JVM会发送一条lock前缀指令给CPU,CPU在计算完之后会立即将这个值写回主内存,同时因为有MESI缓存一致性协议,所以各个CPU都会对总线进行嗅探,自己本地缓存中的数据是否被别人修改。
如果发现别人修改了某个缓存的数据,那么CPU就会将自己本地缓存的数据过期掉,然后这个CPU上执行的线程在读取那个变量的时候,就会从主内存重新加载最新的数据了。
7.2 volatile 是如何保证有序性的
Java 内存模型里有 4 种内存屏障。
- LoadLoad
- StoreStore
- LoadStore
- StoreLoad
Load1:
int localVar = this.variable
LoadLoad屏障
Load2:
int localVar = this.variable2
LoadLoad屏障:Load1;LoadLoad;Load2,确保Load1数据的装载先于Load2后所有装载指令,他的意思,Load1对应的代码和Load2对应的代码,是不能指令重排的
Store1:
this.variable = 1
StoreStore屏障
Store2:
this.variable2 = 2
StoreStore屏障:Store1;StoreStore;Store2,确保Store1的数据一定刷回主存,对其他cpu可见,先于Store2以及后续指令
LoadStore屏障:Load1;LoadStore;Store2,确保Load1指令的数据装载,先于Store2以及后续指令
StoreLoad屏障:Store1;StoreLoad;Load2,确保Store1指令的数据一定刷回主存,对其他cpu可见,先于Load2以及后续指令的数据装载。
对于volatile修改变量的读写操作,都会加入内存屏障
每个volatile写操作前面,加StoreStore屏障,禁止上面的普通写和他重排;每个volatile写操作后面,加StoreLoad屏障,禁止跟下面的volatile读/写重排。
每个volatile读操作后面,加LoadLoad屏障,禁止下面的普通读和voaltile读重排;每个volatile读操作后面,加LoadStore屏障,禁止下面的普通写和volatile读重排。
8 经典的双重检查锁
不使用 volatile 的双重检查锁
public class DoubleCheckSingleton {
// 私有变量
private static DoubleCheckSingleton instance;
// 公共方法
public DoubleCheckSingleton getInstance() {
if(instance == null) {
synchronized (DoubleCheckSingleton.class) {
if(instance == null) {
instance = new DoubleCheckSingleton();
}
}
}
return instance;
}
}
在执行这一行代码的时候:
instance = new DoubleCheckSingleton();
实际上这个步骤并不是原子性的,有三个过程:
- 分配内存空间
- 调用构造器方法,执行初始化
- 将对象指向刚分配的内存空间
但是有些编译器为了性能的原因,可能会将第二步和第三步进行重排序,顺序就成了:
- 分配内存空间
- 将对象指向刚分配的内存空间
- 调用构造器方法,执行初始化
这样就会造成多线程在调用改方法时,有可能会得到一个未被初始化的对象,此时也就是说没有保证可见性。
我们可以使用 volatile 来实现可见性。
public class DoubleCheckSingleton {
// 私有变量
private volatile static DoubleCheckSingleton instance;
// 公共方法
public DoubleCheckSingleton getInstance() {
if(instance == null) {
synchronized (DoubleCheckSingleton.class) {
if(instance == null) {
instance = new DoubleCheckSingleton();
}
}
}
return instance;
}
}