扫盲细节,到底该如何正确地写出单例模式?

133 阅读3分钟

单例模式算是设计模式中最容易理解,也是最容易手写代码的模式,但是其中的坑却不少,很多都是一些老生常谈的问题,如何创建一个线程安全的单例?什么是双检锁?我们知道单例模式一般分两种,即懒汉式和饿汉式,以下逐一分析。

懒汉式,线程不安全

public class Singleton {
    private static Singleton instance;
    private Singleton (){}

    public static Singleton getInstance() {
     if (instance == null) {
         instance = new Singleton();
     }
     return instance;
    }
}

这段代码简单明了,而且使用了懒加载,但是却存在致命的问题。当有多个线程并行调用 getInstance() 的时候,就会创建多个实例,也就是说在多线程下不能达到仅存在一个实例的效果。

懒汉式,线程安全

为了解决上面的问题,最简单的方法是将getInstance() 方法设为同步(synchronized)。

public static synchronized Singleton getInstance() {
    if (instance == null) {
        instance = new Singleton();
    }
    return instance;
}

虽然做到了线程安全,解决了多实例的问题,但它并不高效。

双重检验锁模式实现单例

双重检验锁模式是一种使用同步代码块加锁的方法,会有两次检查instance==null,一次在同步块外,一次在同步块内。那为什么在同步块内还要校验一次呢?是因为可能会有多个线程一起进入同步块外的if,如果在同步块内不进行二次校验的话就可能出现多个实例。

public static Singleton getInstance() {
    if (singleton == null) {
        synchronized (Singleton.class) {
            if (singleton == null) {
                singleton = new Singleton();
            }
        }
    }
    return singleton;
}

很遗憾,以上方式也不是很完美,问题在于singleton = new Singleton()这段代码,这并非是一个原子操作,事实上在 JVM 中对这段代码大概做了3 件事:

  1. 给singleton分配内存
  2. 调用Singleton的构造函数来初始化成员变量
  3. 将singleton对象指向分配的内存空间

但是在JVM的即时编译器中存在指令重排序的优化,也就是上面的第二步和第三步的执行顺序得不到保证,最终执行顺序可能是1-2-3也可能是1-3-2,如果是后者的话,则3执行完毕且2未执行之前,getInstance()被其他线程调用,这时singleton已经不是null,但却没有初始化直接返回singleton然后使用,此时就会报错。为了解决这个问题,我们需要将singleton声明为volatile就行了。

public class Singleton {
    private static volatile Singleton singleton;
    private Singleton() {}

    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

使用volatile不仅仅是保证线程在本地不会有singleton副本,每次去内存中读取,还有另一个重要特性:禁止指令重排序优化。

饿汉式

这种方式很简单,单例的实例被声明成了static和final,在第一次加载到内存中就会被初始化,所以创建的实例本身是线程安全的。

public class Singleton {
    public static final Singleton singleton = new Singleton();
    private Singleton() {}

    public static Singleton getInstance() {
        return singleton;
    }
}

饿汉式的缺点是它不是懒加载模式,单例会在加载类后一开始就被初始化。且这种模式在某些场景下无法使用,比如Singleton实例的创建时依赖参数或者配置文件,在getInstance()之前必须调用某个方法设置参数。