08 利用类的静态初始化同步解决单例模式陷阱
Diego38 86 1

1. 前言

面试过程中,经常被要求写一段单例模式的代码,看似容易,实际上会有不少同学写不对,表面上面试官考察的是设计模式方面的知识,实则是看候选人并发编程的功底。

本小节我们就列举下单例模式有哪些错误写法,会产生什么问题,以及正确的单例模式的多种实现。

2. 单例模式陷阱

一个好的单例模式一般需要我们需要采用延迟初始化的方式来降低创建对象的性能开销,我们看一段实现延迟初始化的单例模式代码:

public class BaseSingleton {
    private static BaseSingleton instance;
    private String name;

    private BaseSingleton(String name) {//构造器设置为私有,其他类无法直接调用新建实例
        this.name = name;
    }

    /**
     * 获取实例的唯一方法
     * @return
     */
    public synchronized static BaseSingleton getInstance() { //加锁访问,instance实例不存在就新建一个
        if (instance == null) {
            instance = new BaseSingleton("hello world");
        }
        return instance;
    }
}

以上代码实现一个 BaseSingleton 的延迟初始化单例,是通过在获取实例方法上加锁实现的,防止在多线程场景下实例被创建多次,加锁后的 getInstance 方法变成了一个原子操作,同时,根据 happens-before 规则:对一个监视器 (Synchonized) 的解锁 happens-before 于每一个后续对同一个监视器的加锁,instance 属性在多线程下可见。

这段代码就正确的实现了单例模式,但会有一个问题,获锁的过程需要线程排队,会产生一定的性能开销,如果对 instance 先做一次非空判断势必会节省一部分性能消耗。

我们看下改进后的代码

public class BaseSingleton {
    private static BaseSingleton instance;
    private String name;

    private BaseSingleton(String name) {
        this.name = name;
    }

    /**
     * 获取实例的唯一方法
     * @return
     */
    public  static BaseSingleton getInstance() {
        if (instance == null) {                //1. 非空检测
            synchronized(BaseSingleton.class) {//2. 类对象加锁
                if (instance == null) {        //3. 获取锁后的二次检测,防止其他线程创建后当前线程重复创建,根据happens-before法则,这里能被正确读取
                    instance = new BaseSingleton("hello world");//4. 分配内存地址,实例初始化,将内存地址赋值于instance
                }
            }

        }
        return instance;
    }
}

去掉 getInstance 方法上的 synchronized 修饰,改为双重检测来实现,方法入口处对 instance 做第一次非空判断,如果非空直接返回实例引用,否则进入加锁操作,这样做的好处是减小了加锁的粒度,而且实例初始化之后线程无需通过加锁访问,降低了同步开销。

看似没有问题,但通过 getInstance 获取的实例对象有可能没有被初始化,问题出在操作 1 上,在首次对 instance 做非空检测时是游离于加锁之外的,即第一次非空检测所发生读操作不能保证 instance 是可见的 (happens-before 规则也不成立)。

其中操作 4 实际上有三个操作组成的,分别是

  • 分配对象内存空间地址
  • 对 BaseSingleton 调用构造器进行初始化 new BaseSingleton (“hello world”)
  • 将内存地址赋值于 instance

在这三个操作中,根据 as-if-serial 法则,当只有一个线程操作时,实例初始化和内存地址赋值即使产生重排序也不会改变执行结果。但是当多线程环境下,情况就不一样了。

举一个可能发生的操作序列,线程 A 首次执行 getInstance 方法,加锁成功顺序执行 2、3、4 操作,在操作 4 时发生了指令重排序,内存地址赋值先于实例初始化执行。然后此时线程 B 执行第一次非空检测时读到的 instance 引用地址非空,但却得到了还未初始化的实例对象,属性 name 不是 hello world 而是 null。

知道上述代码发生的线程不安全问题来自于指令重排序,我们就需要对症下药,禁止指令重排。

3. 单例模式的正确写法

3.1 通过 volatile 修饰

我们只需要将 instance 增加 volatile 修饰,就避免了上例中指令重排。

public class BaseSingleton {
    private static volatile BaseSingleton instance;
    private String name;

    private BaseSingleton(String name) {
        this.name = name;
    }

    /**
     * 获取实例的唯一方法
     * @return
     */
    public  static BaseSingleton getInstance() {
        if (instance == null) {                //1. 非空检测,volatile读
            synchronized(BaseSingleton.class) {//2. 类对象加锁
                if (instance == null) {        //3. 获取锁后的二次检测
                    instance = new BaseSingleton("hello world");//4. volatile写,不会发生指令重排
                }
            }
        }
        return instance;
    }
}

根据 happens-before 规则,对 volatile 字段的写入操作 happens-before 于每一个后续的同一个字段的读操作,volatile 读都能读到上一次对该字段的 volatile 写,所以上述代码是线程安全的延迟初始化的单例模式。

上述双重检测的单例模式代码不仅满足线程安全并且相对直观明了,但是代码看上去显得冗长;如果我们经常创建单例模式,难免在书写时有所遗漏,比如忘记第二次检测,或者忘记加锁,有没有更加简洁标准的单例模式方案呢?接下来我们就看一种简洁的实现方式。

3.2 通过类初始化的加锁保证

JVM 要求在访问类的实例变量和静态变量时都会触发类的初始化,并且类只会被初始化一次,我们可以根据该特性实现延迟初始化。

我们看以下代码

public class StaticSingleton {
    private static class StaticSingletonHolder {
        public static StaticSingleton instance = new StaticSingleton("hello world"); //1. 构造instnace实例,类的初始化是一个加锁同步操作
    }

    private String name;
    private StaticSingleton(String name) {
        this.name = name;
    }

    /**
     * 获取实例的唯一方法
     * @return
     */
    public  static StaticSingleton getInstance() {
        return StaticSingletonHolder.instance;//2. 首次调用会触发StaticSingletonHolder的初始化,执行1操作
    }
}

我们将 instance 唯一的实例隐藏在 StaticSingletonHolder 类里,只有在 StaticSingletonHolder 的静态变量被访问时才会执行 StaticSingletonHolder 的类初始化,对 StaticSingleton 进行实例化赋值给 instance。

JVM 规定每一个类或 interface,都有一个初始化锁与其对应,有一个初始化状态标记 state,在上述代码中, 当首次调用 getInstance 时触发了 StaticSingletonHolder.instance 的访问,进而触发了 StaticSingletonHolder 类初始化,在类的初始化过程中 JVM 会对类进行加锁同步,保证类只被初始化一次。

为了方便理解,我们将类的初始化状态显示到代码中来,来看下整个执行过程

public class StaticSingleton {
    private static class StaticSingletonHolder {
        //隐藏的初始化标记cinit
        public static volatile  boolean cinit;
        public static StaticSingleton instance = new StaticSingleton("hello world"); //1. 构造instnace实例,类的初始化是一个加锁同步操作
    }

    private String name;
    private StaticSingleton(String name) {
        this.name = name;
    }

    /**
     * 获取实例的唯一方法
     * @return
     */
    public  static StaticSingleton getInstance() {
        //return StaticSingletonHolder.instance;  //2. 原有的2操作翻译成3、4、5操作
        if(!StaticSingletonHolder.cinit) {            //3. 隐藏操作,判断类的初始化状态
            synchronized (StaticSingletonHolder.class) { //4. 隐藏操作,对初始化执行加锁
                //执行StaticSingletonHolder的类初始化
                //将cinit设置为true
                //cinit=true
            }
        }
        return StaticSingletonHolder.instance;//5 . 直接读取instance变量
    }
}

我们将隐藏的类初始化状态用 cinit 表示,并且用 volatile 修饰,初始值是 false,当执行 2 操作时,实际上线程是先判断 cinit 状态值,然后进行加锁执行类初始化操作。

根据 happens-before 规则,对变量 cinit 的 volatile 写 happens-before cinit 的 volatile 读,又根据 happens-before 的传递规则,instance 的写 happens-before cinit 的 volatile 写,cinit 读 happens-before 于 instance 的读。

即 happens-before 的顺序串联起来就是 instance 写 —> cinit 写 —> cinit 读 --> instance 读,因此 instance 写 happens-before instance 读,多线程环境下,instance 是可见的,不会有线程安全问题。只有在访问 instance 变量时才会触发 StaticSingletonHolder 类的初始化所以满足延迟初始化要求,同时所有实例都指向 instance 地址,并且类只被初始化一次,所以满足单例要求。

上述代码在满足单例模式、延迟初始化、线程安全的同时,保持代码短小精悍,是单例模式最优的方案。

4. 总结

单例模式有很多种写法,好的单例模式代码应该同时满足延迟初始化和线程安全要求,但在满足初始化条件时我们往往会陷入单例模式陷阱,代码出现了线程安全问题。 在了解了线程安全产生的根源后,我们有两种方式来解决单例模式陷阱,一种是基于 volatile 关键字实现单例模式,一种是基于类初始化实现单例模式。这两种方案各有优点,第一种直观明了无需额外的类进行辅助,第二种代码简洁易读。 image

预览图
评论区

索引目录