单例模式的进阶之旅

单例模式

单例模式(Singleton)是最简单又最实用的设计模式之一,《设计模式——可复用面向对象软件的基础》一书中这样描述单例模式:

  1. 意图

保证一个类仅有一个实例,并提供一个访问它的全局访问点。

  1. 动机

...让类自身负责保存它的唯一实例。这个类可以保证没有其他实例可以被创建(通过截取创建新对象的请求),并且它可以提供一个访问该实例的方法。这就是Singleton模式。

简单的单例模式

在Java中,一个最简单的单例模式是这样的:

public class Singleton1 {

    // 创建这个类的唯一实例
    private static Singleton1 instance = new Singleton1();

    // 构造方法私有化,禁止外部创建实例
    private  Singleton1() {}

    // 提供一个访问点用于获取单例
    public static Singleton1 getInstance() {
        return instance;
    }
}

懒加载

业务中可能会有这样的需求:这个单例不一定会被调用,如果一开始就将其实例化的话,会有浪费空间的可能。因此,我们需要在调用到getInstance()方法时再实例化单例。

public class Singleton2 {

    // 只声明不初始化
    private static Singleton2 instance;

    private  Singleton2() {}
    
    public static Singleton2 getInstance() {
        // 判断是否已被初始化
        if (instance == null){
            instance = new Singleton2();
        }
        return instance;
    }
}

并发安全

对于上一种单例模式的实现,在并发情景下,如果在一个线程判断了instance==null,而尚未实例化instance之际,另一个线程也走到了instance==null这一步,那么仍然会判断为true,导致的后果就是两个线程分别实例化了一个instance,这违背了我们使用单例模式的初衷。想要避免这种情况,也很简单,就是给getInstance()方法加上synchronized关键字,保证这是一个同步方法。

public class Singleton3 {

    private static Singleton3 instance;

    private Singleton3() {}

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

保证并发安全后的效率问题

上一种实现中,调用getInstance()方法时会导致整个方法被锁住,如果这个方法中还有一些比较耗时的业务代码的话,程序运行的效率都会受到比较大的影响,因此,我们需要缩小synchronized作用的范围。

public class Singleton4 {

    private static Singleton4 instance;

    private Singleton4() {}

    public static Singleton4 getInstance() {

        // 业务逻辑...

        if (instance == null) {
            synchronized(Singleton4.class) {
                if(instance == null) {
                    instance = new Singleton4();
                }
            }
        }
        return instance;
    }
}

在这种实现中,我们在不锁方法的前提下先执行一些业务逻辑,然后,如果此时有两个线程同时判断了instance==null,只有一个线程能获取Singleton.class的锁,然后实例化instance对象,再然后另一个线程也获取到了锁,此时它第二次判断instance==nullfalse,就会直接返回上一个线程已经实例化的instance单例。这种方法被称为DCL(Double Check Lock,双重校验锁),基本达到了我们的需求。

volatile

那么,刚刚这种实现是不是就万无一失了呢?并不是。这里涉及到了一些更底层的知识:

我们知道,所有编程语言最终都会转换成指令供CPU执行,例如在Java中创建一个对象Object o = new Object(),就至少包含下面3条CPU指令:

  1. 在内存中为该对象开辟一块空间,此时,该对象的状态称为“半初始化”,各成员的值都是默认值,例如int类型的默认值为0,引用类型的默认值为null
  2. 调用Object的构造方法,各成员初始化,如:int i = 1
  3. 将o的引用指向该对象

而CPU为了运行效率,会对一些指令进行重排。例如第2步中Object初始化的操作可能会比较耗时,而它又对第3步没有影响,CPU就可能会先执行第3步,将o指向开辟好的内存区域,然后再初始化o。

那么,这对我们的单例模式有什么影响呢?

我们再来模拟一下两个线程同时调用getInstance()的场景:线程A获取Singleton4.class锁之后,初始化instance过程中,由于指令重排,先将instance的引用指向某块内存区域,然而尚未完成instance对象的初始化,instance处于一个半初始化的状态。此时线程B第二次判断instance时发现它不为null,就会直接返回这个instance对象,而这个instance中各个成员变量都尚未被赋值。

在实际生产中,这样的问题发生的机率极低,但是一旦发生,就可能造成很大的损失并且难以排查。想要避免这种问题,关键就在于禁止CPU的“指令重排序”操作。而Java中提供了volatile关键字用于实现这一点,volatile的作用有两点:

  1. 保证内存可见性
  2. 禁止指令重排序

简单介绍一下“保证内存可见性”:

由于内存屏障的存在,线程操作某一个变量时,会先从主内存中获取一个该变量的副本存入自己的工作内存中,操作完后再写入主内存,而各个线程的工作内存之间是隔离的。volatile保证内存可见性的意思就是每当线程操作一个变量时,都会强制重新从主内存中读取,操作完存入主内存时,也会通知其他线程重新从主内存更新该变量。由于即时更新的原因,各个线程操作的变量可以看作不是缓存的副本,而是同一个,对变量的操作是彼此可见的,也就是“内存可见性”。

在声明instance实例时加上volatile关键字,就可以避免上述的因指令重排所引发的问题。

public class Singleton5 {

    private static volatile Singleton5 instance;

    private Singleton5() {}

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

这就是最终版的DCL,实现了懒加载、并发安全等一系列要求。

其他方式

除了上述方法外,Java中的单例模式还能通过静态内部类、内部枚举类来实现,事实上,使用枚举实现单例模式是《Effective Java》一书中最为推荐的方式,它不仅代码简洁,并且与DCL方式相比,它还能抵御基于反射的对单例模式的破坏。

public enum  EnumSingleton {
    INSTANCE;
    public EnumSingleton getInstance() {
        return INSTANCE;
    }
}

枚举方式在底层已经为我们实现了并发情况下的安全检查,并且通过反射创建对象时,由于该类是枚举类,会直接抛出异常。

单元素的枚举类型已经成为实现Singleton的最佳方法

posted @ 2020-05-01 00:37  周周zzz  阅读(116)  评论(0编辑  收藏  举报