美团面试题:DCL单例模式需不需要volatile?

业余草

共 4215字,需浏览 9分钟

 · 2021-06-01

最近有粉丝收到美团的面试,去试了一试,结果没有过。然后在群里分享面试经历,其中有一个面试题《DCL 单例模式到底需不需要 volatile?》引起了大家的争议,今天我们一起来讨论讨论这个面试题!

既然讲到单例,我们先来看几个经典的单例实现。

public class Xttblog {
private static Xttblog instance = new Xttblog();
private Xttblog() {}
public static Xttblog getInstance() {
return instance;
}
}

上面的代码是一种最经典,最简单的单例模式。这种写法无论在单线程还是多线程环境下都不会出现任何安全性问题。但是,这种实现方式有一个缺点:无论这个单例是否被使用,都会在内存中创建一个这样的单例。所以出现了后续的懒加载实现方式。

懒加载单例模式

public class Xttblog {
private static Xttblog instance;
private Xttblog() {}
public static Xttblog getInstance() {
if (instance == null) { // 1
instance = new Xttblog();
}
return instance;
}
}

懒加载即使用单例的时候才初始化。但是,这种实现方式有一个明显的缺点:当在多线程环境下,多个线程同时运行到代码 1 处时,instance 为 null,这几个线程都会创建自己的单例,而不是使用的同一个单例对象。如果给 getInstance()方法加上同步关键字呢?

懒加载加锁单例模式

public class Xttblog {
private static Xttblog instance;
private Xttblog() {}
public static synchronized Xttblog getInstance() {
if (instance == null) {
instance = new Xttblog();
}
return instance;
}
}

方法加锁的实现方式自然能保证多线程环境下的安全性,但是方法加锁的方式会严重影响性能。接下来考虑细粒度的加锁方式——代码块加锁。

public class Xttblog {
private static Xttblog instance;
private Xttblog() {}
public static Xttblog getInstance() {
if (instance == null) { // 1
synchronized (Xttblog.class) {
instance = new Xttblog();
}
}
return instance;
}
}

但是这种细粒度的加锁方式并不能保证多线程环境下的安全性。举例说明:A,B 两个线程同时运行到代码 1 处,接下来两个线程会竞争 Xttblog 类锁。假如 A 线程获得了锁,A 线程继续执行,直到 A 线程创建一个 Xttblog 实例对象,A 线程释放锁。这时 B 线程获取到锁,依然是继续执行,此时 B 线程仍然会创建一个 Xttblog 实例对象。A,B 两个线程就创建了两个不同的 Xttblog 实例对象。接下来继续改进,如果在同步代码块中再加一层 check,check instance 是否为null —— 双重验证(DCL —— Double Check Lock)

DCL(Double Check Lock)单例模式

public class Xttblog {
private static Xttblog instance;
private Xttblog() {}
public static Xttblog getInstance() {
if (instance == null) { // 1
synchronized (Xttblog.class) {
if (instance == null) { // 2
instance = new Xttblog();
}
}
}
return instance;
}
}

根据上面的解析,A 线程执行完毕释放锁后,B 线程获取到锁时,再次检查 instance 是否为 Null。由于 synchronized 可以保证线程可见性,所以 A 线程中对 instance 赋值后会将值刷新到主存中去,并且导致所有线程中关于 instance 的线程本地缓存值都会失效。B 线程运行到代码 2 处时,B 线程的 instance 在线程本地缓存中的值已经失效,所以会重新去主存中去拿,这时 B 线程在代码 2 处的返回值为 false。所以 B 线程不再创建新的对象,而直接返回。双重验证总归可以了吧?答案还是 No。这是很多人认为理所当然的,感觉经得起推敲。但是,如果大家了解对象的创建过程,并且知道在多线程环境下,线程有可能会使用一个未完全初始化的对象,就会明白为什么这种方案还是不行。接下来大概描述一下对象的创建过程,以及什么是未完全初始化的对象。这也是美团面试官问到这道题的真实意图

Java 对象的创建过程

public class TestNewObj {
public static void main(String[] args) {
T t = new T();
}

}
class T {
int m = 10;
}

执行 javac ./TestNewObj.java 得到 class 文件。

执行 javap -c ./TestNewObj.class 查看字节码指令,本例如下:

查看字节码指令

创建对象的指令就在红色圈中。

对象的创建过程用到的指令

对象的创建过程new Object(),根据如上 Java 代码进行如下描述:

  1. 申请内存,此时 m 的值是默认值 0

  2. 调用构造方法,此时 m 值为 8

  3. 建立关联,t 指向申请的内存

这 3 个步骤分别对应上图指令中的 0, 4, 7。

public class Xttblog {
private static Xttblog instance;
private Xttblog() {}
public static Xttblog getInstance() {
if (instance == null) { // 1
synchronized (Xttblog.class) {
if (instance == null) { // 2
instance = new Xttblog();
}
}
}
return instance;
}
}

然后回想一下 DCL 单例中,如果存在这样一种情形:

两个线程 A、B,线程 A 获取到锁,当线程 A 创建对象 T,指令执行到指令 0 时(此时 m = 0),发生了指令重排序,指令 4 和指令 7 位置互换,即由之前的执行顺序 4->7 变成了 7->4。此时发生指令重排时,会将一个半初始化的对象与 t 建立关联,并且此时的 m=0。所以当线程 B 到达代码 1 处时判断 instance 是否为 null,此时 instance 已经不为 null 了,就直接用了一个半初始化状态的对象,m = 0,这就是其安全性问题所在。要问这个情况如何验证,抱歉,以普通项目超低并发的水平做不到,但是相信如果有阿里那样的并发量,一定会出现。

Java对象创建过程

根本原因在于,Java 对象的创建不是原子性操作,所以有指令重排序的可能。为了禁止指令重排序,所以要引入 volatile。终于点题了—— DCL 单例模式需不需要volatile?为什么?

答案是肯定的,需要 volatile

public class Xttblog {
private static volatile Xttblog instance;
private Xttblog() {}
public static Xttblog getInstance() {
if (instance == null) { // 1
synchronized (Xttblog.class) {
if (instance == null) { // 2
instance = new Xttblog();
}
}
}
return instance;
}
}

所以 volatile 在 DCL 单例中不是使用它的线程可见性,而是禁止指令重排序

结论

对象的创建不是原子性操作,所以有指令重排序的可能。为了禁止指令重排序,所以要引入 volatile。

浏览 7
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报