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

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个判空来保证安全性,

  1. 第一重判空检查变量是否被初始化(不去获得锁),如果已被初始化则立即返回。
  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();
        }
    }
}

以上是关于你的单例模式真的安全吗?双重检索模式和指令重排序助你高垒单例安全防御的主要内容,如果未能解决你的问题,请参考以下文章

Java - 单例陷阱——双重检查锁中的指令重排问题

单例模式-DCL

Java开发篇——设计模式单例模式你真的了解吗?(下)

单例模式双重锁为什么要volitie修饰:

单例设计模式和Java内存模型

单例模式双重检查(DCL)引发的多线程问题