DCL单例模式与禁止指令重排序(volatile)

·  阅读 1241

对象的半初始化

在说volatile关键字之前我们需要先讲一个知识点,叫做对象的半初始化。

源码:

 class App {
    int num = 8;
 }
 
 App app = new App();
复制代码

汇编码(java的字节码)

0 new #7 <App>  //申请内存,当这句话执行完成之后,堆里面有了一个新的内存,这里面存放的是你new出来了一个新对象
3 dup  //这是一个复制过程,因为invokespecial会消耗掉一个引用,所以必须复制一份
4 invokespecial #9 <App.<init>>  //调用构造方法初始化
7 astore_1  //把app变量与你new出来的新对象建立关联
8 return 
复制代码

这里面有最重要的三步new、invokespecial、astore_1,解释一下:

  • 当执行完第一条指令new的时候,申请内存,堆空间里内存就有了,但是这块内存有了,里面的num的值是多少呢?注意这里的num的值是0!这就是对象的半初始化。 当你刚刚new出来的一个对象的时候,会给里面的成员变量设为它的默认值,int类型的默认值就是0。所以当执行第一条汇编指令new的时候,num的值是0。
  • 接下来调用invokespecial指令来调用它的构造方法,构造方法执行完之后,才会将num设为初始值8。
  • 最后这个astore_1指令会将app这个变量与咱们真真正正的new出来的对象建立关联。

下面我们来讲DCL单例,讲的时候会我会按照饿汉式->懒汉式->DCL单例这个演变过程来说。

饿汉式单例

public class SingleInstance {

    private static final SingleInstance INSTANCE = new SingleInstance();

    private SingleInstance() {
    }

    public static SingleInstance getInstance() {
        return INSTANCE;
    }
}
复制代码

这种单例模式有个小小的问题,INSTANCE变量所指向的对象在我们还没有用的时候就创建了出来,假设它new的过程中非常浪费时间资源和空间资源,能不能等我们想用到它的时候再把它初始化出来。于是来到了咱们的懒汉式单例:

懒汉式单例

public class SingleInstance {

    private static SingleInstance instance;

    private SingleInstance() {
    }

    public static SingleInstance getInstance() {
        if (instance == null) {
            instance = new SingleInstance();
        }
        return instance;
    }
}
复制代码

这种单例模式是线程不安全的,我们可以用下面的代码非常容易的测试出来:

public class SingleInstance {

    private static SingleInstance instance;

    private SingleInstance() {
    }

    public static SingleInstance getInstance() {
        if (instance == null) {
        //为了更好的模拟多个线程都进入到了这里,我们让线程睡眠了一秒
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            instance = new SingleInstance();
        }
        return instance;
    }

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) { //我开启5个线程进行测试,大家可以试试开开启更多的线程模拟
            new Thread(() -> {
                SingleInstance instance = SingleInstance.getInstance();
                System.out.println(instance.hashCode());
            }).start();
        }
    }
}
复制代码

测试结果:

为了使得线程安全,我们可以给方法加锁:

public class SingleInstance {

    private static SingleInstance instance;

    private SingleInstance() {
    }

    //方法加锁
    public static synchronized SingleInstance getInstance() {
        if (instance == null) {
            instance = new SingleInstance();
        }
        return instance;
    }
}
复制代码

这种方式保证了永远只有一个线程在getInstance()方法里面执行,所以无论多少个线程去访问拿到的都会是同一个对象。可是给整个方法上锁,如果里面有很多业务方法,根本没有必要加锁的,锁的粒度太粗了。于是出现了下面的解决方案:

public class SingleInstance {

    private static SingleInstance instance;

    private SingleInstance() {
    }

    private static SingleInstance getInstance() {
        if (instance == null) {
            synchronized (SingleInstance.class) {
                instance = new SingleInstance();
            }
        }
        return instance;
    }
}
复制代码

大家想一下,这种方式是线程安全的吗?

很显然,不是的。我们依然可以使用前面的测试代码去测试出来。不过这里我来分析一下代码的执行流程,假设这时有两个线程A,B。如下:

根本原因:A线程上锁之前,另外的线程已经将这个对象给创建了出来(在这里是B线程)。

怎么来避免创建多次对象呢?

我想大家都已经想到了把,我们在锁里面再判断一次instance是否为null就可以了,于是诞生了DCL单例模式。

DCL单例模式

public class SingleInstance {

    private static volatile SingleInstance instance;

    private SingleInstance() {
    }

    private static SingleInstance getInstance() { //Double Check Lock 简称DCL
        //双重检查
        if (instance == null) { //检查第一遍
            synchronized (b.SingleInstance.class) {
                if (instance == null) { //检查第二遍
                    instance = new SingleInstance();
                }
            }
        }
        return instance;
    }
}
复制代码

在这里我们我们还注意到instance用了volatile关键字修饰。来说一下volatile关键字的作用:

  • 保持线程可见性
  • 禁止指令重排序

什么叫做指令的重排序?

程序是按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:

    a = 6;
    b = 7;
复制代码

编译器优化后可能会变成

    b = 7;
    a = 6;
复制代码

在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能会导致意想不到的bug(经典的案例就是DCL单例)。

为什么要指令重排序?

因为CPU的执行速度特别快,假设和内存的速度比是100:1,咱们给CPU两条指令,这两条指令没有太大关系(也就是第二条指令的执行,不依赖第一条指令的执行完毕)。如果第一条指令是去内存的某一个位置取一个数据,指令发出后,CPU在这里傻等着,需要等99个时间周期,才能拿到内存的数据,再去执行第二条指令...,这样是不是很浪费CPU资源呢?

编译器优化就是来解决这个问题的。第一条指令执行后,CPU不会一直等着从内存返回来的数据,它会把和第一条指令没有太大关系第二条指令拿过来先执行(假设第二条指令不需要读内存,就是一个简单的赋值操作,或者对寄存器的一个简单的加减操作)。

那么咱们再来看一下最开始创建一个对象所需要的三个重要的指令:

0 new #7 <App>  //申请内存,当这句话执行完成之后,堆里面有了一个新的内存,这里面存放的是你new出来了一个新对象
4 invokespecial #9 <App.<init>>  //调用构造方法初始化
7 astore_1  //把instance变量与你new出来的新对象建立关联
复制代码

当指令重排序之后,会发生这样的结果

0 new #7 <App>  //申请内存,当这句话执行完成之后,堆里面有了一个新的内存,这里面存放的是你new出来了一个新对象
7 astore_1  //把instance变量与你new出来的新对象建立关联
4 invokespecial #9 <App.<init>>  //调用构造方法初始化
复制代码

再来看DCL单例的getInstance()方法,如果我们不加volatile关键字进行修饰:

本来new的操作应该是:

  1. 分配一块内存M
  2. 在内存M上初始化SingleInstance对象
  3. 然后M的地址赋值给instance变量

但是实际上优化后的执行路径却是这样的:

  1. 分配一块内存M
  2. 将M的地址赋值给instance变量
  3. 最后在内存M上初始化SingleInstance对象

优化后会导致什么问题呢?我们假设A线程先执行getInstance()方法,当执行完指令2的时候恰好发生了线程切换,切换到了B线程上;如果此时线程B也执行了getInstance()方法,那么线程B在执行第一个判断时会发现instance!=null,所以直接返回instance,而此时的instance是没有执行构造方法进行初始化的,里面的成员变量都是默认值(半初始化状态),如果我们这个时候访问instance的成员变量(引用类型的变量)就可能触发空指针异常。

所以我们必须用volatile关键字来禁用指令的重排序。它会把内存M的这块内存区域的读和写都会加上内存屏障,机制很复杂,在这里就不做说明了。

分类:
后端
标签:
分类:
后端
标签: