Java并发编程:彻底搞懂单例模式中的安全隐患
单例模式(Singleton Pattern)
单例模式是一种设计模式,它的核心目标是:确保一个类在整个执行过程中,有且只有一个实例,并提供一个公共静态方法来获取这个唯一实例。
单例模式的特点:
·私有构造函数:使得外部无法通过构造函数来创建出实例(这里我们不谈反射)
·私有静态实例:类内部有且只有一个实例对象
·公共静态方法:一个获取这个类对象的方法
为什么要使用单例模式?
·节省开销
在日常开发中,有的(例如线程池,数据库连接)等相关类,创建出其对象的成本很高,为了避免资源的浪费,就可以用到单例模式起到一定的约束作用,使得这个类只能被创建一次
·保证全局访问一致
由于这个对象被static修饰,使得这个对象成为独一份的存在,大家拿到的这个实例对象都是同一个对象,里面存着同一份数据,这可以很好避免出现状态不一致等问题
·简化调用方式
我们通过类引用静态方法就可以随时拿到这个类对象,非常方便
单例模式又分饿汉和懒汉两个类型,接下来我们逐一介绍并深入了解和解决其安全隐患问题
饿汉模式:
为什么又叫饿汉模式?这个饿就体现了急迫,非常急
代码中的体现“饿”的形式:
我们都知道,由static修饰的成员或者方法会在程序一启动就马上被创建,所以饿汉模式也是一样,他会在程序一启动就创建出对象。
代码实例:
class Singleton{
private static Singleton instance = new Singleton();
public Singleton getInstance(){
return instance;
}
private Singleton(){
}
}
那么饿汉模式是否存在安全隐患问题?
不谈反射这一非常规获取类对象手段,饿汉是不存在线程安全问题的,为什么呢?
我们来总结之前谈到的导致线程安全问题的五大罪魁祸首
·操作系统对线程的调度是随机的,抢占式执行
·多个线程同时修改同一个变量
·修改操作是非原子的
·内存可见性问题
·指令重排序问题
在饿汉模式中,我们只能调用一个getInstance()方法,再没有别的方法了,因为我们无法调用被private修饰的构造方法,那么这里在多线程环境下,就只涉及到 读 这一操作了,不存在修改操作,所以就不会出现线程安全问题
⭐懒汉模式:
接下来我们重点讨论懒汉模式的语法和安全隐患问题:
我们日常开发中一般推荐使用懒汉模式,为什么呢?
记住,在计算机和现实生活中,形容一个东西的词的效果很多都是相反的,例如现实中说小明很懒,这是明显的贬义词,但在计算机中,懒体现在“非必要情况下,我不怎么怎么样”,也就是说在非必要情况下,我甚至可以不执行某些操作,这会节省很多开销,故计算机中“懒”其实是褒义词
懒汉模式的懒体现在哪?
这个“懒”就体现在上述谈到的:“非必要情况下,我不怎么怎么样。。。”
代码中“懒”的体现:
不会像饿汉模式一样,在程序一启动就立马创建出实例对象,而是视情况(是否有人调用getInstance方法)来决定是否创建对象,如果没人调用,甚至都不会创建这个对象,这也节省了不少开销
接下来我们展示初步代码,并分析其安全隐患问题:
class SingletonLazy {
private static SingletonLazy instance = null;
private SingletonLazy() {
}
public SingletonLazy getInstance() {
if (instance == null) {
instance = new SingletonLazy();
}
return instance;
}
}
我们观察getInstance方法:
if (instance == null) {
instance = new SingletonLazy();
}
这行代码在多线程环境下出现的安全隐患显而易见:
·内存可见性问题:假设这里有两个线程,同时调用getInstance方法,虽然我们说只“读”这一操作不会导致线程安全问题,但这几行代码是指“我读,读完后判断是否创建对象”,这个读就会涉及到“改”这一操作了。
之前我们也谈到“读”涉及到的底层的三个操作:
【LOAD】CPU会从内存中读取instance的值并读到CPU的寄存器上
【CMP】CPU会在寄存器上对变量进行加加或者减减等运算,也能判断和指定目标是否相等
【STORE】比较结果,将结果返回到内存中
由于这一个“读”就涉及到三步操作了,我们就说“读”这一操作是非原子的,在多线程环境下很可能出现安全问题
例子:
假设此时有A,B两个线程
这里我们设LOAD就是读,new就是指创建出了一个对象

这种情况就会导致出现线程安全问题,二者都读到了null,最后都创建出了对象,使得我们创建了两次对象,这违背了我们节省开销的初衷,那么我们该如何解决这种内存可见性问题还有操作是非原子的,这两个问题呢?
解决操作是非原子问题的方案:
加锁
我们通过加锁将LOAD和NEW这两操作绑定起来,成为一步操作,就解决了原子问题
Object locker = new Object();
public SingletonLazy getInstance() {
synchronized (locker) {
if (instance == null) {
instance = new SingletonLazy();
}
}
return instance;
}
解决内存可见性问题的方案:
我们直接使用volatile来修饰这个instance变量即可解决,如何解决,这方面我们不深入研究
private static volatile SingletonLazy instance = null;
解决完这两个问题后,我们看着代码再思考思考:
public SingletonLazy getInstance() {
synchronized (locker) {
if (instance == null) {
instance = new SingletonLazy();
}
}
return instance;
}
想一想,如果有很多线程同时调用getInstance()这个方法,但我们判断instance==null,只要判断一次就够了,因为这会创建成对象,以后的线程调用这个方法直接return走人就好了,无需再判断,但现在的代码是:
如果多个线程调用这个方法,那么就会多次执行:
拿锁 --> 判断 --> 解锁 --> return
明明只要判断一次就够了,但每个线程都会判断一次,虽然判断一次也消耗不了多少资源,但问题是线程是拿锁去判断的,根据锁的互斥和保持等性质,这使得我们代码执行的效率会大打折扣
优化方案:
我们要做到只判断一次,以后调用这个方法的线程直接return走人,那么我们就在锁外面再加一步,只有当instance=null的时候才能进去判断并new,new完后instance!=null了,以后的线程调用这个方法,就会直接执行到return走人
代码实例:
public SingletonLazy getInstance() {
if (instance==null) {
synchronized (locker) {
if (instance == null) {
instance = new SingletonLazy();
}
}
}
return instance;
}
那么我们再深入思考,是否还有安全隐患问题?
指令重排序问题:
讨论这个问题时,我们先谈谈NEW一个对象涉及到的三步操作:
1.向内存申请空间
2.初始化这个对象,完善其中的功能
3.将值返回给引用
解决了操作非原子,内存可见性问题,我们就只剩return,返回实例对象这一操作了,这里我们将上述讲到的三步操作以买房子为例:
1.申请空间(花钱买房)
2.初始化对象,完善功能(装修房子)
3.将值返回给引用(房东把钥匙给业主)
这个时候,编译器为了优化性能,就会实行指令重排序,很可能会把(1.2.3)优化为(1.3.2)
这时候在多线程环境下,线程A(施工队准备装修房子,也就是准备初始化完善功能),在132的情况下,线程A因为指令重排序问题,在还没装修房子的情况下就把钥匙交出去了,就在这一时刻,刚好线程B也在调用getInstance方法,成功拿到了钥匙,但一进房子,发现房子根本没装修,根本没法住!
解决指令重排序问题的方案:
其实volatile关键字不仅解决了内存可见性问题,还顺带解决了指令重排序问题,所以解决方案是给变量instance加上volatile修饰即可解决,这里我们不再深入了解
private static volatile SingletonLazy instance = null; //volatile不仅解决了内存可见性问题,还顺带解决了指令重排序问题
懒汉模式最终版本:
class SingletonLazy {
private static volatile SingletonLazy instance = null;//volatile不仅解决了内存可见性问题,还顺带解决了指令重排序问题
private SingletonLazy() {
}
Object locker = new Object();
public SingletonLazy getInstance() {
if (instance==null) {
synchronized (locker) {
if (instance == null) {
instance = new SingletonLazy();
}
}
}
return instance;
}
}
到这里,我们的懒汉版的单例模式就算是没有什么安全隐患了(不讨论反射的情况下),接下来我们会自主实现一个阻塞队列、线程池、定时器
更多推荐


所有评论(0)