单例模式
1. 概念
单例模式是设计模式的一种,要求在一个进程中,被指定的类只能创建唯一一个实例
举个栗子🌰
有时候我们需要为一个类创建一个实例来管理我们的数据,如果这个时候这个实例管理的数据非常多时,比如10GB,一个实例就已经很占我们的资源空间了,太多的话机器可能就顶不住了,这个时候就需要我们约定好一个创建规则,如这个类只能被实例化一次,若该类被多次实例化,则视为异常情况,这就是单例模式!
实现单例模式的方式有很多种,这里介绍两种最基础的实现方式:饿汉模式 与 懒汉模式。
2. 饿汉模式
饿汉模式的“饿”字,其实想表达的是创建实例的时间非常早,非常迫切,在类加载阶段就完成了对实例的创建与初始化,相当于程序已启动,实例就创建好了;
注:这里之所以将初始化也包括在类加载中是一种口头上的描述,类加载、连接、初始化这三个阶段进程是紧密关联的,所以这三个阶段的行为会被统称为“类加载的效果”,这样简化的说法却是为了方便理解,若更细化则应该按照类的生命周期来描述,故以下将这三个阶段统称为类加载;
如果用代码来表示的话,需要符合三个要求:
- 在类刚加载时就使用static关键字修饰并创建实例;
- 不对外提供修改实例的方法,只提供返回唯一实例的方法;
- 构造方法设为私有,这样new的话就会报错
注:static修饰的静态变量在类加载过程中就会被JVM分配内存并初始化,并且只会被初始化一次,保证了全局唯一性
代码示例:
class Singleton {
private static Singleton instance = new Singleton();
public static Singleton getInstance() {
return instance;
}
private Singleton() {}
}
可以看到,在类加载时就完成了对类的实例化,而这个实例也是这个类的唯一实例,获取该实例唯一的方法就是通过getInstance()调用,并且若打算通过new的方式创建新的实例,会报错:
多线程环境下
上述代码符合单例模式的要求,且不仅在单线程环境下如此,在多线程环境下也能使用,且它是线程安全的,因为饿汉模式并没有提供修改对象的操作,只有读操作(返回唯一实例的方法),故不会造成线程安全问题。
3. 懒汉模式
3.1 单线程环境
懒汉模式也是单例模式的一种实现方式,要求只能创建一个唯一的实例,但创建实例的时间却慢了很多,“懒”到在第一次使用的时候才创建实例,代码示例🌰如下:
class SingletonLazy {
private static SingletonLazy instance = null;
public static SingletonLazy getInstance() {
if (instance == null) {
instance = new SingletonLazy();
}
return instance;
}
private SingletonLazy() {};
}
public class Test7 {
public static void main(String[] args) {
SingletonLazy s1 = SingletonLazy.getInstance();
SingletonLazy s2 = SingletonLazy.getInstance();
System.out.println(s1 == s2);
}
}
// 代码执行结果
true
从输出结果可见,在第一次调用getInstance()方法时来创建该实例,之后再调用这个方法时因为instance不为空所以不会再重复创建实例,而是直接返回这个唯一实例,这也是符合单例模式要求的实现方式!
3.2 多线程环境
上述代码在单线程的环境下是能够正常使用的,那在多线程环境下呢?
我们仔细观察,其实能够发现上述代码有一处地方对instance进行了修改操作:
instance = new SingletonLazy();
如果在多线程环境下,这段代码可能就会导致线程安全问题的发生!
假设有t1、t2两个线程,t1线程在校验后发现当前实例未被创建,于是进入了分支,可在这时t2线程也发现了实例未被创建,于是也进入了分支,并在t1线程创建实例之前就创建好了实例并赋值给了引用变量instance,此时实例已经被创建出来了,但t1却不知道,并继续创建了新实例,这就违背了单例模式的原则了!
上述情况如图所示:
那么如何改进懒汉模式让它成为线程安全的代码?依照之前学过的线程安全的知识,我们可以给这段代码加锁🔒,如synchronized
,且加锁的时候也要加对位置才行,代码示例🌰如下:
class SingletonLazy {
private static SingletonLazy instance = null;
public static SingletonLazy getInstance() {
synchronized (SingletonLazy.class) { // 锁对象设置为类对象,因为类对象也是唯一的
if (instance == null) {
instance = new SingletonLazy();
}
}
return instance;
}
private SingletonLazy() {};
}
在加上锁后,上述因线程安全问题导致的实例重复创建问题就能够被解决了!
解决这个问题之后,我们却发现,加上锁后代码执行效率却下降了,明明我的synchronized只需要在第一次创建实例时加锁就可以了,后面的instance都不为空,就不需要加锁来阻塞线程执行了!
那么,又有什么方法来提高效率呢?其实很简单,只要再给锁的外围加锁一个条件判断来校验即可:
class SingletonLazy {
private static SingletonLazy instance = null;
public static SingletonLazy getInstance() {
if (instance == null) {
synchronized (SingletonLazy.class) {
if (instance == null) {
instance = new SingletonLazy();
}
}
}
return instance;
}
private SingletonLazy() {};
}
外围也加上条件判断后,我们的锁只会在第一次创建实例的时候被调用,这样我们的代码执行效率得到提高了!此时我们的锁,也被称为双重校验锁!!(双重校验 + 一个锁)
上面出现了两个条件判断语句,它们具体作用如下:
- 第一个出现的if:用来提高代码执行效率,减少锁的重复调用;
- 第二个出现的if:用来解决线程安全问题,防止实例的重复创建;
3.3 指令重排序引发的线程安全问题
指令重排序也是编译器用来优化我们代码的一种方式,它能够调整代码原有的执行顺序,在不改变代码逻辑的前提下来提高程序的执行效率。但在多线程的环境下,可能会给我们带来线程安全问题!
就上述代码所示,其中有一行代码:
instance = new SingletonLazy();
它的执行可以细分为三个步骤:
- 申请一段内存空间
- 在刚刚申请的内存空间上,通过调用构造方法创建实例对象
- 将内存地址赋值给引用变量instance
上述的三个步骤,如果在多线程的环境下,因为线程的随机调度以及编译器的指令重排序优化,可能会出现下述的问题:
此时由t2返回的instance是一个非空但未被初始化的对象,这个对象的属性此时都是未初始化的全为"0"的值,如果这个时候调用了这个对象的属性或方法,可能就会引起代码的逻辑问题!
那么,指令重排序引起的线程安全问题又该怎么解决?请出我们的老伙计:volatile
!!
volatile不仅能用来解决内存可见性问题,也能用来解决指令重排序问题,它能够禁止指令重排序,被volatile修饰的变量的读写操作是不会被重排序的,进而解决指令重排序带来的线程安全问题!
注:volatile修饰的变量,每次在被访问使用时都要重新读取内存,而不会被优化到寄存器或缓存中,进而解决内存可见性引起的线程安全问题
代码示例🌰:
class SingletonLazy {
private volatile static SingletonLazy instance = null;
public static SingletonLazy getInstance() {
if (instance == null) {
synchronized (SingletonLazy.class) { // 锁对象设置为类对象,因为类对象也是唯一的
if (instance == null) {
instance = new SingletonLazy();
}
}
}
return instance;
}
private SingletonLazy() {};
}
以上便是对单例模式的介绍了,如果这些内容对大家有帮助的话请给一个三连关注吧💕( •̀ ω •́ )✧( •̀ ω •́ )✧✨