你的单例模式真的安全吗?双重检索模式和指令重排序助你高垒单例安全防御
Posted java叶新东老师
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了你的单例模式真的安全吗?双重检索模式和指令重排序助你高垒单例安全防御相关的知识,希望对你有一定的参考价值。
前言
单例的五种创建方式,可以看我另一篇文章: 设计模式 -- 单例模式 , 这篇文章讲解了5种单例模式的创建方式,但今天我们来主要来讲讲单例的安全模式,那到底怎样才是安全的呢?那么接下来我会由浅入深的方式讲解单例模式;
1、饿汉式
package com.designPatterm.single;
/**
* 单例模式--饿汉式
* 线程安全,效率高
*/
public class SingletonModelEHan {
private static final SingletonModelEHan obj = new SingletonModelEHan();
// 私有构造方法,防止创建多个实例
private SingletonModelEHan(){
// 防止反射破坏单例
if(null != obj) throw new RuntimeException("单例模式不允许创建多实例");
}
// 获取实例
public static SingletonModelEHan getInstance(){
return obj;
}
}
1.1、饿汉式的优点
首先来讲讲饿汉式的实现方式,使用了静态的方式并且加上了final关键字,在静态变量上直接初始化对象,不得不说,这种方式可以说是最简单高效的,又安全,因为在class加载的时候就已经把实例给创建出来了,因为final关键字的限制让该属性无法改变,也不能指向另一个对象,直接就定死了一夫一妻制,真正做到了没有离婚, 只有丧偶;除非被垃圾回收机制给回收了,否则这个引用和实例对象永远也别想分开;
1.1、饿汉式的缺点
刚刚说了那么多优点,难道没有缺点吗?肯定有啦,确实,饿汉式有一个致命的缺点,就是浪费资源,为什么说它浪费资源? 因为你想啊,这个单例太饿了,在类加载的时候就已经把实例给创建出来了,应用上面的一夫一妻制来说的话,在你刚出生的时候就把老婆给娶了,你要知道,你娶老婆是要耗费人民币的呀,你爸口袋里就这么几个钢镚,都用来给你娶老婆了,其他的衣食住行不要钱的吗?要是人人都这样,没有了钱,家里人不都得饿死!所以,正因为饿汉式在一加载的时候就把实例创建好了,因此也耗费了内存,如果所有的对象都这么搞的话,内存空间不就很紧张了吗! 当然,也不是没办法解决,接下来就是我将要讲解的懒汉式就是解决浪费资源问题的;
2、懒汉式
懒汉式其实很好理解,就是懒加载机制,单例模式不是立马就创建出来的,而是你需要用到的时候在去创建,就像这样
package com.designPatterm.single;
/**
* 单例模式--懒汉式
*
*/
public class SingletonModelLanHan {
// 单例对象
private static SingletonModelLanHan obj = null;
// 获取实例
public static SingletonModelLanHan getInstance() {
if(null == obj){
try {
// 延时1ms,实际应用中还需要要执行其他逻辑代码,用这1ms代替
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
obj = new SingletonModelLanHan();
}
return obj;
}
public static void main(String[] args) {
// 模拟高并发场景,用30个线程同时访问单例模式
for (int i = 0; i < 30; i++) {
new Thread(new Runnable() {
@Override
public void run() {
SingletonModelLanHan instance = SingletonModelLanHan.getInstance();
System.out.println("单例对象的hashCode:"+instance.hashCode());
}
}).start();
}
}
}
乍一看好像能解决耗资源的问题,但是你这样不安全啊,我单线程还好,如果我并发量高的话,照样会创建多个实例出来,不信?来,测试一把,运行main方法,然后用30个线程来同时调用 getInstance() 方法,然后打印每个单例的hashCode,如果hashCode不一样,就可以肯定是创建了多个实例;
运行main方法后,打印结果如下,由此可以看到,大多数实例的hashCode都是不一样的,可以证明,这个方案已经创建了多个实例;可以认定为是不可行的;
单例对象的hashCode:429842367
单例对象的hashCode:1787454234
单例对象的hashCode:1787454234
单例对象的hashCode:963329488
单例对象的hashCode:678111586
单例对象的hashCode:1123839895
单例对象的hashCode:1259462729
单例对象的hashCode:678111586
单例对象的hashCode:2140280123
单例对象的hashCode:1259462729
单例对象的hashCode:672455970
单例对象的hashCode:1259462729
单例对象的hashCode:1357645154
单例对象的hashCode:107286582
单例对象的hashCode:1544341753
单例对象的hashCode:82146440
单例对象的hashCode:82146440
单例对象的hashCode:672455970
单例对象的hashCode:2140280123
单例对象的hashCode:1123839895
单例对象的hashCode:1073180601
单例对象的hashCode:1477151319
单例对象的hashCode:1073180601
单例对象的hashCode:1073180601
单例对象的hashCode:1544341753
单例对象的hashCode:1544341753
单例对象的hashCode:1544341753
单例对象的hashCode:1544341753
单例对象的hashCode:1544341753
单例对象的hashCode:107286582
Process finished with exit code 0
因为多个线程没有顺序地争抢时间片,导致拿到的数据不具备原子性,也就是线程不安全的行为,多个线程执行数据时的流程如下
2.1、解决线程安全问题--使用同步方法(synchronized)
让我们加个锁来试试,看看是不是能解决问题,很简单,就是在getInstance() 方法上面加个 synchronized 同步关键字,其他代码不变
关键代码 : public static synchronized SingletonModelLanHan getInstance()
package com.designPatterm.single;
/**
* 单例模式--懒汉式
*
*/
public class SingletonModelLanHan {
// 单例对象
private static SingletonModelLanHan obj = null;
// 获取实例,加锁实现线程安全
public static synchronized SingletonModelLanHan getInstance() {
if(null == obj){
try {
// 延时1ms,实际应用中还需要要执行其他逻辑代码,用这1ms代替
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
obj = new SingletonModelLanHan();
}
return obj;
}
public static void main(String[] args) {
// 模拟高并发场景,用100个线程同时访问单例模式
for (int i = 0; i < 30; i++) {
new Thread(new Runnable() {
@Override
public void run() {
SingletonModelLanHan instance = SingletonModelLanHan.getInstance();
System.out.println("单例对象的hashCode:"+instance.hashCode());
}
}).start();
}
}
}
运行之后,问题确实解决了,打印了清一色的hashCode,全都一样的,
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
单例对象的hashCode:1152408485
Process finished with exit code 0
2.1.1 加锁后带来的问题(效率低下)
你别说,加上 synchronized 关键字 之后,确实解决了线程安全的问题, 但是随之而来的又有了另一个问题: 效率低下,为什么这么说呢?其实啊,用你那聪明绝顶的脑瓜子想想就知道,synchronized 是悲观锁,每次调用这个方法之后都会把当前线程给锁住,我第一次创建实例的时候你把我锁住没关系,但是当实例创建好之后,我每次取单例对象的时候你也把线程给锁了,那么其他线程要取对象的时候就必须得排队,先进入阻塞状态,待获得锁的线程解锁后才能获取对象实例,这样对整个系统而言速度就下降了,
3、解决效率低下问题(使用同步代码块)
因为在方法上加上了synchronized关键字,所以不管是获取实例还是创建实例,都会上锁,所以我们这里将代码优化一下,只有创建实例的时候才上锁,获取实例就不上锁了,在这里的同步方法,改为同步代码块,
package com.designPatterm.single;
/**
* 单例模式--懒汉式
*
*/
public class SingletonModelLanHan {
// 单例对象
private static SingletonModelLanHan obj = null;
// 获取实例,加锁实现线程安全
public static SingletonModelLanHan getInstance() {
// 只有实力为空的时候才上锁
if(null == obj){
// 同步代码块,锁住当前类
synchronized(SingletonModelLanHan.class){
try {
// 延时1ms,实际应用中还需要要执行其他逻辑代码,用这1ms代替
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
obj = new SingletonModelLanHan();
}
}
return obj;
}
public static void main(String[] args) {
// 模拟高并发场景,用100个线程同时访问单例模式
for (int i = 0; i < 30; i++) {
new Thread(new Runnable() {
@Override
public void run() {
SingletonModelLanHan instance = SingletonModelLanHan.getInstance();
System.out.println("单例对象的hashCode:"+instance.hashCode());
}
}).start();
}
}
}
关键代码如下,
// 同步代码块,锁住当前类
synchronized(SingletonModelLanHan.class){
try {
// 延时1ms,实际应用中还需要要执行其他逻辑代码,用这1ms代替
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
obj = new SingletonModelLanHan();
}
运行后,虽然效率上来了,但我们发现了一个更加致命的问题,打印的hashCode不一样了,完了完了, 又变成了线程不安全的单例了
单例对象的hashCode:429842367
单例对象的hashCode:429842367
单例对象的hashCode:429842367
单例对象的hashCode:429842367
单例对象的hashCode:429842367
单例对象的hashCode:429842367
单例对象的hashCode:429842367
单例对象的hashCode:429842367
单例对象的hashCode:429842367
单例对象的hashCode:429842367
单例对象的hashCode:429842367
单例对象的hashCode:196693010
单例对象的hashCode:196693010
单例对象的hashCode:196693010
单例对象的hashCode:196693010
单例对象的hashCode:196693010
单例对象的hashCode:196693010
单例对象的hashCode:196693010
单例对象的hashCode:1508659604
单例对象的hashCode:1893540388
单例对象的hashCode:107286582
单例对象的hashCode:1732326040
单例对象的hashCode:1357645154
单例对象的hashCode:1916740950
单例对象的hashCode:595972758
单例对象的hashCode:242512469
单例对象的hashCode:1123839895
单例对象的hashCode:1787454234
单例对象的hashCode:678111586
单例对象的hashCode:1195510409
Process finished with exit code 0
4、效率高又安全(双重检查锁定模式)
双重检索模式(double checked locking)是一种优化技术,先判断对象是否已经被初始化,再决定要不要加锁。其实原理很简单,就是在同步代码块的前面以及后面再加一层判断来保证线程安全,这样的做可以确保单例模式效率高的同时又安全
package com.designPatterm.single;
/**
* 单例模式--懒汉式
*
*/
public class SingletonModelLanHan {
// 单例对象
private static SingletonModelLanHan obj = null;
// 获取实例,加锁实现线程安全
public static SingletonModelLanHan getInstance() {
// 只有实力为空的时候才上锁
if(null == obj){
// 同步代码块,锁住当前类
synchronized(SingletonModelLanHan.class){
if(null == obj){
obj = new SingletonModelLanHan();
}
}
}
return obj;
}
public static void main(String[] args) {
// 模拟高并发场景,用100个线程同时访问单例模式
for (int i = 0; i < 30; i++) {
new Thread(new Runnable() {
@Override
public void run() {
SingletonModelLanHan instance = SingletonModelLanHan.getInstance();
System.out.println("单例对象的hashCode:"+instance.hashCode());
}
}).start();
}
}
}
关键代码如下,可以看到图中用了2个判空来保证安全性,
- 第一重判空检查变量是否被初始化(不去获得锁),如果已被初始化则立即返回。
- 第二重判空检查变量是否已经被初始化,如果还没被初始化就初始化一个对象。
啥也不说,运行以下看看效果先,
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
单例对象的hashCode:1195510409
Process finished with exit code 0
打印的HashCode结果都一致了,好像没啥问题了,但是这里忽略一个更重要的问题,就是指令重排序,下一步我们接着讲解
5、绝对安全的单例模式(防止指令重排序)
防止指令重排序就能解决问题?那么在此之前,我们需要先知道什么是指令重排序;
5.1、什么是指令重排序
java和CPU、内存之间都有一套严格的指令重排序规则,哪些可以重排,哪些不能重排都有规矩的。编译器和处理器为了提高程序的运行性能,对指令进行重新排序。
cpu在执行一行指令的时候是非常快的,可以达到纳秒级别, 但是如果执行指令的时候涉及到内存的读写,就会慢很多,可能需要上百甚至上千纳秒的时间;如果把cpu执行读写内存比喻为走路的话,那么cpu执行一行未设计到内存的指令就可以比喻为坐飞机,是非常快的,那在这个时候,cpu为了让执行效率最大化,就会对指令进行重新排序,重排序之后不会对语义和执行结果造成影响,只是为了执行速度更快而排序;关于重排序,以下的图中展示了排序前和排序后的变化,
通过排序后我们可以看到,
- 排序前顺序是1、2、3、4,
- 排序后变成了2、4、1、3
5.2、volatile 关键字防止指令重排序
因为java 在创建对象的时候有个半初始化的状态,关于什么是半初始化,请看我的另一篇文章有详细说明: java创建对象过程 实例化和初始化
在双重检查锁定里面,有可能第一个线程已经执行到了半初始化的状态,但是第二个线程在第一重判断里面判断它已经是实例化了,但是还没初始化,因为不为空,所以把这个半初始化的对象return 出去了,注意,这是个不完整的对象,因为它还没初始化,所以它还不能使用,为了解决这个问题,我们就需要加上volatile 关键字防止指令重排序,用法也很简单,在单例对象上加上volatile 关键字就可以了;
关键代码: private volatile static SingletonModelLanHan obj = null;
package com.designPatterm.single;
/**
* 单例模式--懒汉式
*
*/
public class SingletonModelLanHan {
// 单例对象
private volatile static SingletonModelLanHan obj = null;
// 获取实例,加锁实现线程安全
public static SingletonModelLanHan getInstance() {
// 只有实力为空的时候才上锁
if(null == obj){
// 同步代码块,锁住当前类
synchronized(SingletonModelLanHan.class){
if(null == obj){
obj = new SingletonModelLanHan();
}
}
}
return obj;
}
public static void main(String[] args) {
// 模拟高并发场景,用100个线程同时访问单例模式
for (int i = 0; i < 30; i++) {
new Thread(new Runnable() {
@Override
public void run() {
SingletonModelLanHan instance = SingletonModelLanHan.getInstance();
System.out.println("单例对象的hashCode:"+instance.hashCode());
}
}).start();
}
}
}
以上是关于你的单例模式真的安全吗?双重检索模式和指令重排序助你高垒单例安全防御的主要内容,如果未能解决你的问题,请参考以下文章