0. 絮絮叨叨
- 面向对象编程,通过new创建一个类的对象,一般叫做创建该类的实例对象
- 根据需要,我们可以创建任意个实例对象,并为这些对象赋予不同的属性值
- 单例,就是只有一个实例对象的意思;单例模式,就是设计一个类,该类只有一个实例对象
- 为了方便描述,本文将这样的类直接叫做
Singleton
1. 入门引导
1.1 需求分析
1.1.1 私有构造函数
-
要想一个类只能有一个实例对象,首先应该回收构造函数,避免外部类通过构造函数创建多个实例对象
-
Java中构造函数的有以下规定
- 若类中未定义构造函数,编译器将自动创建方法体为空的、
public
类型的无参构造函数,称为默认构造函数 - 如果定义了有参构造函数,编译器将不会自动生成无参构造函数;若想使用无参构造函数,需要显式定义
- 若类中未定义构造函数,编译器将自动创建方法体为空的、
-
上述规定告诉我们,要想回收构造函数,必须自定义
private
类型的构造函数 -
不考虑通过参数初始化对象,我们可以将显式定义方法体为空的无参构造函数,该构造函数的访问权限为
private
public class Singleton { private Singleton(){} // 私有构造函数 }
1.1.2 自我实例化
-
将构造函数定义为
private
后,外部类将无法创建实例对象 -
这时,只能在Singleton内部创建一个实例对象,也就是说Singleton需要自我实例化
-
因此,需要定义一个
private
的成员变量instance
,对应该类的唯一实例对象。(private
是为了控制instance的权限,避免instance
在其他类中被修改) -
若在定义时初始化
instance
,整个代码完善如下:public class Singleton { private Singleton instance = new Singleton(); // 自我实例化 private Singleton() {} // 私有构造 }
1.1.3 提供一个访问唯一实例的全局访问点
-
其他类想要访问这个唯一实例,Singleton 需要提供一个
public
方法,返回唯一实例 -
同时,这个方法必须是
static
的:- 外部类无法实例化对象 → \rightarrow → 无法访问类的实例方法 → \rightarrow → 无法获取到类的唯一实例
- 只有将该方法定义为静态方法,才能通过类名进行访问,而无需实例化对象
-
也就是说,Singleton类需要提供一个访问唯一实例的
public static
方法。这样的方法,叫做全局访问点 -
按照Java的规定,静态方法只能访问静态成员变量,因此之前定义的
instance
应该定义为静态成员变量 -
至此,单例模式的代码如下:
public class Singleton { private static Singleton instance = new Singleton(); private Singleton() {} public static Singleton getInstance() { return instance; } }
1.2 单例模式总结
- 单例模式:确保一个类只有一个实例,并提供该实例的全局访问点。
- 3个典型特征
- 只有一个实例:
private
构造函数 - 自我实例化
- 提供一个访问唯一实例的全局访问点:
public static
方法返回唯一实例,唯一实例是private static
变量
- 只有一个实例:
- 单例模式的UML图如下
2. 单例模式的多种实现方式
2.1 饿汉模式
2.1.1 什么是饿汉模式?
- 在类的加载过程中,链接步骤的准备阶段,会为静态变量分配内存并赋予默认初始值。此时,唯一实例instance的值为
null
- 接着,在初始化阶段,唯一实例instance将被初始化为Singleton实例
- 因此,当外部类通过全局访问点获取instance时,instance已经准备就绪了,可以直接返回
- 这样的设计,非常适合
饿汉
:顾客非常饿,希望一到面馆就能立马吃上热腾腾的兰州拉面,饿汉模式由此而来 - 总结: 饿汉模式,即在定义实例时就进行初始化,类一旦加载完成就可以立即使用
2.1.2 饿汉模式的实现
- 上面通过需求分析写出的代码,就是饿汉模式:定义时就初始化
public class Singleton { private static Singleton instance = new Singleton(); private Singleton() {} public static Singleton getInstance() { return instance; } }
2.1.3 饿汉模式的优缺点
- 优点:
- 类加载时就完成了实例的初始化,通过getInstance() 方法直接获取对象,速度很快
- 线程安全:类加载时就进行初始化,避免了多线程并发访问多次实例化instance的问题
- 缺点
- 类加载时就进行初始化,降低了类加载的速度
- 不管是否有外部类需要使用instance,instance早就已经实例化了,可能会浪费资源
2.2 非线程安全的懒汉模式
-
类加载时就初始化instance,可能会浪费资源
-
因此,可以采用懒加载(
Lazy Loading
)的方式:有使用需求时,再实例化 -
相对饿汉模式,这种方式又称懒汉模式:这里是老板很懒,有顾客上门了,才开始慢悠悠地制作兰州拉面 😂
-
代码如下:
- 外部类通过getInstance() 方法获取instance时,发现instance尚未实例化,于是通过new实例化
public class Singleton { private static Singleton instance; private Singleton() { } public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }
-
懒汉模式的优点:通过延迟实例化,达到了节约资源的目的
-
懒汉模式的缺点:非线程安全
- Singleton类刚被加载,线程A和线程B同时通过getInstance() 方法获取instance,发现instance尚未实例化
- 线程A和线程B都会执行new操作,instance将被多次实例化,与单例模式的初衷不一致
- Singleton类刚被加载,线程A和线程B同时通过getInstance() 方法获取instance,发现instance尚未实例化
2.3 线程安全的懒汉模式
最原始的想法
-
既然刚初始化时,容易因为多线程同时访问出现instance多次实例化的问题
-
那就使用synchronized给getInstance() 方法加锁,以保证线程安全
public class Singleton { private static Singleton instance; private Singleton() { } public synchronized static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }
-
存在的问题:
- 同一时刻,只有一个线程能访问getInstance()方法,效率很低
- 实际上,一旦instance被成功实例化,多线程可以并发访问getInstance()方法的
2.4 双重检验锁(懒汉模式)
2.4.1 缩小锁范围
-
针对上面的问题,可以缩小锁的范围,只对实例化部分的代码加锁,锁范围为整个类
-
这样一旦成功实例化instance,后续线程是可以并发访问getInstance()方法的
public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { instance = new Singleton(); } } return instance; } }
-
这样的设计看起来安全,实际并不安全
- 线程A和线程B同时进入
if
语句块,线程A先获得锁成功实例化instance - 线程B后续获得锁,也会再次实例化instance
- 线程A和线程B同时进入
2.4.2 不完善的双重检验锁(DCL)
-
线程A先进入同步语句块,实例化instance
-
线程B进入时,可以再次判断instance是否为
null
,避免重复实例化public class Singleton { private static Singleton instance; private Singleton() { } public static Singleton getInstance() { if (instance == null) { // 第一重检验,避免不必要的同步 synchronized (Singleton.class) { // 同步锁 if (instance == null) { // 第二重检验,避免重复创建 instance = new Singleton(); } } } return instance; } }
-
这样的代码看起来无懈可击,实际上,这样的代码会因为JVM内部的指令重排序而存在一定的问题
2.4.3 指令重排序引发的问题
-
线程A和线程B一前一后访问getInstance() 方法,线程B在第一重null判断时,线程A已经在执行new操作了
-
new操作在JVM中对应三条指令,只有当instance指向分配的内存后,instance将不再是
null
memory = allocate(); //1:分配对象的内存空间 ctorInstance(memory); //2:初始化对象 instance = memory; //3:设置instance指向刚分配的内存地址
-
由于JVM存在指令重排序,1 → \rightarrow → 2 → \rightarrow → 3可能会变成 1 → \rightarrow → 3 → \rightarrow → 2
memory = allocate(); //1:分配对象的内存空间 instance = memory; //3:设置instance指向刚分配的内存地址 ctorInstance(memory); //2:初始化对象
-
在指令重排序的情况下,如果线程B刚好在线程A执行完
3
之后,就执行第一重null
判断,发现instance不为null
,直接执行return instance
-
这时,线程B获取到的是一个初始化尚未完成的instance,对instance的后续访问将会报错
2.4.4 volatile解决指令重排序问题
-
可以使用
volatile
关键字修饰instance,禁止JVM进行指令重排序,从而保证instance要么为null
,要么指向一个初始化完毕的对象public class Singleton { private volatile static Singleton instance; // 禁止指令重排序 private Singleton() { } public static Singleton getInstance() { if (instance == null) { // 第一重检验,避免不必要的同步 synchronized (Singleton.class) { // 同步锁 if (instance == null) { // 第二重检验,避免重复创建 instance = new Singleton(); } } } return instance; } }
2.5 静态内部类(懒汉模式)
- 通过synchronized、双重检验锁实现的懒汉模式,由于使用锁,性能上都会受到一定的影响
- 是否存在一种实现方式,既可以支持懒加载,又可以在不使用锁的情况下保证线程安全?
- 这时,可以考虑通过静态内部类来实现
public class Singleton { private Singleton() {} private static class LazyHolder { private static Singleton instance = new Singleton(); } public static Singleton getInstance() { return LazyHolder.instance; } }
- 利用JVM的类加载机制,不仅实现了懒加载,还保证了instance实例化时的线程安全
- 首先,静态内部类LazyHolder为
private
,外部类无法访问。 - 只有当第一次执行getInstance()方法中的
return LazyHolder.instance;
时,LazyHolder 才会被加载 - 同时,instance作为LazyHolder类的静态变量,定义时初始化,有效地保证了实例化时的线程安全
- 首先,静态内部类LazyHolder为
2.6 枚举类(饿汉模式)
-
上面的实现方法,都存在一个相同的问题:可以通过反射机机制破坏单例
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { // 获取class对象 Class clazz = Singleton.class; // 获取对象的构造函数 Constructor constructor = clazz.getDeclaredConstructor(); // 破除private限制,创建新的实例对象 constructor.setAccessible(true); Singleton singleton1 = (Singleton) constructor.newInstance(); // 新创建的对象和getInstance()返回的不是同一个对象 System.out.println("是否为同一实例对象: " + (singleton1 == Singleton.getInstance())); }
-
可以通过枚举类构建单例,可以避免反射攻击,其实现优雅而简洁
public enum Singleton { INSTANCE; }
-
优点:
- JVM的语法糖,可以阻止反射获取枚举类的构造方法
- 同时,枚举类默认是线程安全的,无需担心双重锁检测的问题
-
缺点: 枚举类加载时,实例对象就进行初始化了,不属于懒加载方式
-
絮絮叨叨
- 针对枚举类的实现方式,其实自己也不是很理解
- 就看人家说,默认线程安全、语法糖之类的优势
3. 后记
3.1 总结
-
层层递进,介绍了6种单例的实现方式
实现方式 是否线程安全 是否懒加载 是否防止反射攻击 备注 饿汉模式(最原始的) 是 否 否 借助JVM的类加载机制保证线程安全 非线程安全的懒汉模式 否 是 否 通过一次 null
判断,实现懒加载线程安全的懒汉模式 是 是 否 锁住整个getInstance()方法,牺牲了效率 双重检测锁 是 是 否 每重 null
判断的作用,volatile
禁止指令重排序静态内部类 是 是 否 借助JVM的类加载机制,基于静态内部类保证线程安全和懒加载 枚举类 是 否 是 枚举类的语法糖 -
如果面试需要实现单例模式,建议使用双重检测锁(DCL) 或者 静态内部类进行实现
3.2 参考链接
- 完美的单例模式讲解:漫画:什么是单例模式?
- 基于面试,层层递进实现单例模式,与本博客十分相似:面试:用 Java 实现一个 Singleton 模式
- 超级喜欢的页面排版: 如何正确地写出单例模式
- 推荐理由让人信服: Java:单例模式我只推荐两种