设计模式之单例模式及其在多线程下的使用
很多时候,我们在使用类创建类的实例并不想可以创建很多实例对象,比如在数据库连接的时候,对于一个数据库的连接通常只需要连接池中的某个连接的实例,连接一次即可,对于session会话,用户在访问网页做会话保持的时候,一个用户只需要一个实例来表示本次会话即可。
设计模式就像是下围棋那样的一些定式,棋谱,如果对方小飞挂角,我们可以选择小飞守角,或者大飞守角等等,也就说如果一个棋力不足的新手能把一部分常用的定式或者棋谱背下来,那么遇到了类似的情况或者定式的招数就可以运用出来,而不至于下的一塌糊涂,也就是为新手菜狗提供了保底的手法。
1.单例模式
在23种典型的设计模式中就存在一种单例模式,可以很好的解决我们最初提到的,"全局单个实例"的场景,就是"单例模式"很有见名知意的感觉。
1.1 定义
单例模式在大佬给出的定义是:
通常我们可以让一个全局变量使得一个对象被访问,但它不能防止你实例化多个对象。一个最好的办法就是,让类自身负责保存它的唯一实例。这个类可以保证没有其他实例可以被创建,并且它可以提供一个访问该实例的办法。
1.提供全局变量使得一个对象被访问是什么意思呢?
MyClass instance = new MyClass(); // 每次都可以创建新的实例
可以把instance设置为一个全局变量,比如
private static final MyClass instance = new MyClass();
2.让类自身负责保存它唯一的实例是什么意思?
这个类做到两件事:
-
私有化构造方法,别人就不能随便
new
它了; -
在类中自己创建并保存唯一的实例;
-
提供一个公开的静态方法用于获取该实例。
1.2 单例模式的使用
在单例模式这个定式中,存在两种手法
1.饿汉式的单例模式
饿汉式就是,在提供全局变量的时候,就为这个全局变量创建一个实例,这个全局变量和实例一般设置为static,这样他就会随着类的加载就进行初始化。
public class Singleton {// 1. 提前创建好唯一实例(类加载时就实例化)private static final Singleton instance = new Singleton();// 2. 构造方法私有化,防止外部 newprivate Singleton() {}// 3. 提供静态方法让外部访问实例public static Singleton getInstance() {return instance;}
}
2.懒汉式的单例模式
懒汉式就是,在提供全局变量的时候并不为这个全局变量创建实例,而是等到使用时在提供的全局访问点,也就是提供的静态方法去获得实例的时候,在进行创建实例,这样就减少了类加载时的一些初始化工作。
public class Singleton {private static Singleton instance;private Singleton() {}public static synchronized Singleton getInstance() {if (instance == null) {instance = new Singleton(); // 延迟加载}return instance;}
}
单例模式除了可以保证唯一的实例外,还有什么好处呢?
比如单例模式因为Singleton类封装他的唯一实例,这样它可以严格控制客户怎么样访问它以及合适访问它,简单来说就是对唯一实例的受控访问。
单例模式通过自己管理自己的唯一实例(比如通过
private static Singleton instance
),并只提供一个公开的获取方法(如getInstance()
),从而实现对这个实例的访问控制:
外部不能随便
new
一个新的对象;外部必须通过你提供的方式来访问;
类本身可以在需要的时候控制创建时机(比如懒汉式延迟创建);
这就叫“对唯一实例的受控访问”。
单例模式看起来有点像实用类中的静态方法,比如Math类有很多数学计算方法,他们之间虽然很类似,实用类通常也会采用私有化的构造方法来避免其有实例。但是他们还是有很多不同的
单例类和工具类(实用类)在结构上是有点像的,比如:
都私有了构造方法,不允许
new
;都通过类名来访问功能(方法或实例);
比如
Math.abs(-1)
这样的调用方式也不用创建对象,看起来就和Singleton.getInstance()
类似。
1.实用类不保存状态,仅提供一些静态方法或者静态属性来让我们使用,单例模式却是有状态的。
2.实用类不能用于继承多态,而单例模式虽然实例唯一,却可以有子类来继承
3.实用类只不过是一些方法属性的集合,而单例模式确实有着唯一的对象实例。
2.多线程下的单例模式
很多代码程序在单线程下运行的十分完美,但是到了多线程的环境下就会暴露出很多短板甚至是bug,比如上面的单例模式,在多个线程同时,注意是同时访问Singleton类,调用getInstance方法是会有可能创建多个实例的。
很尴尬,那应该怎么解决呢?
线程安全问题的发现与解决-CSDN博客
我们在前面分析了,多线程下的线程安全问题,这种情况就属于线程安全的问题之一,
是因为,修改操作不是原子的情况所造成的
比如下面的代码
/*** 懒汉式单例模式*/
class SingletonLazy{private static SingletonLazy instance = null;private SingletonLazy(){};public static SingletonLazy getInstance(){if(instance == null){instance = new SingletonLazy();}return instance;}
}
我们发现,在getInstance中并没有像之前提到的count++这样的修改操作呀
也就是有个
if(instance == null)//判断 instance = new SingletonLazy();//赋值 return instance;
返回操作是一种"读操作"通常不会是多线程下bug的元凶
那么原因是因为if(instance == null)//判断 或者 instance = new SingletonLazy();//赋值 再或者是二者合并起来造成的问题吗?
Java中的赋值操作,确实本质上是一种"读操作"也不应该是造成问题的原因,
原因是第三种情况,拆开各自安好,合并就可能会出现问题了,因为if(instance == null)//判断 和instance = new SingletonLazy();//赋值 二者放在一起是一个完整的逻辑。
1.多线程改进1
问题核心就是线程不安全导致重复实例化
我们不妨尝试一下加锁,让不是原子性的操作变成加锁后的原子性的操作
synchronized (SingletonLazy.class){if(instance == null){instance = new SingletonLazy();}}
之前我们讨论解决线程安全时讲过synchronized的使用
在此处的getInstance方法中,想要加锁因为是静态方法的缘故,就要使用当前类的Class对象来充当锁对象。
如果想要使用实例的锁对象也是可以的可以这样写代码:
/*** 懒汉式单例模式*/
class SingletonLazy{private static SingletonLazy instance = null;private SingletonLazy(){};private static final Object lock = new Object();public static SingletonLazy getInstance(){synchronized (lock){if(instance == null){instance = new SingletonLazy();}}return instance;}
}
关于锁的问题,我们不再讨论,现在我们来看一下加锁后的效果
-
线程1 抢到了锁,成功进入
synchronized (lock)
的同步块; -
线程1 执行判断:
instance == null
,结果为true
; -
线程1 开始创建单例对象(执行
new SingletonLazyO()
); -
此时线程2 也调用了
getInstance()
,但因为同步块已经被线程1占用,所以线程2在 synchronized 外面等待; -
线程1 创建完实例后,退出同步块,释放了锁,并且
instance
已经指向新建好的对象; -
线程2 被唤醒,获取到了锁,进入同步块;
-
再次检查
instance == null
,这次结果为false
(因为线程1已经创建好了); -
线程2 直接返回现有的实例,避免了重复创建。
-
最终,两个线程都获得了同一个对象实例;
-
没有出现重复创建或资源浪费的问题;
-
符合单例模式“全局唯一实例”的设计目标;
-
这种方式虽然线程安全,但每次访问都进入同步块,性能稍差,可以通过双重检查优化
2.多线程改进2
我们知道加锁是存在一定的代价的
为了避免每次都进入 synchronized
块,可以使用“双重检查锁”:
public static SingletonLazy getInstance(){if(instance == null){synchronized (lock){if(instance == null){instance = new SingletonLazy();}}}return instance;}
初次见这种双重if而且内外if条件还是相同的,很多新手会觉得代码逻辑很混乱
if (instance == null)
— 第一次检查(不加锁)
-
这是性能优化的关键:
-
大多数时候,
instance
已经被创建了,不需要进入同步代码块; -
只有第一次创建的时候才需要同步;
-
避免每次都加锁,提高效率。
-
synchronized (Singleton.class)
-
加锁的对象是类的
.class
对象,因为instance
是静态变量,是整个类共享的; -
保证只有一个线程可以创建实例;
-
是解决线程安全的核心。
if (instance == null)
— 第二次检查(加锁后再确认)
-
为什么要检查两次?
-
如果不再判断一遍,多个线程可能都在排队等锁;
-
第一个线程创建了对象,释放锁后,后面的线程仍然会再创建一次,如果不检查;
-
所以要加锁后再检查一次,防止重复创建。
-
instance = new Singleton();
-
真正创建对象的地方;
-
只有在加锁的前提下,并且确认 instance 为 null 的时候才会执行。
3.多线程改进3
上面的代码仍然存在一定的缺陷,我们还有一种很隐匿的缺陷没有找到,那就是指令重排序的问题
线程安全问题的发现与解决-CSDN博客
前面我们提到,线程安全的几大问题其中之一就是,修改操作不是原子的
new SingletonLazy()
这条语句看起来仅仅只是Java的一条普通的实例化语句,但是在JVM层面就包括了三个步骤
1.为该实例开辟内存空间,分配内存
2.初始化该实例对象
3.最后instance引用赋值,引用这一块内存空间
编译器会觉得,如果我快点引用,先不初始化能不能让代码执行的更快,更高效呢?
所以它大胆的调换了执行顺序变成了
1.为该实例开辟内存空间,分配内存
3.instance引用赋值,引用这一块内存空间
2.初始化该实例对象
这不换不要紧,一换的话,如果存在别的线程在“赋值”和“初始化之间”访问这个对象,顺便修改了就会造成bug。
假如线程 A 执行到
instance = new SingletonLazy();
,由于重排序:
它已经把 instance 指向了还“没初始化”的对象
此时线程 B 也进来了,看到
instance != null
,以为已经初始化好了然后就直接拿这个对象用了(return instance)!结果呢?对象状态是不完整的!
这就产生了严重的**“半初始化对象被访问”**的问题
这里其实也说明了为什么单线程下指令重排序根本没有问题
因为
不存在别的线程在“赋值”和“初始化之间”访问这个对象,也就不存在bug。
3.多线程下单例模式的使用总结
综上,很多代码在单线程下生龙活虎,因为单线程下没有其他线程来“抢时间”、“抢资源”,所以很多细节(比如原子性、可见性、重排序)根本不会暴露出来。这就是为什么并发编程下会出现很多的问题,所以我们在使用单例模式的多线程版本的时候,要记得以下两点
-
使用双重 if 判定(Double-Checked Locking)
避免每次获取实例都加锁,提高性能。 -
在实例变量上添加
volatile
关键字
防止 JVM 发生指令重排序,确保对象初始化的完整性。