设计模式之单例模式,带你彻底搞清饿汉式、懒汉式,并能够手写单例模式

单例模式

什么是单例模式

我们知道单例模式顾名思义就是一个类只有一个实例对象,且不能通过 new 来创建该类的实例对象 ,当外部想要拿到这个类的实例的时候,不能直接获取,需要通过调用该类的方法 getInstance 从而得到这个唯一的实例对象。

由上面一段话我们可以分析出单例模式的几个特点

  • 一个类只有一个实例对象,不能直接访问 => 这个实例对象一定是 static ,private 的
  • 不能通过 new 来 创建实例对象 => 这个类的构造器是 private 的

通过上述分析我们能够得到一个简单的单例模式

public class Singleton {

    private static Singleton SINGLETON = new Singleton();

    private Singleton(){}

    public static HungrySingleton getInstance(){
        return HUNGRY_SINGLETON;
    }
}

单例模式的分类

思考一个问题:如果一个类使用单例模式,但是这个类的实例是非常消耗资源的,比如如下的情况

public class Singleton {

    private final byte[] resource = new byte[1024 * 1024 * 1024 * 10 ];
    private static Singleton SINGLETON = new Singleton();

    private Singleton(){}

    public static HungrySingleton getInstance(){
        return HUNGRY_SINGLETON;
    }
}

这个类在被加载的时候就生成了相应的实例对象,但是我们没有一开始就使用这个单例对象,就会造成内存浪费

因此我们只想在获取实例的时候才生成实例对象

这时单例模式就分成了两类:

  • 饿汉式:在一开始就实例化对象,也就是上述单例模式的实现
  • 懒汉式:在 getInstance 时创建实例对象

懒汉式单例模式

第一种实现

根据之前的思想,我们对代码做如下修改,就变成了懒汉式单例模式

public class LazySingleton1 {

    private static LazySingleton1 LAZY_SINGLETON;

    private LazySingleton1(){}

    public LazySingleton1 getInstance(){
        if (LAZY_SINGLETON == null){
            LAZY_SINGLETON = new LazySingleton1();
        }
        return LAZY_SINGLETON;
    }

}

我们在获取实例的时候判断一下实例对象是否为空,如果为空就创建一个新的实例对象

但是这个代码还是存在这一定的问题的:

在单线程的情况下没什么问题,但是如果使用多条线程同时调用 getInstance 方法,此时就会出现

# 线程A、B同时运行 getIsntance 方法

线程A 判断 LAZY_SINGLETON 为 null
线程B 判断 LAZY_SINGLETON 为 null

线程A 创建实例对象1
线程A 拿到实例对象1

线程B 创建实例对象2
线程B 拿到实例对象2

此时它们拿到的就不是同一个实例对象,这违反了单例模式的理念

第二种实现

在这种情况下我们很自然的就想到了通过加锁来保证对象只被创建了一次,代码如下

public class LazySingleton2 {

    private static LazySingleton2 LAZY_SINGLETON;

    private LazySingleton2(){}

    //    双重检测锁 DCL
    public LazySingleton2 getInstance(){
        if (LAZY_SINGLETON == null){
            synchronized (LazySingleton2.class){
                if(LAZY_SINGLETON == null){
                    LAZY_SINGLETON = new LazySingleton2();
                }
            }
        }
        return LAZY_SINGLETON;
    }

}

这里解释一下为什么要在同步代码块的内外都要加上判断,我们先假设同步代码块内部没有判断

public LazySingleton2 getInstance(){
    if (LAZY_SINGLETON == null){
        synchronized (LazySingleton2.class){
            LAZY_SINGLETON = new LazySingleton2();
        }
    }
    return LAZY_SINGLETON;
}
# 线程A、B同时运行 getIsntance 方法

线程A 判断 LAZY_SINGLETON 为 null
线程B 判断 LAZY_SINGLETON 为 null

线程A拿到锁,线程B被阻塞
线程A 创建实例对象1
线程A释放锁

线程B 创建实例对象2
线程B 拿到实例对象2

此时线程A和线程B拿到的还不是同一个实例对象,所以内部需要加判断

至于外面的判断则是为了提高效率,如果不加判断,每个线程调用 getInstance 方法都进入同步代码块并阻塞其他的线程,这会一定程度上的影响效率

尽管此时的代码已经非常完备了,但还是存在一定的问题。

第三种实现

public class LazySingleton3 {

    private static volatile LazySingleton3 LAZY_SINGLETON;

    private LazySingleton3(){}

    //    双重检测锁 DCL
    public LazySingleton3 getInstance(){
        if (LAZY_SINGLETON == null){
            synchronized (LazySingleton3.class){
                if(LAZY_SINGLETON == null){
                    LAZY_SINGLETON = new LazySingleton3();
                }
            }
        }
        return LAZY_SINGLETON;
    }

}

我们可以看到第三种实现知识在变量上加上了一个 volatile 关键字

volatile的特点:

  • 保证可见性
  • 不保证原子性
  • 禁止指令重排

我们这里用到了 volatile 的 禁止指令重排的特性

**指令重排:**在不影响执行结果的情况下对指令进行重新排序

对象的创建与引用粗略的可以理解为以下三个阶段:

  1. 为对象申请空间
  2. 初始化内存空间
  3. 变量指向对象

在指令重排的情况下有可能会重排为3、1、2

假如一个线程在创建对象的时候执行了3,由于1、2还没有执行,此时另一个线程判断到变量指向是 null 再次创建了对象,这又使两个线程获取的对象不同。

所以加上 volatile 关键字是非常有必要的

总结

  • 单例模式分为饿汉式和懒汉式
  • 懒汉式获取实例时要使用双重锁检测(DCL)
  • 懒汉式的变量要加 volatile 关键字

此外,通过反射也可能破坏单例模式,此时的解决方案是使用 Enum 枚举创建一个 getInstance 方法,枚举的底层实现还是一个类,它的构造器禁止使用反射,否则就会跑出异常,这样就彻底的解决了安全问题。

上一篇:Kotlin开发中的一些Tips,自学Android


下一篇:react之Lazy和Suspense(懒加载)