你的单例模式真的安全吗?双重检索模式和指令重排序助你高垒单例安全防御

发布时间:2022-03-01 11:12:53 作者:yexindonglai@163.com 阅读(692)

前言

    单例的五种创建方式,可以看我另一篇文章: 设计模式 -- 单例模式  , 这篇文章讲解了5种单例模式的创建方式,但今天我们来主要来讲讲单例的安全模式,那到底怎样才是安全的呢?那么接下来我会由浅入深的方式讲解单例模式;

1、饿汉式

  1. package com.designPatterm.single;
  2. /**
  3. * 单例模式--饿汉式
  4. * 线程安全,效率高
  5. */
  6. public class SingletonModelEHan {
  7. private static final SingletonModelEHan obj = new SingletonModelEHan();
  8. // 私有构造方法,防止创建多个实例
  9. private SingletonModelEHan(){
  10. // 防止反射破坏单例
  11. if(null != obj) throw new RuntimeException("单例模式不允许创建多实例");
  12. }
  13. // 获取实例
  14. public static SingletonModelEHan getInstance(){
  15. return obj;
  16. }
  17. }

1.1、饿汉式的优点  

    首先来讲讲饿汉式的实现方式,使用了静态的方式并且加上了final关键字,在静态变量上直接初始化对象,不得不说,这种方式可以说是最简单高效的,又安全,因为在class加载的时候就已经把实例给创建出来了,因为final关键字的限制让该属性无法改变,也不能指向另一个对象,直接就定死了一夫一妻制,真正做到了没有离婚, 只有丧偶;除非被垃圾回收机制给回收了,否则这个引用和实例对象永远也别想分开;

1.1、饿汉式的缺点

    刚刚说了那么多优点,难道没有缺点吗?肯定有啦,确实,饿汉式有一个致命的缺点,就是浪费资源,为什么说它浪费资源? 因为你想啊,这个单例太饿了,在类加载的时候就已经把实例给创建出来了,应用上面的一夫一妻制来说的话,在你刚出生的时候就把老婆给娶了,你要知道,你娶老婆是要耗费人民币的呀,你爸口袋里就这么几个钢镚,都用来给你娶老婆了,其他的衣食住行不要钱的吗?要是人人都这样,没有了钱,家里人不都得饿死!所以,正因为饿汉式在一加载的时候就把实例创建好了,因此也耗费了内存,如果所有的对象都这么搞的话,内存空间不就很紧张了吗! 当然,也不是没办法解决,接下来就是我将要讲解的懒汉式就是解决浪费资源问题的;

2、懒汉式

    懒汉式其实很好理解,就是懒加载机制,单例模式不是立马就创建出来的,而是你需要用到的时候在去创建,就像这样

  1. package com.designPatterm.single;
  2. /**
  3. * 单例模式--懒汉式
  4. *
  5. */
  6. public class SingletonModelLanHan {
  7. // 单例对象
  8. private static SingletonModelLanHan obj = null;
  9. // 获取实例
  10. public static SingletonModelLanHan getInstance() {
  11. if(null == obj){
  12. try {
  13. // 延时1ms,实际应用中还需要要执行其他逻辑代码,用这1ms代替
  14. Thread.sleep(1);
  15. } catch (InterruptedException e) {
  16. e.printStackTrace();
  17. }
  18. obj = new SingletonModelLanHan();
  19. }
  20. return obj;
  21. }
  22. public static void main(String[] args) {
  23. // 模拟高并发场景,用30个线程同时访问单例模式
  24. for (int i = 0; i < 30; i++) {
  25. new Thread(new Runnable() {
  26. @Override
  27. public void run() {
  28. SingletonModelLanHan instance = SingletonModelLanHan.getInstance();
  29. System.out.println("单例对象的hashCode:"+instance.hashCode());
  30. }
  31. }).start();
  32. }
  33. }
  34. }

乍一看好像能解决耗资源的问题,但是你这样不安全啊,我单线程还好,如果我并发量高的话,照样会创建多个实例出来,不信?来,测试一把,运行main方法,然后用30个线程来同时调用  getInstance() 方法,然后打印每个单例的hashCode,如果hashCode不一样,就可以肯定是创建了多个实例;

运行main方法后,打印结果如下,由此可以看到,大多数实例的hashCode都是不一样的,可以证明,这个方案已经创建了多个实例;可以认定为是不可行的;

  1. 单例对象的hashCode:429842367
  2. 单例对象的hashCode:1787454234
  3. 单例对象的hashCode:1787454234
  4. 单例对象的hashCode:963329488
  5. 单例对象的hashCode:678111586
  6. 单例对象的hashCode:1123839895
  7. 单例对象的hashCode:1259462729
  8. 单例对象的hashCode:678111586
  9. 单例对象的hashCode:2140280123
  10. 单例对象的hashCode:1259462729
  11. 单例对象的hashCode:672455970
  12. 单例对象的hashCode:1259462729
  13. 单例对象的hashCode:1357645154
  14. 单例对象的hashCode:107286582
  15. 单例对象的hashCode:1544341753
  16. 单例对象的hashCode:82146440
  17. 单例对象的hashCode:82146440
  18. 单例对象的hashCode:672455970
  19. 单例对象的hashCode:2140280123
  20. 单例对象的hashCode:1123839895
  21. 单例对象的hashCode:1073180601
  22. 单例对象的hashCode:1477151319
  23. 单例对象的hashCode:1073180601
  24. 单例对象的hashCode:1073180601
  25. 单例对象的hashCode:1544341753
  26. 单例对象的hashCode:1544341753
  27. 单例对象的hashCode:1544341753
  28. 单例对象的hashCode:1544341753
  29. 单例对象的hashCode:1544341753
  30. 单例对象的hashCode:107286582
  31. Process finished with exit code 0

因为多个线程没有顺序地争抢时间片,导致拿到的数据不具备原子性,也就是线程不安全的行为,多个线程执行数据时的流程如下

2.1、解决线程安全问题--使用同步方法(synchronized)

    让我们加个锁来试试,看看是不是能解决问题,很简单,就是在getInstance() 方法上面加个 synchronized 同步关键字,其他代码不变

关键代码 :  public static synchronized SingletonModelLanHan getInstance()

  1. package com.designPatterm.single;
  2. /**
  3. * 单例模式--懒汉式
  4. *
  5. */
  6. public class SingletonModelLanHan {
  7. // 单例对象
  8. private static SingletonModelLanHan obj = null;
  9. // 获取实例,加锁实现线程安全
  10. public static synchronized SingletonModelLanHan getInstance() {
  11. if(null == obj){
  12. try {
  13. // 延时1ms,实际应用中还需要要执行其他逻辑代码,用这1ms代替
  14. Thread.sleep(1);
  15. } catch (InterruptedException e) {
  16. e.printStackTrace();
  17. }
  18. obj = new SingletonModelLanHan();
  19. }
  20. return obj;
  21. }
  22. public static void main(String[] args) {
  23. // 模拟高并发场景,用100个线程同时访问单例模式
  24. for (int i = 0; i < 30; i++) {
  25. new Thread(new Runnable() {
  26. @Override
  27. public void run() {
  28. SingletonModelLanHan instance = SingletonModelLanHan.getInstance();
  29. System.out.println("单例对象的hashCode:"+instance.hashCode());
  30. }
  31. }).start();
  32. }
  33. }
  34. }

运行之后,问题确实解决了,打印了清一色的hashCode,全都一样的,

  1. 单例对象的hashCode:1152408485
  2. 单例对象的hashCode:1152408485
  3. 单例对象的hashCode:1152408485
  4. 单例对象的hashCode:1152408485
  5. 单例对象的hashCode:1152408485
  6. 单例对象的hashCode:1152408485
  7. 单例对象的hashCode:1152408485
  8. 单例对象的hashCode:1152408485
  9. 单例对象的hashCode:1152408485
  10. 单例对象的hashCode:1152408485
  11. 单例对象的hashCode:1152408485
  12. 单例对象的hashCode:1152408485
  13. 单例对象的hashCode:1152408485
  14. 单例对象的hashCode:1152408485
  15. 单例对象的hashCode:1152408485
  16. 单例对象的hashCode:1152408485
  17. 单例对象的hashCode:1152408485
  18. 单例对象的hashCode:1152408485
  19. 单例对象的hashCode:1152408485
  20. 单例对象的hashCode:1152408485
  21. 单例对象的hashCode:1152408485
  22. 单例对象的hashCode:1152408485
  23. 单例对象的hashCode:1152408485
  24. 单例对象的hashCode:1152408485
  25. 单例对象的hashCode:1152408485
  26. 单例对象的hashCode:1152408485
  27. 单例对象的hashCode:1152408485
  28. 单例对象的hashCode:1152408485
  29. 单例对象的hashCode:1152408485
  30. 单例对象的hashCode:1152408485
  31. Process finished with exit code 0

2.1.1 加锁后带来的问题(效率低下

    你别说,加上 synchronized 关键字 之后,确实解决了线程安全的问题, 但是随之而来的又有了另一个问题: 效率低下,为什么这么说呢?其实啊,用你那聪明绝顶的脑瓜子想想就知道,synchronized 是悲观锁,每次调用这个方法之后都会把当前线程给锁住,我第一次创建实例的时候你把我锁住没关系,但是当实例创建好之后,我每次取单例对象的时候你也把线程给锁了,那么其他线程要取对象的时候就必须得排队,先进入阻塞状态,待获得锁的线程解锁后才能获取对象实例,这样对整个系统而言速度就下降了,

3、解决效率低下问题(使用同步代码块)

      因为在方法上加上了synchronized关键字,所以不管是获取实例还是创建实例,都会上锁,所以我们这里将代码优化一下,只有创建实例的时候才上锁,获取实例就不上锁了,在这里的同步方法,改为同步代码块,

  1. package com.designPatterm.single;
  2. /**
  3. * 单例模式--懒汉式
  4. *
  5. */
  6. public class SingletonModelLanHan {
  7. // 单例对象
  8. private static SingletonModelLanHan obj = null;
  9. // 获取实例,加锁实现线程安全
  10. public static SingletonModelLanHan getInstance() {
  11. // 只有实力为空的时候才上锁
  12. if(null == obj){
  13. // 同步代码块,锁住当前类
  14. synchronized(SingletonModelLanHan.class){
  15. try {
  16. // 延时1ms,实际应用中还需要要执行其他逻辑代码,用这1ms代替
  17. Thread.sleep(1);
  18. } catch (InterruptedException e) {
  19. e.printStackTrace();
  20. }
  21. obj = new SingletonModelLanHan();
  22. }
  23. }
  24. return obj;
  25. }
  26. public static void main(String[] args) {
  27. // 模拟高并发场景,用100个线程同时访问单例模式
  28. for (int i = 0; i < 30; i++) {
  29. new Thread(new Runnable() {
  30. @Override
  31. public void run() {
  32. SingletonModelLanHan instance = SingletonModelLanHan.getInstance();
  33. System.out.println("单例对象的hashCode:"+instance.hashCode());
  34. }
  35. }).start();
  36. }
  37. }
  38. }

关键代码如下,

  1. // 同步代码块,锁住当前类
  2. synchronized(SingletonModelLanHan.class){
  3. try {
  4. // 延时1ms,实际应用中还需要要执行其他逻辑代码,用这1ms代替
  5. Thread.sleep(1);
  6. } catch (InterruptedException e) {
  7. e.printStackTrace();
  8. }
  9. obj = new SingletonModelLanHan();
  10. }

运行后,虽然效率上来了,但我们发现了一个更加致命的问题,打印的hashCode不一样了,完了完了, 又变成了线程不安全的单例了

  1. 单例对象的hashCode:429842367
  2. 单例对象的hashCode:429842367
  3. 单例对象的hashCode:429842367
  4. 单例对象的hashCode:429842367
  5. 单例对象的hashCode:429842367
  6. 单例对象的hashCode:429842367
  7. 单例对象的hashCode:429842367
  8. 单例对象的hashCode:429842367
  9. 单例对象的hashCode:429842367
  10. 单例对象的hashCode:429842367
  11. 单例对象的hashCode:429842367
  12. 单例对象的hashCode:196693010
  13. 单例对象的hashCode:196693010
  14. 单例对象的hashCode:196693010
  15. 单例对象的hashCode:196693010
  16. 单例对象的hashCode:196693010
  17. 单例对象的hashCode:196693010
  18. 单例对象的hashCode:196693010
  19. 单例对象的hashCode:1508659604
  20. 单例对象的hashCode:1893540388
  21. 单例对象的hashCode:107286582
  22. 单例对象的hashCode:1732326040
  23. 单例对象的hashCode:1357645154
  24. 单例对象的hashCode:1916740950
  25. 单例对象的hashCode:595972758
  26. 单例对象的hashCode:242512469
  27. 单例对象的hashCode:1123839895
  28. 单例对象的hashCode:1787454234
  29. 单例对象的hashCode:678111586
  30. 单例对象的hashCode:1195510409
  31. Process finished with exit code 0

4、效率高又安全(双重检查锁定模式)

    双重检索模式(double checked locking)是一种优化技术,先判断对象是否已经被初始化,再决定要不要加锁。其实原理很简单,就是在同步代码块的前面以及后面再加一层判断来保证线程安全,这样的做可以确保单例模式效率高的同时又安全

  1. package com.designPatterm.single;
  2. /**
  3. * 单例模式--懒汉式
  4. *
  5. */
  6. public class SingletonModelLanHan {
  7. // 单例对象
  8. private static SingletonModelLanHan obj = null;
  9. // 获取实例,加锁实现线程安全
  10. public static SingletonModelLanHan getInstance() {
  11. // 只有实力为空的时候才上锁
  12. if(null == obj){
  13. // 同步代码块,锁住当前类
  14. synchronized(SingletonModelLanHan.class){
  15. if(null == obj){
  16. obj = new SingletonModelLanHan();
  17. }
  18. }
  19. }
  20. return obj;
  21. }
  22. public static void main(String[] args) {
  23. // 模拟高并发场景,用100个线程同时访问单例模式
  24. for (int i = 0; i < 30; i++) {
  25. new Thread(new Runnable() {
  26. @Override
  27. public void run() {
  28. SingletonModelLanHan instance = SingletonModelLanHan.getInstance();
  29. System.out.println("单例对象的hashCode:"+instance.hashCode());
  30. }
  31. }).start();
  32. }
  33. }
  34. }

关键代码如下,可以看到图中用了2个判空来保证安全性,

  1. 第一重判空检查变量是否被初始化(不去获得锁),如果已被初始化则立即返回。
  2. 第二重判空检查变量是否已经被初始化,如果还没被初始化就初始化一个对象。

啥也不说,运行以下看看效果先,

  1. 单例对象的hashCode:1195510409
  2. 单例对象的hashCode:1195510409
  3. 单例对象的hashCode:1195510409
  4. 单例对象的hashCode:1195510409
  5. 单例对象的hashCode:1195510409
  6. 单例对象的hashCode:1195510409
  7. 单例对象的hashCode:1195510409
  8. 单例对象的hashCode:1195510409
  9. 单例对象的hashCode:1195510409
  10. 单例对象的hashCode:1195510409
  11. 单例对象的hashCode:1195510409
  12. 单例对象的hashCode:1195510409
  13. 单例对象的hashCode:1195510409
  14. 单例对象的hashCode:1195510409
  15. 单例对象的hashCode:1195510409
  16. 单例对象的hashCode:1195510409
  17. 单例对象的hashCode:1195510409
  18. 单例对象的hashCode:1195510409
  19. 单例对象的hashCode:1195510409
  20. 单例对象的hashCode:1195510409
  21. 单例对象的hashCode:1195510409
  22. 单例对象的hashCode:1195510409
  23. 单例对象的hashCode:1195510409
  24. 单例对象的hashCode:1195510409
  25. 单例对象的hashCode:1195510409
  26. 单例对象的hashCode:1195510409
  27. 单例对象的hashCode:1195510409
  28. 单例对象的hashCode:1195510409
  29. 单例对象的hashCode:1195510409
  30. 单例对象的hashCode:1195510409
  31. Process finished with exit code 0

打印的HashCode结果都一致了,好像没啥问题了,但是这里忽略一个更重要的问题,就是指令重排序,下一步我们接着讲解

5、绝对安全的单例模式(防止指令重排序)

防止指令重排序就能解决问题?那么在此之前,我们需要先知道什么是指令重排序;

5.1、什么是指令重排序

java和CPU、内存之间都有一套严格的指令重排序规则,哪些可以重排,哪些不能重排都有规矩的。编译器和处理器为了提高程序的运行性能,对指令进行重新排序。

cpu在执行一行指令的时候是非常快的,可以达到纳秒级别, 但是如果执行指令的时候涉及到内存的读写,就会慢很多,可能需要上百甚至上千纳秒的时间;如果把cpu执行读写内存比喻为走路的话,那么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;

  1. package com.designPatterm.single;
  2. /**
  3. * 单例模式--懒汉式
  4. *
  5. */
  6. public class SingletonModelLanHan {
  7. // 单例对象
  8. private volatile static SingletonModelLanHan obj = null;
  9. // 获取实例,加锁实现线程安全
  10. public static SingletonModelLanHan getInstance() {
  11. // 只有实力为空的时候才上锁
  12. if(null == obj){
  13. // 同步代码块,锁住当前类
  14. synchronized(SingletonModelLanHan.class){
  15. if(null == obj){
  16. obj = new SingletonModelLanHan();
  17. }
  18. }
  19. }
  20. return obj;
  21. }
  22. public static void main(String[] args) {
  23. // 模拟高并发场景,用100个线程同时访问单例模式
  24. for (int i = 0; i < 30; i++) {
  25. new Thread(new Runnable() {
  26. @Override
  27. public void run() {
  28. SingletonModelLanHan instance = SingletonModelLanHan.getInstance();
  29. System.out.println("单例对象的hashCode:"+instance.hashCode());
  30. }
  31. }).start();
  32. }
  33. }
  34. }

关键字Java