最近话猫的一个读者去华为面试,反馈说一面面试官考了一个volatile的知识点,被问懵了,今天我们情景再现,彻底搞懂volatile。
面试官:写一个单例模式。
同学小A:(心中窃喜,可算是考到我手里了),赶紧把头一天晚上背的代码默写出来。
public class Singleton {
private volatile Singleton singleton;
private Singleton() {}
public Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}在这里插入代码片
面试官:为什么创建Singleton对象时已经使用synchronized加锁,还要使用volatile修饰呢?volatile有什么作用呢?说说底层实现原理?
同学小A:呃呃…
今天就来对volatile一探究竟,彻底拿下volatile。
我变了,你为什么看不到
我们先来看一段代码,分析下执行结果会是什么样?
public class TestVolatile {
private /*volatile*/ boolean flag = true;
public void m() {
System.out.println("m start");
while (flag) {
}
System.out.println("m end");
}
public static void main(String[] args) throws Exception {
TestVolatile test = new TestVolatile();
new Thread(() -> test.m()).start();
TimeUnit.SECONDS.sleep(1);
test.flag = false;
}
}在这里插入代码片
执行之后,发现test线程会一直卡在while死循环中,flag的变化,test线程竟然像看不见一样。放开flag变量的volatile修饰,再次执行,会看到代码如预期那样test线程跳出了死循环,flag的变化在线程间可见了。
为什么主线程对flag变更的修改了,test线程不可见呢?
由java内存模型我们知道,所有线程共享主内存,每个线程都有自己的工作内存。线程对变量的所有操作都必须在工作内存中进行,不能直接对主存进行操作。因此线程对flag的修改必须从主存中读取后,缓存到自己的工作内存中,在自己工作内存修改后,再写入到主内存中。所以,才出现了falg变成false,test线程依然一直卡在while死循环中,flag在线程间不可见的现诡异象。
那被volatile修饰之后,为什么又可见了呢?
上面分析了线程不可见是由于主内存中的共享变量,在被多个线程读取后加载到各自的工作内存,当其中有一个线程修改了共享变量写回主存之后,其他线程不能及时读取到该线程写入的最新值,所以导致了程序卡在死循环中。
而volatile关键字,是一种实现跨线程写入的内存保持可见性的机制。使用volatile修饰的变量在编译成汇编指令时会多了一个lock指令。lock汇编指令在硬件层可以基于总线锁或者缓存锁的机制来达到可见性。volatile底层实现实际是利用CPU的缓存一致性协议(MESI),通过总线嗅探机制感知到变量的变化,使自己工作内存的变量失效,重新从内存中读取。
注意: volatile如果修饰的是对象,保证可见性的是对象地址的值,如果对象内部的成员变量的值发生了变化,那这也是监控不到的。
我后面的指令怎么比我先执行
除了上面所说的缓存导致可见性外,还有一个因素指令重排序也会导致可见性问题。先来看一个简单的代码:
public class TestVolatile2 {
private static int x = 0, y = 0, a = 0, b = 0;
public static void main(String[] args) throws Exception {
while (true) {
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
t1.start();t2.start();
t1.join();t2.join();
System.out.println("x = " + x);
System.out.println("y = " + y);
}
}
}在这里插入代码片
这段代码执行的结果,无非是3种情况:
t1执行完a=1,t2开始执行b=1,结果:x = 1, y = 1;
t1先执行完之后,t2开始执行,结果:x = 0, y = 1;
t2先执行完之后,t1开始执行,结果:x = 1, y = 0;
但是通过多次实验会发现结果中出现 x = 0, y = 0的情况,仿佛t1的a = 1; 和t2的b = 1;互相之间不可见。这发什么了什么呢?
CPU在执行程序时,为了提高性能,常常会对指令做重排序。对于前后无依赖关系的指令,CPU会打乱指令的顺序去执行。
如上面出现x = 0, y = 0的情况时,代码执行顺序可能变成了
Thread t1 = new Thread(() -> {
x = b;
a = 1;
});
Thread t2 = new Thread(() -> {
x = a;
b = 1;
});在这里插入代码片
当你对x,y,a,b用volatile修饰之后,执行结果就只会是预期的那3种情况,这又是为什么呢?
被volatile修饰之后的变量,在底层实现上,对被修饰的变量前后加上内存屏障。内存屏障的作用可以通过防止CPU对内存的乱序访问来保证共享数据在多线程并行执行下的可见性。
不同的CPU所设计的内存屏障、逻辑也是不一样的(以下以因特尔X86 CPU来说)。
sfence:(store)| 在sfence指令前的写操作,必须在sfence指令后的写操作前完成。
lfence:(load) | 在lfence指令前的读操作,必须在lfence指令后的读操作前完成。
mfence:(mix)| 在mfence指令前的读写操作,必须在mfence指令后的读写操作前完成。
总结来说,volatile的作用一是保证线程可见性,二是禁止指令重排序。
volatile不能保证原子性
volatile为什么不能保证原子性呢?
我们来分析一下,i++是我们最常见的一行代码,其实它包含了三步 1)获取i 2)i自增 3)回写i。
i被volatile修饰时,当有a、b两个线程同时自增i时,i的值会出现被覆盖的现象,也就是线程不安全。volatile只能保证拿到的变量一定最新的,至于拿到的变量做了什么其他操作,volatile无法也没有办法保证它们的线程安全性。
你可能会有疑问,volatile不是保证了变量在线程间的可见性吗。如果线程a对i进行自增了以后,由于MESI协议的保证,应该通知其他缓存失效,并且重新load i。
load的前提是read,问题是,线程a对i进行了自增,线程b的缓存行内容的确会失效,但是线程b已经拿到了i,此时线程b中i的值已经运行在加法指令中,并不存在需要再次读取i的场景,当然是不会重新load i这个值的。
因此volatile不能保证原子性。
单例模式的双重检查
我们回到文章开头的面试题,单例模式的双重检查为什么必须要加volatile呢?
我们先看一下new一个对象的过程:1)申请内存(成员变量的值是默认值)
2)给对象的成员变量赋值
3)将对象的地址赋值给变量
new一个对象这3个步骤之间有可能会被打断,或者重排序。
syncronized保证原子性、可见性,不能禁止指令重排序。volatile可以保证有序性(禁止指令的重排序)、可见性,但是不保证原子性。
因此当new Singleton()对象的三个指令发生了重排序,比如上述的顺序调整为1 -> 3 -> 2 时。此时有两个线程 a,b 都调用getSingleton方法,a线程刚执行了new Singleton()的第3条指令,此时singleton已经不为null,是一个半初始化的对象,等待着给对象的成员变量赋值。此时线程b开始执行,执行到if (singleton == null) 时,发现不为null,直接返回了singleton对象,那么b线程就拿到了一个半初始化的对象。
public Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}在这里插入代码片
总结下今天的内容:volatile可保证可见性、防止指令重排序,但不能保证原子性,在单例模式的双重检查中,volatile 和 syncronized都必须有。
好了,volatile的分析就到这里了!!!
碎碎念
我输出的文章都是我多年面试大厂总结的经验,将各系列面试高频知识点抽出来进行全面解析,还有一些个人感悟等等。如果恰好对你有用处,【点赞、关注、在看】是我创作的最大动力!!!
我这里有份非常全的面试题,长达20万字,算是 Java 一整套技术栈都写了,包括 Java 基础,虚拟机,消息队列,框架等等。当然,还有通用基础知识,例如计算机网络,操作系统,Mysql,Redis 也都整理了,给大家看一下目录。
如何获取这份资料
关注公众号「话猫」,回复「1024」即可获取下载链接哦,以下是公众号