单例模式
1 实际场景
1.1 读取配置文件的内容
很多应用项目中,都有与应用相关的配置文件,这些配置文件是由项目开发人员自定义的,在里面定义一些应用需要的参数数据。在实际的项目中,这种配置文件多采用 xml
格式的。也有采用 properties
格式(使用 Java
来读取 properties
格式的配置文件比较简单)
考虑如何实现代码来读取配置文件中的内容?
1.2 不使用设计模式的方式
直接读取文件内容,再将文件内容存放在相应的数据对象中。假设系统采用的是 properties
格式的配置文件。
1、直接使用 Java 来读取配置文件
1 | /** |
只有访问参数的方法,没有设置参数的方法
2、应用的配置文件,名字是 Test.properties
1 | paramA=a |
3、客户端代码
1 | /** |
1.3 存在的问题
如上客户端的代码,是通过 new
一个 AppConfig
的实例来得到一个配置文件内容的对象。假设在系统的运行过程中,有许多地方都需要使用此配置文件,即:有很多地方都需要创建该对象的实例。
换句话说,在系统运行期间,系统中会存在很多个・
AppConfig
的实例对象,这有什么问题吗?
肯定有问题!每一个 AppConfig
实例对象,里面都封装着配置文件的内容,系统中有多个 AppConfig
实例对象,也就是说系统中会同时存在多份配置文件的内容,这会严重浪费内存资源。如果配置文件内容较少,问题还小一点,如果配置文件内容较大,会造成系统资源严重浪费。事实上,对于 AppConfig
这种类,在运行期间,只需要一个实例对象就够了。
进一步抽象问题:在一个系统运行期间,某个类只需要一个类实例就可以了,那么应该怎么实现呢?
2 解决方案
2.1 单例模式
2.1.1 定义
单例模式(Singleton
):保证一个类仅有一个实例,并提供一个可被全局访问的访问点。
2.1.2 单例模式解决问题的思路
通过分析上面的问题,一个类能够被创建多个实例,问题的根源在于类的构造方法是公开的,也就是可以让类的外部来通过构造方法创建多个实例。换句话说,只要类的构造方法能让类的外部访问,就没有办法去控制外部来创建这个类的实例个数。
要想控制一个类只被创建一个实例,那么首要的问题就是要把创建实例的权限收回来,让类自身来负责自己类实例的创建工作,然后由这个类来提供外部可以访问这个类实例的方法,这就是单例模式的实现方式。
2.2 结构说明
Singleton:负责创建
Singleton
类自己的唯一实例,并提供一个getInstance
的方法,让外部来访问这个类的唯一实例。
2.3 代码示例
单例模式的实现又分为两种,一种称为懒汉式,一种称为饿汉式,其实就是在具体创建对象实例的处理上,有不同的实现方式。
1、懒汉式实现
1 | /** |
2、饿汉式实现
1 | /** |
2.4 重写方案
选择饿汉式的方式来重写示例
1 | /** |
客户端代码
1 | /** |
3 模式讲解
3.1 认识单例模式
3.1.1 功能
单例模式的功能是用来保证这个类在运行期间只会被创建一个类实例,另外单例模式还提供了一个全局唯一访问这个类实例的访问点,即:getInstance
方法。不管采用懒汉式还是饿汉式的实现方式,这个全局访问点是一样的。
对于单例模式而言,不管采用何种实现方式,它都是只关心类实例的创建问题,并不关心具体的业务功能。
3.1.2 作用范围
目前 Java
里面实现的单例是一个 ClassLoader
及其子 ClassLoader
的范围。因为一个 ClassLoader
在装载饿汉式实现的单例类的时候就会创建一个类的实例。
这就意味着如果一个 JVM
里面有很多个 ClassLoader
,而且这些 ClassLoader
都装载某个类的话,就算这个类是单例,它也会产生很多个实例。当然,如果一个机器上有多个虚拟机,那么每个虚拟机里面都应该至少有一个这个类的实例,也就是说整个机器上就有很多个实例,这就不算单例。
另外注意:这里讨论的单例模式并不适用于集群环境
3.1.3 命名
一般建议单例模式的方法命名为:getInstance()
,该方法的返回类型是单例类的类型。getInstance
方法可以有参数,这些参数可能是创建类实例所需要的参数(大多数情况下是不需要的)
3.2 懒汉式和饿汉式
前面示例中提到了单例的两种模式:懒汉式和饿汉式,本章节会介绍一些实现细节,为了看得更清晰一点,只是实现基本的单例控制部分,不再提供示例的属性和方法。
关于线程安全的问题,在后面会重点分析
3.2.1 懒汉式
1、私有化构造方法
要想在运行期间控制某一个类的实例只有一个,那首先的任务就是要控制创建实例的地方,也就是不能随随便便就可以创建类实例。现在是让使用类的地方来创建类实例,也就是在类外部来创建类实例。那么怎样才能让类的外部不能创建一个类的实例呢?—— 私有化构造方法
1 | private Singleton() { |
2、提供获取实例的方法
构造方法被私有化,则外部创建不了该类的实例就无法调用这个对象的方法,就实现不了功能处理,单例模式可以让这个类提供一个方法来返回实例,以提供给外部使用。
1 | public Singleton getInstance() { |
3、获取实例的方法改为静态方法
随着上一个步骤,产生一个新问题,获取对象实例的这个方法是个实例方法,客户端要想调用这个方法,需要先得到类实例,然后才可以调用,导致死循环问题。
解法是将获取实例的方法改成静态方法 static
,这样可以直接通过类来调用此静态方法获取实例。
1 | public static Singleton getInstance() { |
4、定义存储实例的属性
定义好获取实例的方法 getInstance
后,如何实现方法?是否可以直接实现?如下示例代码:
1 | public static Singleton getInstance(){ |
答案是肯定不行。若多处地方调用,会导致产生多个实例。单例模式的做法是用一个属性来记录自己创建好的类实例,当第一次创建过后,就把这个实例保存下来,以后就可以复用这个实例,而不是重复创建对象实例。示例代码如下
1 | private Singleton instance = null; |
5、定义静态属性
这个属性变量应该在什么地方用?肯定是第一次创建类实例的地方,也就是在前面那个返回对象实例的静态方法里面使用。
由于要在一个静态方法里面使用,所以这个属性被迫成为一个类变量,要强制加上 static
,也就是说,这里并没有使用 static
的特性。示例代码如下:
1 | private static Singleton instance = null; |
6、控制实例的创建
控制实例的创建较为简单,查看存放实例的属性是否已经赋值,如有值,说明已经创建过,若没有值,应该创建。
1 | public static Singleton getInstance() { |
7、完整实现
1 | public class Singleton { |
3.2.2 饿汉式
与懒汉式的实现方式相比,私有化构造方法、提供静态的 getInstance
方法返回实例的方法都一样。区别在于如何实现 getInstance
方法。
在 Java
中 static
的特性为:static
变量在类装载的时候进行初始化;多个实例的 static
变量会共享同一块内存区域。
利用 static
的特性,单例模式的饿汉式的实现方式为:
1 | public class Singleton { |
无论采用哪一种方式,在运行期间,都只会生成一个实例,而访问这些类的一个全局访问点,为静态的
getInstance
方法。
3.3 关于延迟加载
延迟加载:通俗来讲,就是一开始不要加载资源或者数据,直到到马上就要使用这个资源或者数据时才加载,也称 Lazy Load
,这在实际开发中是一种很常见的思想,尽可能的节约资源。
延迟加载在单例模式中的体现:
1 | public class Singleton { |
3.4 缓存思维
简单来讲,如果某些资源或者数据会被频繁地使用,而这些资源或数据存储在系统外部,比如数据库、硬盘文件等,那么每次操作这些数据的时候都从数据库或者硬盘上去获取,速度会较慢,容易造成性能问题。
一个简单的解决方法:将热点数据缓存到内存里面,每次操作的时候,先到内存里面找,看有没有这些数据,如果有,则直接使用,如果没有那么就获取它,并设置到缓存中,下一次访问的时候就可以直接从内存中获取了。从而节省大量的时间,缓存是一种典型的空间换时间的方案。
缓存思维在单例模式中的体现:
1 | public class Singleton { |
缓存实现简介
引申一下,在 java 中最常见的缓存实现方式是 Map
,基本步骤为:
- 先到缓存里面查找,看看是否存在需要使用的数据;
- 如果没有找到,那么就创建一个满足要求的数据,然后把这个数据设置回到缓存中,以备下次使用;
- 如果找到了相应的数据,或者是创建了相应的数据,那就直接使用这个数据;
示例代码(基本实现,没有考虑缓存的清除、同步等功能)
1 | /** |
3.5 单例模式的优缺点
3.5.1 时间和空间
懒汉式是典型的时间换空间,每次获取实例都会进行判断,看是否需要创建实例,消耗判断的时间,如果一直没有人使用的话,那就不会创建实例,节约内存空间。
饿汉式是典型的空间换时间,当类装载的时候就会创建类实例,无论是否调用,先创建出来,然后每次调用时,就不需要再判断,节省了运行时间。
3.5.2 线程安全
1、不加同步的懒汉式是线程不安全的
假设:有两个线程 A 和 B,同时调用 getInstance
方法,可能导致并发问题。
1 | public static Singleton getInstance(){ |
通过上图的分解描述,明显可以看出,当 A、B 线程并发的情况下,会创建出两个实例来,也就是单例的控制在并发情况下会失效。
如何实现懒汉式的线程安全?可以通过将方法加锁 synchronized
的方式
1 | public static synchronized Singleton getInstance(){} |
但是,此种加锁的方式太重了,并发量大时,会导致性能问题。
双重检查加锁
所谓双重检查加锁机制,指的是:不需要每次进入 getInstance
方法都需要同步,而是先不同步,进入方法过后,先检查实例是否存在,如果不存在才进入下面的同步块,这是第一重检查。进入同步块过后,再次检查实例是否存在,如果不存在,就在同步的情况下创建一个实例,这是第二重检查。如此只需要同步一次,从而减少了多次在同步情况下进行判断所浪费的时间。
双重检查加锁机制的实现会使用关键字 volatile
被
volatile
修饰的变量的值,将不会被本地线程缓存,所有对该变量的读写都是直接操作共享内存,从而确保多个线程能正确的处理该变量。由于
volatile
关键字可能会屏蔽掉虚拟机中一些必要的代码优化,所以运行效率并不是很高,因此一般建议,没有特别的需要,不要使用。也就是说,虽然可以使用双重加锁机制来实现线程安全的单例,但并不建议大量采用。
实例代码:
1 | public class Singleton { |
这种实现方式既可使实现线程安全的创建实例,又不会对性能造成太大的影响,它只是在第一次创建实例的时候同步,以后就不需要同步了,从而加快运行速度。
2、饿汉式是线程安全的
因为虚拟机保证了只会装载一次,在装载类的时候是不会发生并发的。
3.6 其它实现方式
3.6.1 静态内部类方式
懒汉式和饿汉式的两种实现方式都各有优缺点,而是用静态内部类可以达到既能实现延迟加载,又能实现线程安全。
1、基础知识介绍
什么是类级内部类?简单点说,类级内部类指的是:有 static
修饰的成员式内部类。如果没有 static
修饰的成员式内部类被称为对象级内部类。
类级内部类相当于其外部类的 static
成分,它的对象与外部类对象间不存在依赖关系,因此可直接创建。而对象级内部类的实例,是绑定在外部对象实例中的。
类级内部类中,可以定义静态的方法,在静态方法中只能够引用外部类中的静态成员方法或者成员变量。类级内部类相当于其外部类的成员,只有在第一次被使用的时候才会被装载。
多线程缺省同步锁:在多线程开发中,为了解决并发问题,主要是通过使用 synchronized
来加互斥锁进行同步控制。但是在某些情况中,JVM
已经隐含地为用户执行了同步,这些情况下就不用用户再来进行同步控制了。这些情况包括:
- 由静态初始化器(在静态字段上或
static{}
块中的初始化器)初始化数据时;- 访问
final
字段时;- 在创建线程之前创建对象时;
- 线程可以看见它将要处理的对象时;
2、实现方式
要想很简单的实现线程安全,可以采用静态初始化器的方式,它可以由 JVM
来保证线程安全。比如前面的 “饿汉式” 实现方式,但是这样一来,不是会浪费一定的空间吗?因为这种实现方式,会在类装载的时候就初始化对象,不管你需不需要。
如果现在有一种方法能够让类装载的时候不去初始化对象,那不就解决问题了?一种可行的方式就是采用类级内部类,在这个类级内部类里面去创建对象实例,这样一来,只要不使用到这个类级内部类,那就不会创建对象实例。从而同时实现延迟加载和线程安全。
1 | public class Singleton { |
当 getInstance
方法第一次被调用的时候,它第一次读取 SingletonHolder.instance
,导致 SingletonHolder
类得到初始化;而这个类在装载并被初始化的时候,会初始化它的静态域,从而创建 Singleton 的实例,由于是静态的域,因此只会被虚拟机在装载类的时候初始化一次,并由虚拟机来保证它的线程安全性。
这个实现方式的优势在于,getInstance
方法并没有被同步,并且只是执行一个域的访问,因此延迟初始化并没有增加任何访问成本。
3.6.2 枚举方式
单元素的枚举类型已经成为实现 Singleton
的最佳方法 ——Effective Java
Java
的枚举类型实质上是功能齐全的类,因此可以有自己的属性和方法;Java
枚举类型的基本思想:通过公有的静态final
域为每个枚举常量导出实例的类;- 从某个角度讲,枚举是单例的泛型化,本质上是单元素的枚举;
示例代码:
1 | /** |
使用枚举来实现单实例控制,会更加简洁,而且无偿的提供了序列化的机制,并由 JVM
从根本上提供保障,绝对防止多次实例化,是更简洁、高效、安全的实现单例的方式。
综上,可以得出结论:枚举是实现单例模式的最佳实践。毕竟使用它全都是优点:
- 反射安全
- 序列化 / 反序列化安全
- 写法简单
3.7 思考单例模式
3.7.1 单例模式的本质
单例模式的本质:控制实例数目
单例模式是为了控制在运行期间,某些类的实例数目只能有一个。可能有人就会想了,那么我能不能控制实例数目为 2
个,3
个,或者是任意多个呢?目的都是 —— 节省资源,有些时候单个实例不能满足实际的需要(并发量),根据测算,3
个实例刚刚好,也就是说,现在要控制实例数目为 3
个,怎么办?
其实思路很简单,就是利用上面通过 Map
来缓存实现单例的示例,进行变形,一个 Map
可以缓存任意多个实例,新的问题就是,Map
中有多个实例,但是客户端调用的时候,到底返回那一个实例呢?,即实例的调度问题,本文不会对调度算法进行展开,示例代码利用简单的循环来展示:
1 | /** |
执行结果:
1 | t1==pattern.singleton.OneExtend@1d44bcfa |
3.7.2 使用场景
当需要控制一个类的实例只能有一个,而且客户只能从一个全局访问点访问它时,可以选用单例模式,这些功能恰好是单例模式要解决的问题。