面试官:你平时是怎么创建单例的?
我:我一般用DCL双重检锁的方式来创建单例,然后为 instance 加上 volatile 修饰,防止 DCL 失效。
面试官:那你可以具体说说 volatile 吗?
我:行!
相信很多 Andorid程序员跟我一样,最开始接触到 volatile 这个关键字是在创建单例的时候,如:
public class SingleTon {
//为了防止出现 DCL失效问题,加上 volatile 关键字
private static volatile SingleTon instance;
public static SingleTon getInstance() {
if (instance == null) {
//同步锁,保证同一时刻只有一个线程进入该代码块。
synchronized (SingleTon.class) {
if (instance == null) {
instance = new SingleTon();
}
}
}
return instance;
}
}
当我们使用双重检锁(DCL)来创建单例的时候,我们会为 instance 加上 volatile关键字修饰,来防止出现DCL失效。这里其实就是利用 volatile 可以禁止指令重排序功能,来防止出现 DCL 失效问题。
那 volatile 到底是如何防止出现DCL失效,是怎么做到的呢?让我们一起来往下学习。
另外如果你想进一步了解 Synchronized,可以看我的另一篇文章 Android程序员重头学Synchronized
就如刚刚那个创建单例的代码来说,我们需要考虑其在多线程下的运行情况,也就是在多线程并发中,线程是否安全?那线程在什么样的情况下我们可以称之为是线程安全呢?
答: 线程在保证 可见性、有序性、原子性 的情况下,就可以称之为是线程安全的。
那可见性、有序性、原子性又是什么呢?别急~,在介绍这三种特性之前,我们需要先了解一下 Java内存模型。
Java内存模型(Java Memory Model,JMM): 是 Java虚拟机规范中定义的,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java程序在各种平台下都能达到一致的并发效果,JMM 规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
嗯~~??你还是去一下看 深入理解Java虚拟机 12.3 Java内存模型 吧。
我们都知道在计算机中 CPU 的计算速度是非常快的,但是绝大多数的计算任务光靠 CPU 是完不成的,CPU 需要与内存进行交互,也就是从内存中获取数据及将计算结果写入到内存中,相对比,这个速度就很慢了。因此,间接导致了 CPU 的计算速度大打折扣,所以为了解决这个问题,现在 CPU 厂商会在 CPU 中内置高速缓冲存储器,CPU 不再直接与内存进行交互,而是与高速缓冲存储器进行信息交换。
之前看到的一个段子,很贴切了:
内存:你跑慢点行不行?
CPU:跑慢点你养我吗?
内存:我不管!
CPU:那我只能找高速缓冲存储器了!
那这高速缓冲存储器是什么呢?百度百科是这么介绍的:
高速缓冲存储器是存在于主存与CPU之间的一级存储器, 由静态存储芯片(SRAM)组成,容量比较小但速度比主存高得多, 接近于CPU的速度。
主要由三大部分组成:
- Cache存储体:存放由主存调入的指令与数据块。
- 地址转换部件:建立目录表以实现主存地址到缓存地址的转换。
- 替换部件:在缓存已满时按一定策略进行数据块替换,并修改地址转换部件。
从此,CPU 直接从高速缓冲存储器中读取数据,计算速度大大提升,但这也存在了一个问题,那就是高速缓冲存储器与主内存数据的同步问题,准确的说是在多核多线程条件下就会发生高速缓冲存储器中的数据内容与内存中的数据内容不一致问题。
如上图所示,线程1 持有的是高速缓存1,线程2 持有的高速缓存2,高速缓存1 与 高速缓存2 中的数据都是从同一个内存中读取的。一开始,两个线程的数据内容肯定是一样的,但是两个线程都可以更改自己持有的高速缓存中的数据内容,并且两者互不干扰,这也就导致了数据不一致问题。
比如:一开始,两个线程都从内容中读取了 num = 1, 但是过了一会,线程2 将 num 改为了 2,然后写入到内存中,这时,线程1 没有再去内存中拿新的 num = 2 新值,而是直接用 num = 1 这个旧值,这就存在问题了。
所以为了保证可见性,当一个线程更新了内存中的共享变量时,需要通知其他线程重新从内存中读取值。
关于内存与工作内存,在 《深入理解Java虚拟机 12.3Java内存模型》 中是这么介绍的:
每条线程还有自己的工作内存(可与处理器的高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。
ok,了解了可见性,接着我们再来看看有序性。
看一段代码
int age = 10;
boolean isAdult = false;
//修改数值
age = 20; //修改年龄
isAdult = true; //修改是否成年
针对上述代码,你觉得是会先修改年龄再修改是否成年呢?还是先修改是否成年然后再修改年龄呢?
答案是:都不一定!
按照代码顺序,肯定是会先执行 age = 20;
然后再执行isAdult = true;
,但其实JVM会考虑性能效率问题然后对指令进行重排序,所以答案是不一定。
为了提高性能,编译器和处理器可能会对指令做重排序,重排序可以分为三种:
原子性(Atomicity)就是指对数据的操作是一个独立的、不可分割的整体,即:一个操作或多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么都不执行。
JMM 来直接保证的原子性变量操作有 read、load、use、assign、store、write 这六个。
所以大致认为基本数据类型访问、读写都是具备原子性的。但如果需要一个更大范围的原子性保证,JMM 还提供了lock 和 unlock 操作来完成。
JMM 并没有把 lock 和 unlock 直接开放出来供用户使用,但是提供了 monitorenter 与 monitorexit 两个直接码来隐式使用这两个操作。如果你不了解 monitorenter 与 monitorexit 这两个指令,可以看我的另一篇文章 Android程序员重头学Synchronized。
现在,我们回归一开始的 DCL失效问题。
public class SingleTon {
//为了防止出现 DCL失效问题,加上 volatile 关键字
private static volatile SingleTon instance;
public static SingleTon getInstance() {
if (instance == null) {
//同步锁,保证同一时刻只有一个线程进入该代码块。
synchronized (SingleTon.class) {
if (instance == null) {
instance = new SingleTon();
}
}
}
return instance;
}
}
其实 DCL失效的本质,正是由于指令重排序导致的,在具体一点就是上述代码中的instance = new SingleTon();
这条实例化 SingleTon 对象代码,因为它不是一个原子操作。
在JVM中,实例化一个对象分为三个步骤:
但由于 JVM 会对指令进行重排序,所以上面的步骤2与步骤3顺序可能发生改变,可能会变成:
所以在多线程的条件下,刚刚的 DCL 代码可能会出现这样的情况:
线程1获取锁,进入同步代码块,这时instance == null
,所以执行instance = new SingleTon();
,给 instance 分配内存空间
,然后指向刚刚分配的空间地址
,这时候也就意味着 instance 不为 null 了,然后刚准备执行 SingleTon() 构造方法来初始化时
,这时线程2调用了getInstance()
方法,此时instance != null
,所以会直接返回一个不为 null 但是未完成初始化的 instance 对象。
所以我们为 instance 加上 volatile 关键字修饰,来禁止指令重排序,就可以避免这个问题出现。
那 volatile 是怎么做到的呢?
volatile 其实通过内存屏障(Memory Barrier)
来防止指令重排序的,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad屏障 | Load1;LoadLoad;Load2 | 该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作 |
StoreStore屏障 | Store1;StoreStore;Store2 | 该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作 |
LoadStore屏障 | Load1;LoadStore;Store2 | 确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作 |
StoreLoad屏障 | Store1;StoreLoad;Load2 | 该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令 |
为 volatile写操作的前后都插入StoreStore屏障
操作,保证写操作按顺序将缓存中的数据写入内存中。
为 volatile读操作的后面分别插入LoadLoad屏障
与LoadStore屏障
,保证读操作按顺序将内存中的数据复制到缓存中。
当共享变量被 volatile 修饰后,在多线程环境下,当一个线程对它进行修改值后,会立即写入到内存中,然后让其他所有持有该共享变量的线程的工作内存中的值过期,这样其他线程就必须去内存中重新获取最新的值,从而做到共享变量及时可见性。
volatile 可以保证可见性,禁止指令重排序,而且又说它比 Synchronized 轻量,那这样的话,我们是不是直接用 volatile 就行了呀,还用啥 Synchronized。
别急,让我们来看段代码:
public class VolatileIncreaseTest {
public static volatile int count = 0;
public static void increase() {
count++;
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
System.out.println(Thread.currentThread().getName() + " race = " + count);
}
}).start();
}
}
}
其执行结果为:
Thread-5 count = 26031
Thread-7 count = 24936
Thread-3 count = 20780
Thread-9 count = 25867
Thread-6 count = 25720
Thread-1 count = 18145
Thread-2 count = 19876
Thread-4 count = 22125
Thread-8 count = 25488
Thread-0 count = 18527
咦~ ,我不是为 count 变量加上了 volatile 关键字修饰了吗?他不是可以在多线程并发环境下保证及时可见性吗?怎么 count 最终的输出结果没有增加到 100000 呢?
你可以自己试着去写一遍这个代码,其实 IDE 会给你提示:
提示我们说:我们对 volatile 修饰的 count 进行非原子性操作。
那如何才能保证原子性呢?
可以用 Synchronized 关键字(如果你想进一步了解 Synchronized,可以看我的另一篇博客 Android程序员重头学Synchronized)
针对上述代码,我们为 increase() 方法加上 Synchronized 关键字,如:
public static synchronized void increase() {
count++;
}
你可以发现 IDE 的警告消失了,再次执行一下,其结果如下:
Thread-1 count = 89753
Thread-8 count = 100000
Thread-3 count = 98947
Thread-9 count = 97470
Thread-6 count = 88132
Thread-0 count = 71229
Thread-4 count = 96263
Thread-2 count = 91294
Thread-5 count = 98571
Thread-7 count = 99592
根据结果可以发现 count 最终会增加到 10000,达到了预期效果。
所以volatile不能保证原子性
。
volatile关键字用于修饰变量,保证该变量在某一线程中数值发生更新时,其他持有该共享变量的线程可以及时知道,从而及时更新到最新的数值,即保证线程及时可见性,volatile 还可以禁止指令重排序,从而保证有序性,但是 volatile 不能保证原子性,所以不能保证线程安全,但是如果 volatile 修饰的变量的所有的操作都是原子性的(比如修饰一个 flag 变量,flag 只有赋值操作,赋值操作是原子性的),那么也是可以保证是线程安全的。
参考文献:
深入理解Java虚拟机
一文解决内存屏障
OK, 到这文章也就结束了。
其实分享文章的最大目的正是等待着有人指出我的错误,如果你发现哪里有错误,请毫无保留的指出即可,虚心请教。 另外,如果你觉得文章不错,对你有所帮助,请给我点个赞,就当鼓励,谢谢~Peace~!