对象的半初始化
在说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 (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的操作应该是:
- 分配一块内存M
- 在内存M上初始化SingleInstance对象
- 然后M的地址赋值给instance变量
但是实际上优化后的执行路径却是这样的:
- 分配一块内存M
- 将M的地址赋值给instance变量
- 最后在内存M上初始化SingleInstance对象
优化后会导致什么问题呢?我们假设A线程先执行getInstance()方法,当执行完指令2的时候恰好发生了线程切换,切换到了B线程上;如果此时线程B也执行了getInstance()方法,那么线程B在执行第一个判断时会发现instance!=null,所以直接返回instance,而此时的instance是没有执行构造方法进行初始化的,里面的成员变量都是默认值(半初始化状态),如果我们这个时候访问instance的成员变量(引用类型的变量)就可能触发空指针异常。
所以我们必须用volatile关键字来禁用指令的重排序。它会把内存M的这块内存区域的读和写都会加上内存屏障,机制很复杂,在这里就不做说明了。