单例模式与DCL双重校验锁

前言

在面试里面,单例模式是经常被问到的设计模式。今天正好学习完了《Java并发编程实战》,该书的最后一章讲得就是JMM(Java内存模型),其中就提到了以DCL方式实现单例模式的优缺点。

单例模式

单例模式的概念就不在这里赘述了。在保证线程安全的前提下,最简单的实现方式是“饿汉式”,即在加载单例类的字节码时,在初始化阶段对静态的instance变量进行赋值,代码如下。

//“饿汉式”实现线程安全的单例模式
public class Singleton {
    private static Singleton instance = new Singleton();
    
    private Singleton() {
    }
    
    public static Singletion getInstance() {
        return instance;
    }
}

如果我们希望延迟初始化这个单例对象,就不能使用上述的“饿汉式”实现,而要使用“懒汉式”的实现。最容易想到的一种实现方式当然是使用synchronized关键字对getInstance()方法进行修饰。代码如下。

//使用同步方法实现的单例模式
public class Singleton {
    private static Singleton instance;
    
    private Singleton(){
    }
    
    public static synchronized getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        
        return instance;
    }
}

这是最简单的单例模式的延迟初始化实现版本,并且通过synchonized锁住了Singleton这个类的字节码,保证了线程安全。但是,这种锁字节码的方式粒度太大,同一时间只能有一个线程执行同步方法拿到这个单例,因此,在高并发环境下,吞吐量严重受限。

为了提升并发性能,DCL(double checked lock)实现方式看起来是不错的选择。代码如下。

//实现双重校验锁实现单例模式
public class Singleton {
    private static volatile Singleton instance;
    
    private Singleton() {
    }
    
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized(Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        
        return instance;
    }
}

DCL方式将同步方法改成了同步代码块,锁的粒度缩小,并发性能更好。当单例对象已经被创建之后,多个线程可以同时执行第一个if条件判断并且拿到单例对象。当单例对象未被创建时,同一时间只有一个线程能进入同步代码块进行第二次if条件判断,如果发现此时单例对象仍没有被其他线程所创建,则创建单例对象。

DCL方式实现的关键点在于volatile关键字。volatile关键字有两个作用:(1) 保证共享变量在修改之后对于其他线程的可见性;(2) 禁止JVM在执行字节码时发生的指令重排。鄙人认为,volatile关键字在上述代码的真正作用是禁止指令重排而不是保证可见性。为什么这么说呢,因为同步代码块上的synchronized关键字本身就有保证可见性和原子性的作用。因此,如果从可见性的角度而言,大可不必使用volatile。换言之,当代码执行到同步代码块中的第二个if条件判断,如果先前已经有线程创建了对象,由于synchronized关键字能够保证可见性,当前线程的高速缓存中存的instance的值(null)就会失效,导致当前线程一定会到内存中重新读取instance的值进行第二次if判断。因此,volatile的真正作用就是防止在new这个单例对象时发生的指令重排现象,即防止其他线程访问并拿到未完全初始化的单例对象。问题来了,这种情况是如何发生的呢?下面我尝试从字节码的执行过程来进行分析。

我们单看instance = new Singleton()这行代码,其对应的字节码如下:

1 NEW // 在堆内存中分配内存,将指向该区域的引用放入操作数栈
2 DUP // 在操作数栈中复制引用
3 INVOKESPECIAL // 调用Singleton类的构造方法
4 PUTSTATIC // 将引用赋值给静态变量instance

在JVM执行以上字节码的时候,如果不加volatile关键字,那么可能在DUP指令(指令2)执行之后,跳过执行构造方法的指令(指令3),而直接执行PUTSTATIC指令(指令4),然后用操作数栈上剩下的引用来执行指令3。因为在单线程环境下,JVM认为打乱指令3、4的执行顺序并不会影响程序的正确性。但是,在多线程环境下,如果指令3、4发生重排,当执行完指令1、2、4之后,instance对象已经不再为null,此时来一个线程调用getInstance方法,就会拿到一个尚未完全初始化的对象,从而发生对象逃逸。这种现象在单例类的构造函数耗时很大时更加频繁。而volatile关键字的存在则告诉JVM,在处理被volatile修饰的变量时,禁止使用指令重排。

但是,volatile的禁止指令重排功能在Java 5及之后才有作用,因此,DCL的实现方式在早前的版本就不起作用了。根据《Java并发编程实战》的介绍:“DCL使用方法已经被广泛废弃——促使该模式出现的驱动力(无竞争同步的执行速度很慢,以及JVM启动很慢)已经不复存在,因为它不是一种高效的优化措施。”

因此,实现单例的更好方式,应该是使用静态内部类的延迟初始化机制。代码如下:

// 通过静态内部类的延迟初始化机制实现单例模式
public class Singleton {
    private Singleton() {
    }
    
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

上述代码只有在有线程调用getInstance方法时才会完成静态内部类Singleton.SingletonHolder的加载过程(类加载、链接、初始化)。

当然,单例模式还可以通过编写enum类来实现。代码就不写了吧。

你可能感兴趣的:(java,设计模式,单例模式)