23种设计模式之单例模式

244 阅读14分钟

微信二维码

 The best time to plant a tree was twenty years ago. The second-best time is now

一、前言

站在巨人的肩上,本篇文章我将整理 "23种设计模式之单例模式"的相关技术笔记,算是这段时间我阅读学习经典书籍“设计模式解析(第二版)”的思考和总结,目录结构如下:

  • 前言
  • 单例模式简介
  • 单例模式适用场景
  • 单例模式的优缺点
  • 单例模式的几种实现方式
  • 关于单例模式常见面试题(重点)
  • 回顾总结

二、单例模式简介

众所周知,根据设计模式分类标准,单例模式属于创建型设计模式,按照<<设计模式>>一书的说法,我们可以从以下几个方面来理解Singleton模式: - **意图: ** 保证一个类仅有一个实例,并提供一个访问它的全局安全点。

  • 工作原理: 用一个特殊的方法来实例化所需的对象。

    何为“特殊的方法”:

    • 调用这个方法时,检查对象是否已经实例化.如果已经实例化,该方法返回改对象的一个引用.否则,该方法实例化该对象并返回新实例的一个引用.
    • 为了确保全局在一个JVM中,该对象只有一个实例存在,需要将该类定义为受保护或私有的(即其他对象无法访问它).
  • 本质: 应用程序中的每个对象都使用Singleton类的同一实例,即应用程序中所有协作对象都是用同一实例.在一个协作对象对此实例所做的修改必须对另一个协作对象保持可见特性,这一点尤为重要.

  • 实现步骤:

    1. 添加一个类私有的静态成员变量,引用所需的对象(初值为null) .
    2. 添加一个公共静态方法,它在成员变量的值为null时实例化这个类(并填充变量的值),然后返回.
    3. 将该类的构造函数设置为保护或私有,从而防止任何人直接实例化这个类,绕过静态构造函数机制.

三、单例模式的适用场景

通常我们在写程序的时候会碰到一个类只允许在整个系统中只存在一个实例(Instance)的情况, 比如说我们想做一计数器,统计某些接口调用的次数,通常我们的数据库连接也是只期望有一个实例。Windows系统的系统任务管理器也是始终只有一个,垃圾回收站也是如此。基于单例模式的特点,其是使用场景也很明确,就是一个类只需要、且只能需要一个实例的时候使用单例模式。

所谓单例,就是整个程序有且仅有一个实例。该类负责创建自己的对象,同时确保只有一个对象被创建。在Java,一般常用在工具类的实现或创建对象需要消耗大量系统资源时。

  • 需要生成唯一序列的环境
  • 需要频繁实例化然后销毁的对象
  • 创建对象时耗时过多或者耗资源过多,但又经常用到的对象
  • 方便资源相互通信的环境
  • 频繁访问数据库或文件的对象
  • 工具类对象

四、单例模式的优缺点

优点:在内存中只有一个对象,节省内存空间; 避免频繁的创建销毁对象,可以提高性能;避免对共享资源的多重占用,简化访问;为整个系统提供一个全局访问点。

缺点:单例模式不适用于变化频繁的对象;如果实例化的对象长时间不被利用,系统会认为该对象是垃圾而被回收,这可能会导致对象状态的丢失;滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;

五、单例模式的几种实现方式

在介绍单例模式的实现方式前,我们需要明确一点基本要求。要想实现要想实现效率高的线程安全的单例,我们必须注意以下两点:尽量减少同步块的作用域;尽量使用细粒度的锁;

第一种 饿汉式(静态常量)[可用]

这种方式基于classloder机制避免了多线程的同步问题,不过,instance在类装载时就实例化,虽然导致类装载的原因有很多种,在单例模式中大多数都是调用getInstance方法, 但是也不能确定有其他的方式(或者其他的静态方法)导致类装载,这时候初始化instance显然没有达到lazy loading的效果。

优点:写法比较简单,就是在类装载的时候就完成实例化。避免了线程同步问题。

缺点:在类装载的时候就完成实例化,没有达到Lazy Loading的效果。如果从始至终从未使用过这个实例,则会造成内存的浪费。

第二种 饿汉式(静态代码块)[可用]

这种方式只不过将类实例化的过程放在了静态代码块中,也是在类加载的过程中,执行静态代码块中的代码,初始化类的实例。关于类加载的过程和特点我会在后面的文章中进行总结。

第三种 懒汉式(线程不安全)[不可用]

这种写法起到了Lazy Loading的效果,但是只能在单线程下使用。如果在多线程下,一个线程进入了if (singleton == null)判断语句块,还未来得及往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例。所以在多个线程环境下可能会并发调用它的getInstance()方法,导致创建多个实例,因此需要加锁解决线程同步问题

第四种 懒汉式(线程安全,同步方法)[不推荐用]

此种方法虽然解决了线程安全的问题,但是不推荐使用。 锁粒度偏大,效率低,第一次加载需要实例化,反应稍慢。每次调用getInstance方法都会进行同步,消耗不必要的资源。

在 Java 中,关键字 synchronized 可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作),同时它还可以保证共享变量的内存可见性。

Synchronized 包括三种用法:

  • 修饰实例方法--锁实例对象
  • 修饰静态方法--锁对象
  • 修饰代码块--锁实例对象或类对象
  • 修饰类--不推荐使用

第五种 懒汉式(线程安全,同步代码块)[不可用]

由于第四种同步方法的效率太低,所以改为同步产生实例化的的代码块。减少锁粒度范围,但是这种同步并不能起到线程同步的作用。跟第3种实现方式遇到的情形一致,假如一个线程进入了if (singleton == null)判断语句块,还未来得及往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例对象,违背了单例模式的本质。

第六种 双重检查单例(DCL实现单例)[推荐用]

DCL实现实例模式主要在getInstance()方法中进行了双重的判断,第一层判断的主要避免了不必要的同步,第二层判断是为了在实例对象null的情况下再去创建实例;线程安全;延迟加载;效率较高。此处需要特别注意Volatitle 关键字的使用,这也是常问的面试点(第六小节详细讲解,见下文)。

假如现在有多个线程同时触发这个方法: 线程A执行到nstance = new Singleton(),它大致的做了三件事:

(1)、给Singleton实例分配内存,将函数压栈,并且申明变量类型。 (2)、初始化构造函数以及里面的字段,在堆内存开辟空间。 (3)、将instance对象指向分配的内存空间。

第七种 静态内部类[推荐用]

静态内部类也采用了类加载的机制来保证初始化实例时只有一个线程。不同的地方在饿汉式方式是只要Singleton类被装载就会实例化,没有Lazy-Loading的作用,而静态内部类方式在Singleton类被装载时并不会立即实例化,而是在需要实例化时,调用getInstance方法,才会装载SingletonInstance类,从而完成Singleton的实例化。

类的静态属性只会在第一次加载类的时候初始化,所以在这里,JVM帮助我们保证了线程的安全性,在类进行初始化时,别的线程是无法进入的。静态内部类实现单例模式,这种方式优于上面几种方式,他既实现了线程安全,又省去了null的判断操作。

优点:避免了线程不安全,延迟加载,效率高。

第八种 枚举类[推荐用]

在多线程环境下上面几种单例模式实现方式都是非常不错的。但是在考虑反射机制序列化机制的情况下,则上面的单例就无法做到单例类在JVM中仅有一个实例。事实上,通过Java反射机制是能够实例化构造方法为private类。这也就是我们现在需要引入的枚举单例模式,它不仅能避免多线程同步问题,而且还能防止反序列化和反射机制下重新创建新的对象,可谓是很坚强的壁垒。

以双重检索的单例模式为例,我使用反射机制,能够创建出新的实例。由此可见双重检索模式不是最安全的,无法避免反射的攻击。

public static void main(String[] args) throws Exception {
        Singleton s=Singleton.getInstance();
        Singleton sual=Singleton.getInstance();
        Constructor<Singleton> constructor=Singleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton s2=constructor.newInstance();
        System.out.println(s+"\n"+sual+"\n"+s2);
        System.out.println("正常情况下,实例化两个实例是否相同:"+(s==sual));
        System.out.println("通过反射攻击单例模式情况下,实例化两个实例是否相同:"+(s==s2));
 }
 
 结果为:
     cn.singleton.Singleton@1641e19d
     cn.singleton.Singleton@1641e19d
     cn.singleton.Singleton@677323b6
     正常情况下,实例化两个实例是否相同:true
     通过反射攻击单例模式情况下,实例化两个实例是否相同:false

第九种 通过容器实现单例模式[不推荐用]

在开始的时候将单例类型注入到容器之中,也就是单例ManagerClass,在使用的时候再根据key值获取对应的实例,这种方式可以使我们很方便的管理很多单例对象,也对用户隐藏了具体实现类,降低了耦合度;但是为了避免造成内存泄漏,一般在生命周期销毁的时候也要去销毁它。

六、关于单例模式常见的面试题

通过上面的总结我们可知,实现单例模式的方式多种多样,并且每种实现方式都是在遵循单例模式本质原理的基础上进行扩展实现。下面我们来讨论下关于单例模式常见的面试题。

  • 知道单例模式吗? 那说说单例模式的特点和作用吧。

正确回答: 知道的。单例模式是设计模式中非常重要的一种设计模式。在日常开发中我们使用到场景也是很多的。比如 需要频繁实例化,然后销毁的对象创建对象时耗时过多或者耗资源过多,但又经常用到的对象 。以及频繁访问数据库或文件的对象,工具类对象等。

特点: 全局仅提供一个实例、提供公有的方法获取实例、私有的构造方法、私有的成员变量等

作用:1. 在要求线程安全的情况下,保证了类实例的唯一性。

​ 2. 在不需要多实例的情况下,保证了 类实例的单一性。

  • 不使用synchronized和lock,如何实现一个线程安全的单例?

错误回答:实现单例模式的方式多种多样,通过上面咱们的总结,这里可以使用饿汉模式、饿汉模式变种、静态内部类、枚举、以及通过容器等方式来实现单例模式。但这里有个误区,需要区分隐形锁和显示锁的使用。

以上几种答案,其实现原理都是利用借助了类加载的时候初始化单例。即借助了ClassLoader的线程安全机制。所谓ClassLoader的线程安全机制,就是ClassLoader的loadClass方法在加载类的时候使用了synchronized关键字。也正是因为这样, 除非被重写,这个方法默认在整个装载过程中都是同步的,也就是保证了线程安全。

所以,以上各种方法,虽然并没有显示的使用synchronized,但是还是其底层实现原理还是用到了synchronized,即隐形使用。

终极答案: 那就是使用CAS。

CAS是项乐观锁技术,当多个线程尝试使用CAS同时更新同一个临界资源时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

用CAS的好处在于不需要使用传统的锁机制来保证线程安全,CAS是一种基于忙等待的算法,依赖底层硬件的实现,相对于锁它没有线程切换和阻塞的额外消耗,可以支持较大的并行度。

CAS的一个重要缺点在于如果忙等待一直执行不成功(一直在死循环中),会对CPU造成较大的执行开销。

  • DCL实现单例模式中,Volatitle 关键字的作用?

这里我们简单总结几个要点,关于Volatitle关键字的底层原理,后面我会写一篇文章专门分析

  1. volatile 变量提供执行顺序和内存可见性保证,例如,JVM 或者 JIT为了获得更好的性能会对语句重排序,但是 volatile 类型变量即使在没有同步块的情况下赋值也不会与其他语句重排序。
  2. volatile 提供 happens-before 的保证,确保一个线程的修改能对其他线程是可见的。某些情况下,volatile 还能提供原子性。volatile 本质是在告诉jvm当前变量在寄存器中的值是不确定的,需要从主存中读取。

七、回顾总结

到这里这篇文章已经接近尾声了。这里我们从单例模式的各个方面进行归纳总结,我想认真思考过的朋友一定获益匪浅。关于懒汉模式和饿汉模式,我想强调的一点是,饿汉模式“类不能实现延迟加载,不管用不用始终占据内存;”懒汉式模式“类线程安全控制烦琐,而且性能受影响。而“既可以延时加载又不影响性能”的解决方案,我们可以选择使用"静态内部类"

这是我第一篇技术类文章。相信我,做出这个决定并不容易,付出的精力和时间也非常可观。我是那种既然决定做就会认真对待的人。后面我也会认真对待将要写的每一篇文章,绝不水文。希望和大家一起学习进步,在技术的道路上更进一步。知其然,知其所以然,真的很重要。也欢迎大家给我留言,指出问题。

模式不是独立的个体,模式应该相互配合,共同解决问题。

需求总是不断变更,使用功能分解,合理运用设计模式,能应对绝大部分情况。

设计模式不仅仅具体的一种规范和要求,去理解它背后的思想和逻辑,是更加重要的。

参考资料(部分):

设计模式解析(第二版) Alan Shalloway,James R.Trott 著

深入理解设计模式(一):单例模式

单例模式八种写法比较

synchronized关键字以及对象锁和类锁的区别

单例模式最好用枚举

如何实现一个线程安全的单例,前提是不能加锁