写此文初衷源于昨晚线上代码抛出的一个空指针异常。单例是一个类,我们希望该类的对象在任意高并发多线程的调用下,只被初始化一次(例如用于系统环境和词典的加载等),后续线程均直接调用即可。我们首先来讲解几种常见的单例模式和其优缺点吧。

1.懒汉式

// 懒汉式,线程不安全
class SingletonDemo {
    
    // 定义一个私有的静态全局变量来保存该类的唯一实例
    private static SingletonDemo instance;
    
    private SingletonDemo() {
    
    }
    
    public static SingletonDemo getInstance() {
        // 这里可以保证只实例化一次
        if (instance == null) {  //语句(1)
            instance = new SingletonDemo();
        }
        return instance;
    }
}

以上代码很明显不能满足我们的要求。设想有n个线程同时执行语句(1),此时实例还未被初始化,因此均判断为null,于是这n个线程每一个都新建了该类对象。

2.懒汉式改进

// 懒汉式,线程安全,但不高效,因为任何时候只能有一个线程调用getInstance()方法。
class SingletonDemo2 {
    
    private static SingletonDemo2 instance;
    
    private SingletonDemo2() {}
    
    public static synchronized SingletonDemo2 getInstance() { //语句(1)
        if (instance == null) { //区域(1)
            instance = new SingletonDemo2();
        }
        return instance;
    }
}

我们使用synchronized来强制使每个线程串行执行语句(1),因此永远只有第一个线程新建了该类对象。那么这段代码缺点在哪呢?速度慢。假设现在有1000个线程,均在语句(1)处排队,当第一个线程创建新对象后,剩下999个线程仍然需要排队,进入区域(1)判断不为空并返回。

这里懒汉式的意思是:要用的时候才去new。区别于接下来要讲的:

3.饿汉式

/**
 * 饿汉式,单例的实例被声明成static和final,在第一次加载到内存中时会初始化。
 * 
 * 缺点:
 * 不是一种懒加载(lazy initlalization),在一些场景中无法使用:
 * 譬如Singleton实例的创建时以来参数或者配置文件的,在getInstance()之前必须调用
 */
class SingletonDemo5 {
    // 类加载时就初始化
    private static final SingletonDemo5 instance = new SingletonDemo5();
    
    private SingletonDemo5() {}
    
    public static SingletonDemo5 getInstance() {
        return instance;
    }
}

这段代码利用了jvm对private static final只初始化一次的特性,可以解决多线程问题,但是当我们要在getInstance()前做一些配置工作(例如初始化数据库连接等),那么这种方式就捉襟见肘了。

4.双重检验锁

// 双重检验锁(double checked) 
class SingletonDemo3 {
    
    private static volatile SingletonDemo3 instance;
    
    private SingletonDemo3() {}
    
    public static SingletonDemo3 getInstance() {
        if (instance == null) {    // 区域(1)
            synchronized (SingletonDemo3.class) {  // 区域(2)
                if (instance == null) {
                    instance = new SingletonDemo3(); // 语句(1)
                }
            }
        }
        return instance;
    }
}

双重检验锁(double checked)。注意到它有2次判断,一次在同步块内,一次在同步块外。假设现在有4个线程T1,T2,T3,T4。T1,T2进入了区域(1),T3,T4还没启动。T1能进入区域(2)创建instance成功,之后T2进入区域(2),判断非空并出来。此时T3,T4启动了,不会进入区域(1),且无需等待锁。

instance变量声明成volatile, 它可以禁止指令重排序优化。
volatile的两个作用:

  • 禁止指令重排优化。
  • 所修饰的变量一旦被修改,其他线程立即可见。(但是非原子操作,即其他线程可以感知到变量被修改,但无法使用val += 1这种语句使其原子增加。)因此可用于1读者n写者的场景,例如点击"游戏退出按钮",其他(金币累加/音效)线程将立即感知到。

更多单例模式可以看这里

5.异常问题回放

昨晚报出异常的代码片段如下:

class WrongSample {
    
    private volatile static WrongSample instance;
    
    private WrongSample() {

    }
    
    public static WrongSample getInstance() {
        if (instance == null) {    // 区域(1)
            synchronized (WrongSample.class) {  // 区域(2)
                if (instance == null) {
                    instance = new WrongSample(); // 语句(1)
                    instance.init(); //语句(2)
                }
            }
        }
        return instance;
    }

    private void init() {
    
    }
}

该代码使用双重检验锁构建了一个单例,且对单例进行初始化。那么空指针异常抛出对原因在哪呢?设想现在有线程T1,T2。线程T1进入区域(2),T2此时还未启动。T1执行了语句(1),但并未执行语句2,此时instance已经不是null,所以T2启动时在区域(1)判断非null将直接返回instance,但T2并未被初始化,由是产生异常。

解决方案:初始化操作放进构造函数,执行语句(1)时里暗含里执行构造函数。代码如下:

class WrongSample {
    
    private volatile static WrongSample instance;
    
    private WrongSample() {
        init();
    }
    
    public static WrongSample getInstance() {
        if (instance == null) {    // 区域(1)
            synchronized (WrongSample.class) {  // 区域(2)
                if (instance == null) {
                    instance = new WrongSample(); // 语句(1)
                }
            }
        }
        return instance;
    }

    private void init() {
    
    }
}

ringocat
28 声望3 粉丝