极客时间已完结课程限时免费阅读

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

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

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

讲述:李良

时长09:35大小8.77M

你好,我是刘超。
从这一讲开始,我们将一起探讨设计模式的性能调优。在《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 包,但未必会使用到这个工具类,懒汉模式实现的单例可以避免提前被加载到内存中,占用系统资源。

思考题

除了以上那些实现单例的方式,你还知道其它实现方式吗?
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 10

提建议

上一篇
26 | 答疑课堂:模块四热点问题解答
下一篇
28 | 原型模式与享元模式:提升系统性能的利器
unpreview
 写留言

精选留言(39)

  • Loubobooo
    2019-07-23
    使用枚举来实现单例模式,具体代码如下:public class SinletonExample5 { private static SinletonExample5 instance = null; // 私有构造函数 private SinletonExample5(){ } public static SinletonExample5 getInstance(){ return Sinleton.SINLETON.getInstance(); } private enum Sinleton{ SINLETON; private SinletonExample5 singleton; // JVM保证这个方法只调用一次 Sinleton(){ singleton = new SinletonExample5(); } public SinletonExample5 getInstance(){ return singleton; } } }
    展开

    作者回复: 很赞!这是一种懒加载模式的枚举实现。

    共 7 条评论
    55
  • 豆泥丸
    2019-07-23
    最安全的枚举模式,反射和序列化都是单例。

    作者回复: 对的,我们之前序列化优化这一讲中的问答题就是与枚举实现单例相关,《Effective Java》作者也是强烈推荐枚举方式实现单例。

    共 4 条评论
    30
  • Jxin
    2019-07-23
    1.可能大部分同学都知道,但为了少部分同学,我在老师这个单例上补个点。其它线程空指针异常确实是指令重排导致的,但其原因还有一个。加锁并不能阻止cpu调度线程执行体,所以时间片还是会切的(假设单核),所以其他线程依旧会执行锁外层的if(),并发情况下就可能拿到仅赋值引用,未在内存空间存储数据的实例(null实例),进而空指针。 2.给老师的代码补段骚的: // 懒汉模式 + synchronized 同步锁 + double-check public final class Singleton { private static validate Singleton instance = null;// 不实例化 public List<String> list;//list 属性 private Singleton(){ list = new ArrayList<String>(); }// 构造函数 public static Singleton getInstance(){// 加同步锁,通过该函数向整个系统提供实例 Singleton temp = instance; if(null == temp){// 第一次判断,当 instance 为 null 时,则实例化对象,否则直接返回对象 synchronized (Singleton.class){// 同步锁 temp = instance; if(null == temp){// 第二次判断 temp = new Singleton();// 实例化对象 instance = temp; } } } return instance;// 返回已存在的对象 } } 用临时变量做方法内数据承载(相对于validate修饰的属性,可以减少从内存直接拷贝数据的次数),最后用instance接收临时变量时,因为是validate修饰,所以也不会有指令重排。所以前面临时变量的赋值操作已经完成,这样instance就必然是赋值好的实例。(如有错误请老师指出,仅个人理解的骚操作) 3.极限编程试试就好,业务代码还是尽量优先保证可读性,只有在有性能需求时再采用影响可读性的性能优化。我的这种骚写法和老师的内部类,这种看起来需要想那么一下的东西尽量避免,简单才是王道。
    展开

    作者回复: 虽然有点绕,还是值得表扬的。我们还是鼓励简单易懂的编程风格。

    共 9 条评论
    11
  • 行者
    2019-07-23
    枚举也是一种单例模式,同时是饿汉式。 相比Double Check,以内部类方式实现单例模式,代码简洁,性能相近,在我看来是更优的选择。

    作者回复: 也可以使用枚举实现懒汉模式,可以根据本讲中的使用内部类方式实现懒加载。

    9
  • -W.LI-
    2019-07-23
    枚举底层实现就是静态内部类吧

    作者回复: 对的,枚举是一种语法糖,在Java编译后,枚举类中的枚举会被声明为static,接下来就跟我们文中讲的一样了。

    8
  • 我又不乱来
    2019-07-23
    枚举天生就是单例,但是不清楚这么实现。 注册式单例,spring应该是用的这种。这个也不太清楚,超哥有机会讲一下spring的实现方式和枚举方式实现的单例。谢谢😁

    作者回复: Spring中的bean的单例虽然是一种单例效果,但实现方式是通过容器缓存实现,严格来说是一种享元模式。

    6
  • 承香墨影
    2020-03-20
    老师,您好,有个问题想请教您。 我在看极客时间《设计模式之美》专栏中有一篇讲单例的文章,其中讲述了在Java的高版本中,已经不需要增加volatile来禁止类重排序。 我自己查了一下没有找到相关的资料,我想请问这个说法有依据吗?如果是这样的,能不能推荐一些文章的链接或者资料我们看一下? ---- 原文如下: 要解决这个问题,我们需要给 instance 成员变量加上 volatile 关键字,禁止指令重排序才行。实际上,只有很低版本的 Java 才会有这个问题。我们现在用的高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序)。
    展开
    共 2 条评论
    4
  • ylw66
    2019-10-28
    👍,看过的讲singleton最好的文章
    4
  • 张三丰
    2019-10-22
    如果把这个成员变量的static去掉,在多线程情况下就可能创建多个实例,单线程没问题。老师,这么理解没问题吧? // 饿汉模式 public final class Singleton { private Singleton instance=new Singleton();// 自行创建实例 private Singleton(){}// 构造函数 public static Singleton getInstance(){// 通过该函数向整个系统提供实例 return instance; } }
    展开

    作者回复: 这个没法调用的哦,静态方法无法调用非静态成员变量。

    4
  • Zed
    2019-07-23
    容器类管理 class InstanceManager { private static Map<String, Object> objectMap = new HashMap<>(); private InstanceManager(){} public static void registerService(String key,Object instance){ if (!objectMap.containsKey(key)){ objectMap.put(key,instance); } } public static Object getService(String key){ return objectMap.get(key); } }
    展开

    作者回复: Spring中bean的单例就是使用容器来实现的,便于管理。

    共 3 条评论
    4
  • 我知道了嗯
    2019-07-23
    枚举实现单例

    作者回复: 对的

    3
  • 楊威
    2020-01-19
    爱是一道光
    1
  • 风轻扬
    2019-09-11
    老师。枚举单例可以防止反射攻击、序列化攻击。但是,我们要获取的实例化对象怎么防止暴力反射呢?我现在的做法是在实例化对象的私有构造器中加判断,如果暴力反射,直接抛出运行异常。老师有没有好的办法?百思不得其解

    作者回复: 可以通过反序列化对象白名单来控制运行反序列哪些对象,这种方式需要重写resolveClass 方法,具体参考09讲: 难道 这里面的第3步会经过指令排序排到同步锁外面去么? 如果不会那加volatile关键字意义何在? synchronized (Singleton.class){//同步锁 if(null == instance){//第二次判断 instance = new Singleton();//实例化对象 } }

    展开
  • null
    2021-04-29
    Singleton 类在加载,连接,初始化时,没涉及到静态变量和静态代码块的执行操作。当调用类的静态方法 getInstance() 时,触发 InnerSingleton 类的加载,连接,初始化流程,InnerSingleton 类的静态变量在准备阶段会被分配内存,并初始化为系统初始值,在初始化阶段,会被收录到 clinit 方法执行初始化为用户指定的值。 是这样么,老师。谢谢!
    展开
  • FileNotfoundExceptio...
    2021-03-22
    public class Ceshi { public static Ceshi getinstance(){ return text.INSTANCE.getinstance(); } enum text{ /** * */ INSTANCE; private Ceshi ceshi=new Ceshi(); public Ceshi getinstance(){ return ceshi; } } public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { System.out.println(Ceshi.getinstance()); System.out.println(Ceshi.getinstance()); System.out.println(Ceshi.getinstance()==Ceshi.getinstance()); Class<Ceshi> c = Ceshi.class; Constructor<Ceshi> declaredConstructor = c.getDeclaredConstructor(); declaredConstructor.setAccessible(true); Ceshi ceshi1 = declaredConstructor.newInstance(); System.out.println(ceshi1); System.out.println(Ceshi.getinstance()==ceshi1); } } 我使用枚举去创建单例对象 ,但是反射去调用,还是会出现多个对象,请指教是哪里出现问题了,谢谢 ===============运行结果================== com.skyedu.live.constant.Ceshi@56f4468b com.skyedu.live.constant.Ceshi@56f4468b true com.skyedu.live.constant.Ceshi@6cc4c815 false
    展开
  • 缘分注定
    2020-10-19
    设计模式 重点中的重点