大师兄

27 | 单例模式:如何创建单一对象优化系统性能?

你好,我是刘超。

从这一讲开始,我们将一起探讨设计模式的性能调优。在《Design Patterns: Elements of Reusable Object-Oriented Software》一书中,有23种设计模式的描述,其中,单例设计模式是最常用的设计模式之一。无论是在开源框架,还是在我们的日常开发中,单例模式几乎无处不在。

什么是单例模式?

它的核心在于,单例模式可以保证一个类仅创建一个实例,并提供一个访问它的全局访问点。

该模式有三个基本要点:一是这个类只能有一个实例;二是它必须自行创建这个实例;三是它必须自行向整个系统提供这个实例。

结合这三点,我们来实现一个简单的单例:

//饿汉模式
public final class Singleton {
private static Singleton instance=new Singleton();//自行创建实例
private Singleton(){}//构造函数
public static Singleton getInstance(){//通过该函数向整个系统提供实例
return instance;
}
}

由于在一个系统中,一个类经常会被使用在不同的地方,通过单例模式,我们可以避免多次创建多个实例,从而节约系统资源。

饿汉模式

我们可以发现,以上第一种实现单例的代码中,使用了static修饰了成员变量instance,所以该变量会在类初始化的过程中被收集进类构造器即<clinit>方法中。在多线程场景下,JVM会保证只有一个线程能执行该类的<clinit>方法,其它线程将会被阻塞等待。

等到唯一的一次<clinit>方法执行完成,其它线程将不会再执行<clinit>方法,转而执行自己的代码。也就是说,static修饰了成员变量instance,在多线程的情况下能保证只实例化一次。

这种方式实现的单例模式,在类初始化阶段就已经在堆内存中开辟了一块内存,用于存放实例化对象,所以也称为饿汉模式。

饿汉模式实现的单例的优点是,可以保证多线程情况下实例的唯一性,而且getInstance直接返回唯一实例,性能非常高。

然而,在类成员变量比较多,或变量比较大的情况下,这种模式可能会在没有使用类对象的情况下,一直占用堆内存。试想下,如果一个第三方开源框架中的类都是基于饿汉模式实现的单例,这将会初始化所有单例类,无疑是灾难性的。

懒汉模式

懒汉模式就是为了避免直接加载类对象时提前创建对象的一种单例设计模式。该模式使用懒加载方式,只有当系统使用到类对象时,才会将实例加载到堆内存中。通过以下代码,我们可以简单地了解下懒加载的实现方式:

//懒汉模式
public final class Singleton {
private static Singleton instance= null;//不实例化
private Singleton(){}//构造函数
public static Singleton getInstance(){//通过该函数向整个系统提供实例
if(null == instance){//当instance为null时,则实例化对象,否则直接返回对象
instance = new Singleton();//实例化对象
}
return instance;//返回已存在的对象
}
}

以上代码在单线程下运行是没有问题的,但要运行在多线程下,就会出现实例化多个类对象的情况。这是怎么回事呢?

当线程A进入到if判断条件后,开始实例化对象,此时instance依然为null;又有线程B进入到if判断条件中,之后也会通过条件判断,进入到方法里面创建一个实例对象。

所以我们需要对该方法进行加锁,保证多线程情况下仅创建一个实例。这里我们使用Synchronized同步锁来修饰getInstance方法:

//懒汉模式 + synchronized同步锁
public final class Singleton {
private static Singleton instance= null;//不实例化
private Singleton(){}//构造函数
public static synchronized Singleton getInstance(){//加同步锁,通过该函数向整个系统提供实例
if(null == instance){//当instance为null时,则实例化对象,否则直接返回对象
instance = new Singleton();//实例化对象
}
return instance;//返回已存在的对象
}
}

但我们前面讲过,同步锁会增加锁竞争,带来系统性能开销,从而导致系统性能下降,因此这种方式也会降低单例模式的性能。

还有,每次请求获取类对象时,都会通过getInstance()方法获取,除了第一次为null,其它每次请求基本都是不为null的。在没有加同步锁之前,是因为if判断条件为null时,才导致创建了多个实例。基于以上两点,我们可以考虑将同步锁放在if条件里面,这样就可以减少同步锁资源竞争。

//懒汉模式 + synchronized同步锁
public final class Singleton {
private static Singleton instance= null;//不实例化
private Singleton(){}//构造函数
public static Singleton getInstance(){//加同步锁,通过该函数向整个系统提供实例
if(null == instance){//当instance为null时,则实例化对象,否则直接返回对象
synchronized (Singleton.class){
instance = new Singleton();//实例化对象
}
}
return instance;//返回已存在的对象
}
}

看到这里,你是不是觉得这样就可以了呢?答案是依然会创建多个实例。这是因为当多个线程进入到if判断条件里,虽然有同步锁,但是进入到判断条件里面的线程依然会依次获取到锁创建对象,然后再释放同步锁。所以我们还需要在同步锁里面再加一个判断条件:

//懒汉模式 + synchronized同步锁 + double-check
public final class Singleton {
private static Singleton instance= null;//不实例化
private Singleton(){}//构造函数
public static Singleton getInstance(){//加同步锁,通过该函数向整个系统提供实例
if(null == instance){//第一次判断,当instance为null时,则实例化对象,否则直接返回对象
synchronized (Singleton.class){//同步锁
if(null == instance){//第二次判断
instance = new Singleton();//实例化对象
}
}
}
return instance;//返回已存在的对象
}
}

以上这种方式,通常被称为Double-Check,它可以大大提高支持多线程的懒汉模式的运行性能。那这样做是不是就能保证万无一失了呢?还会有什么问题吗?

其实这里又跟Happens-Before规则和重排序扯上关系了,这里我们先来简单了解下Happens-Before规则和重排序。

我们在第二期加餐中分享过,编译器为了尽可能地减少寄存器的读取、存储次数,会充分复用寄存器的存储值,比如以下代码,如果没有进行重排序优化,正常的执行顺序是步骤1/2/3,而在编译期间进行了重排序优化之后,执行的步骤有可能就变成了步骤1/3/2,这样就能减少一次寄存器的存取次数。

int a = 1;//步骤1:加载a变量的内存地址到寄存器中,加载1到寄存器中,CPU通过mov指令把1写入到寄存器指定的内存中
int b = 2;//步骤2 加载b变量的内存地址到寄存器中,加载2到寄存器中,CPU通过mov指令把2写入到寄存器指定的内存中
a = a + 1;//步骤3 重新加载a变量的内存地址到寄存器中,加载1到寄存器中,CPU通过mov指令把1写入到寄存器指定的内存中

在 JMM 中,重排序是十分重要的一环,特别是在并发编程中。如果JVM可以对它们进行任意排序以提高程序性能,也可能会给并发编程带来一系列的问题。例如,我上面讲到的Double-Check的单例问题,假设类中有其它的属性也需要实例化,这个时候,除了要实例化单例类本身,还需要对其它属性也进行实例化:

//懒汉模式 + synchronized同步锁 + double-check
public final class Singleton {
private static Singleton instance= null;//不实例化
public List<String> list = null;//list属性
private Singleton(){
list = new ArrayList<String>();
}//构造函数
public static Singleton getInstance(){//加同步锁,通过该函数向整个系统提供实例
if(null == instance){//第一次判断,当instance为null时,则实例化对象,否则直接返回对象
synchronized (Singleton.class){//同步锁
if(null == instance){//第二次判断
instance = new Singleton();//实例化对象
}
}
}
return instance;//返回已存在的对象
}
}

在执行instance = new Singleton();代码时,正常情况下,实例过程这样的:

  • 给 Singleton 分配内存;
  • 调用 Singleton 的构造函数来初始化成员变量;
  • 将 Singleton 对象指向分配的内存空间(执行完这步 singleton 就为非 null 了)。

如果虚拟机发生了重排序优化,这个时候步骤3可能发生在步骤2之前。如果初始化线程刚好完成步骤3,而步骤2没有进行时,则刚好有另一个线程到了第一次判断,这个时候判断为非null,并返回对象使用,这个时候实际没有完成其它属性的构造,因此使用这个属性就很可能会导致异常。在这里,Synchronized只能保证可见性、原子性,无法保证执行的顺序。

这个时候,就体现出Happens-Before规则的重要性了。通过字面意思,你可能会误以为是前一个操作发生在后一个操作之前。然而真正的意思是,前一个操作的结果可以被后续的操作获取。这条规则规范了编译器对程序的重排序优化。

我们知道volatile关键字可以保证线程间变量的可见性,简单地说就是当线程A对变量X进行修改后,在线程A后面执行的其它线程就能看到变量X的变动。除此之外,volatile在JDK1.5之后还有一个作用就是阻止局部重排序的发生,也就是说,volatile变量的操作指令都不会被重排序。所以使用volatile修饰instance之后,Double-Check懒汉单例模式就万无一失了。

//懒汉模式 + synchronized同步锁 + double-check
public final class Singleton {
private volatile static Singleton instance= null;//不实例化
public List<String> list = null;//list属性
private Singleton(){
list = new ArrayList<String>();
}//构造函数
public static Singleton getInstance(){//加同步锁,通过该函数向整个系统提供实例
if(null == instance){//第一次判断,当instance为null时,则实例化对象,否则直接返回对象
synchronized (Singleton.class){//同步锁
if(null == instance){//第二次判断
instance = new Singleton();//实例化对象
}
}
}
return instance;//返回已存在的对象
}
}

通过内部类实现

以上这种同步锁+Double-Check的实现方式相对来说,复杂且加了同步锁,那有没有稍微简单一点儿的可以实现线程安全的懒加载方式呢?

我们知道,在饿汉模式中,我们使用了static修饰了成员变量instance,所以该变量会在类初始化的过程中被收集进类构造器即<clinit>方法中。在多线程场景下,JVM会保证只有一个线程能执行该类的<clinit>方法,其它线程将会被阻塞等待。这种方式可以保证内存的可见性、顺序性以及原子性。

如果我们在Singleton类中创建一个内部类来实现成员变量的初始化,则可以避免多线程下重复创建对象的情况发生。这种方式,只有在第一次调用getInstance()方法时,才会加载InnerSingleton类,而只有在加载InnerSingleton类之后,才会实例化创建对象。具体实现如下:

//懒汉模式 内部类实现
public final class Singleton {
public List<String> list = null;// list属性
private Singleton() {//构造函数
list = new ArrayList<String>();
}
// 内部类实现
public static class InnerSingleton {
private static Singleton instance=new Singleton();//自行创建实例
}
public static Singleton getInstance() {
return InnerSingleton.instance;// 返回内部类中的静态变量
}
}

总结

单例的实现方式其实有很多,但总结起来就两种:饿汉模式和懒汉模式,我们可以根据自己的需求来做选择。

如果我们在程序启动后,一定会加载到类,那么用饿汉模式实现的单例简单又实用;如果我们是写一些工具类,则优先考虑使用懒汉模式,因为很多项目可能会引用到jar包,但未必会使用到这个工具类,懒汉模式实现的单例可以避免提前被加载到内存中,占用系统资源。

思考题

除了以上那些实现单例的方式,你还知道其它实现方式吗?

期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。