单例模式(Singleton),保证一个类仅有一个实例,并提供一个访问它的全局访问点。——《大话设计模式》
单例模式是在内存中 仅会创建一次对象 的设计模式

简单来说单例模式的简单实现就是
成员是 私有的静态的
构造方法是 私有的
对外暴露的获取访问是 公有的静态的
单例模式分类
- 饿汉式:类加载就会导致该单实例对象被创建
- 懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时被创建
饿汉式创建单例对象
饿汉式在类加载时已经创建好该对象,在程序调用时直接返回该单例对象即可,即我们在编码时就已经指明了要马上创建这个对象,不需要等到被调用时再去创建。
饿汉式简单实现代码如下:
懒汉式创建单例对象
懒汉式创建对象的方法是在程序使用对象前,先判断该对象是否已经实例化(判空),若已实例化直接返回该类对象。,否则则先执行实例化操作。
懒汉式简单实现代码如下:
客户端代码:
可以看到打印结果是同一对象

多问一个为什么?
- 成员变量 为什么是私有的?
最直接的原因就是 防止外部直接访问,如果不声明为 ,其他类将能直接访问和修改它,而这就违反了单例模式的原则,因为单例模式要求类只有一个实例化 - 成员变量 为什么是静态的?
使用 修饰后,意味着该变量是属于类的,而不是类的实例
你可以通过 类名.变量名 的方式直接访问
同时,将只存在一个
你可以像
这样创建两个实例化对象,但他们都共享一个 对象,确保只有一个单例 - 构造方法 为什么是私有的?
与成员变量相似,是为了防止外部实例化 ,防止其他类通过 关键字直接创建该类的实例( 关键字本质是调用了类的构造方法),而应该通过特定途径(通常是类提供的静态方法,也就是 - 方法为什么是公有的?
很显然,这是我们暴露给其他类调用来创建实例的方法,因此必须是公有的,如果是私有那不就是成黑盒搁这里圈地自萌了~ - 方法为什么是静态的?
与成员变量相似,因为构造方法被私有化了,我们无法通过 关键字来实例化对象,而通过 类名.方法名 的方式可以直接访问
好的,我们已经明白了一个简单的单例模式的基本实现,了解了单例模式是什么,而在我们继续深入单例模式的各种实现之前,我们加入一个小插曲,你也许会有这样的疑问,我们为什么要有单例模式呢?单例模式应用场景有哪些?
使用单例模式的原因
- 资源控制:单例模式可以用来控制系统中的资源,例如数据库连接池或线程池,确保这些关键资源不会被过度使用。
- 内存节省:当需要一个对象进行全局访问,但创建多个实例会造成资源浪费时,单例模式可以确保只创建一个实例,节省内存。
- 共享:单例模式允许状态或配置信息在系统的不同部分之间共享,而不需要传递实例。
- 延迟初始化:单例模式支持延迟初始化,即实例在首次使用时才创建,而不是在类加载时。
- 一致的接口:单例模式为客户端提供了一个统一的接口来获取类的实例,使得客户端代码更简洁。
- 易于维护:单例模式使得代码更易于维护,因为所有的实例都使用相同的实例,便于跟踪和修改变更。
单例模式的应用场景
- 配置管理器:在应用程序中,配置信息通常只需要读取一次,并全局使用。单例模式用于确保配置管理器只被实例化一次。
- 日志记录器:一个系统中通常只需要一个日志记录器来记录所有的日志信息,使用单例模式可以避免日志文件的重复写入。
- 数据库连接池:数据库连接是一种有限的资源,使用单例模式可以确保数据库连接池的唯一性,并且能够重用连接,减少连接创建和销毁的开销。
- 线程池:类似于数据库连接池,线程池也是有限的资源,使用单例模式可以避免创建过多的线程,提高应用程序的并发性能。
- 任务调度器:在需要全局调度和管理的场景下,如定时任务调度器,单例模式提供了一个集中的管理方式。
- 网站的计数器:一般也是采用单例模式实现,否则难以同步。
值得注意的是,在许多框架中,单例模式也有广泛的应用,比如Spring,可以看看这篇文章
总之,单例模式在确保资源有效管理、减少不必要的开销、以及提供全局访问控制方面有着广泛的应用。当然,滥用单例模式也可能导致代码的灵活性和可测试性降低,因此应当在适当的场景下谨慎使用。
好的我们在知晓单例模式的强大之处后,继续深入探讨一下单例模式的实现问题吧!
让我们编写一个测试,让三个线程去尝试实例化:
饿汉式
因为饿汉式单例模式下,单例实例在类加载的时候就已经创建,并在静态化容器中完成初始化,所以他通常是线程安全的

可以看到,三个线程获取到的实例是相同的
懒汉式
测试一下,会发现出现问题,创建了三个实例!!



所以,我们要解决的是线程安全问题。
懒汉加锁
当然,我们最容易想到的呢就是在方法上加锁,或者对类对象进行加锁
通过 关键字,可以让每个线程在进入方法之前,都要等到别的线程都离开此方法,不会有两个线程同时进入此方法

可以看到,此时三个线程获取到的实例都是同样的了
但是这带来了新的问题:每次去获取对象都需要先获取锁,并发性能非常地差。
加锁优化(双重检验加锁)
接下来要做的就是优化性能,目标是:
- 如果已经实例化了,则不需要加锁
- 如果没有实例化对象则加锁,然后再判断一次有没有实例化
- 如果实例化了就直接返回
- 如果没有实例化就再次加锁

所以直接在方法上加锁的方式就被废掉了,因为这种方式无论如何都需要先获取锁
所以优化的关键点其实是在于 加锁的时机
上面的代码已经完美地解决了 并发安全+性能低效 问题:
- 第2行代码,如果 不为空,则直接返回对象,不需要获取锁;而如果多个线程发现 为空,则进入分支;
- 第3行代码,多个线程尝试争抢同一个锁,只有一个线程争抢成功,第一个获取到锁的线程会再次判断 是否为空,因为 有可能已经被之前的线程实例化
- 其它之后获取到锁的线程在执行到第4行校验代码,发现 已经不为空了,则不会再 一个对象,直接返回对象即可
- 之后所有进入该方法的线程都不会去获取锁,在第一次判断 对象时已经不为空了
也就是说,我们通过 多加一次初始化检验判空,避免了不管有没有初始化,每次都要加锁的并发性能差的问题
一图胜千言,如图,假设这是初次实例化
- 三个线程都进行实例化判断,发现实例不存在,进入三线程的三国争霸(抢锁)!!

- 线程 C 抢赢了,获取到了锁,然后进行判断实例是否存在,发现不存在,直接 一个 —— 该单例就被线程 C 创建成功!
- 线程 C 退出了,然后归还了锁,此时在锁外面双眼红光,饥渴难耐的线程 B 立马就抢了锁,在他正准备大干一场创建实例时,判断发现已经创建过了,实例 存在,大败而归!

- 线程A 同理,接着线程 ABC就都退出了本次的 实例争霸赛
因为需要两次判空,且对类对象加锁,该懒汉式写法也被称为:Double Check(双重校验) + Lock(加锁)
使用volatile防止指令重排
创建一个对象,在JVM中会经过三步:
(1)为 分配内存空间
(2)初始化 对象
(3)将 指向分配好的内存空间
而指令重排序是指:JVM在保证最终结果正确的情况下,可以不按照程序编码的顺序执行语句,尽可能提高程序的性能
在这三步中,第2、3步有可能会发生指令重排现象,创建对象的顺序变为1-3-2,会导致多个线程获取对象时,有可能线程A创建对象的过程中,执行了1、3步骤时,
线程 B 判断 已经不为空,获取到还未初始化的 对象并返回,这就会报 异常,也就是空指针异常。
文字较为晦涩,可以看流程图:

使用 关键字可以防止指令重排序,其原理较为复杂,这篇博客不打算展开,可以这样理解:使用 关键字修饰的变量,可以保证其指令执行的顺序与程序指明的顺序一致,不会发生顺序变换,这样在多线程环境下就不会发生NPE异常了。
最终的代码如下:
无论是完美的懒汉式还是饿汉式,终究敌不过反射和序列化,它们俩都可以把单例对象破坏掉(产生多个对象)。
反射破坏
- 演示利用反射破坏单例模式
上述的代码一针见血了:利用反射,强制访问类的私有构造器,去创建另一个对象
序列化与反序列化破坏
- 利用序列化与反序列化破坏单例模式
两个对象地址不相等的原因是:readObject() 方法读入对象时,它必定会返回一个新的对象实例,必然指向新的内存地址。
原因分析
实际上对于序列化与反序列化破坏单例模式的问题,主要是通过方法,出现了破坏单例模式的现象,主要是因为这个方法最后会通过反射调用无参数的构造方法创建一个新的对象,从而每次返回的对象都不一致。
对于反射破坏单例模式是因为单例模式通过 指示反射的对象在使用时,取消了 Java 语言访问检查,使得私有的构造函数能够被访问,
而单例模式的设计在于只保留一个公有静态函数来获取唯一的实例,其他方法(构造函数)或字段为私有,外界不能访问。
而反射破坏了这一原则,它突破了构造函数私有的限制,可以获取单例类的私有构造函数并使用其创建多个对象。
问题解决
● 序列化、反序列方式破坏单例模式的解决方法
在 方法的调用栈的底层方法中有这么两个方法:
:
表示如果实现了 或者 接口的类中包含 则返回 true
:通过反射的方式调用要被反序列化的类的方法。
所以,原理也就清楚了,主要在Singleton中定义方法,并在该方法中指定要返回的对象的生成策略,就可以防止单例被破坏。
既然你能访问我的构造函数,我就在我的构造函数中建立防御机制,不让你通过我的构造函数创建多个实例对象。
在构造方法来添加一些限制,如果存在实例,那么就抛出异常!
我们已经掌握了懒汉式与饿汉式的常见写法了,在《图解设计模式》以及《大话设计模式》中,并没有再做更多的涉及。但是,追求极致的我们,怎么能够止步于此,在《Effective Java》书中,给出了终极解决方法。
在 JDK1.5 后,使用 Java 语言实现单例模式的方式又多了一种:枚举
我们先来看看枚举如何实现单例模式的,如下代码:
枚举类实现单例模式是极力推荐的单例实现模式,因为枚举类型是线程安全的,并且只会装载一次,设计者充分的利用了枚举的这个特性来实现单例模式,枚举的写法非常简单,而且枚举类型是所用单例实现中唯一一种不会被破坏的单例实现模式。
我们可以简单地理解枚举实现单例的过程:在程序启动时,会调用Singleton的空参构造器,实例化好一个Singleton对象赋给INSTANCE,之后再也不会实例化
- 防止反射攻击:
- 枚举类型的实例在编译后会被编译成静态常量,并且在运行时通过方法来获取实例。这个方法在内部会检查传递的名称是否与枚举定义中的名称匹配,并且直接返回对应的枚举实例,而不允许创建新的枚举实例。
- 因此,即使调用者尝试使用反射创建新的枚举实例,也会因为名称不匹配而失败。例如,将会抛出。
- 防止序列化/反序列化攻击:
- 枚举实例在序列化时会被转换为其名称,而不是实例本身。这意味着,即使反序列化时提供了枚举实例的字节流,也会根据名称来查找对应的枚举实例,而不是创建一个新的实例。
- 由于枚举实例是静态的,反序列化时并不会创建新的实例,而是恢复之前序列化时的那个实例。这样,即使通过序列化/反序列化机制,也无法创建新的枚举实例。
- 防止直接实例化:
- 枚举类型默认有一个私有构造器,这意味着不能直接实例化枚举类型,只能通过枚举的静态方法来获取实例。
- 例如,对于,你不能写代码,因为这会编译错误,因为的构造器是私有的。
通过这些机制,枚举类有效地阻止了通过反射、序列化和反序列化机制来破坏单例的行为。这使得枚举类成为实现单例模式的一种非常安全和简洁的方法。
(1)单例模式常见的写法有两种:懒汉式、饿汉式
(2)懒汉式:在需要用到对象时才实例化对象,正确的实现方式是:Double Check + Lock,解决了并发安全和性能低下问题
(3)饿汉式:在类加载时已经创建好该单例对象,在获取单例对象时直接返回对象即可,不会存在并发安全和性能问题。
(4)在开发中如果对内存要求非常高,那么使用懒汉式写法,可以在特定时候才创建该对象;
(5)如果对内存要求不高使用饿汉式写法,因为简单不易出错,且没有任何并发安全和性能问题
(6)为了防止多线程环境下,因为指令重排序导致变量报NPE,需要在单例对象上添加volatile关键字防止指令重排序
(7)最优雅的实现方式是使用枚举,其代码精简,没有线程安全问题,且 Enum 类内部防止反射和反序列化时破坏单例。
参考文章:
我给面试官讲解了单例模式后,他对我竖起了大拇指!
设计模式之单例模式(七种方法超详细)
设计模式|序列化、反序列化对单例的破坏、原因分析、解决方案及解析
版权声明:
本文来源网络,所有图片文章版权属于原作者,如有侵权,联系删除。
本文网址:https://www.mushiming.com/mjsbk/738.html