前言
我在上一篇文章聊volatile的时候,埋下了一个问题,在并发情况下单例模式双重检验锁可能会存在的问题,那么本文就来详细分析分析它。
2
浅谈单例模式双重检验锁陷阱
首先看一段代码
public class Test04 {
private static Test04 test04;
public static Test04 getInstance() {
if (null == test04) {
synchronized (Test04.class) {
if (null == test04) {
test04 = new Test04();
}
}
}
return test04;
}
public static void main(String[] args) {
Test04 instance1 = Test04.getInstance();
Test04 instance2 = Test04.getInstance();
System.out.println(instance1);
System.out.println(instance2);
if (instance1 == instance2) {
System.out.println("true");
} else {
System.out.println("false");
}
}
}
//-----输出结果
com.dream.sunny.Test04@3f99bd52
com.dream.sunny.Test04@3f99bd52
true
如上是一段单例模式中的懒汉模式双重检验锁,我来解释一下为什么需要进行两次if判断。最内部的if判断很好理解,因为这段代码是单例模式需要的是单例对象,所以需要在初始化对象前,当然要判断该对象是否已经被初始化过,如果没有初始化才进行初始化嘛。那么在它的外层加上synchronized关键字是因为什么呢?因为在并发情况下,多个线程可能同时进入的内部if判断进行初始化对象,产生线程安全问题,为止防止这一现象的发生,所以在外层加上同步块操作。那在synchronized外层在加上if判断又是因为什么呢?我认为加的原因,是因为如果不在最外层加if判断的前提下,当对象已经被初始化后,后续线程访问总会走同步块操作,然后再判断对象是否初始化完成对象,synchronized本身是一个重操作,在进行读取的时候完全没必要进行上锁,反而降低性能。所以在synchronized外层再加上if判断是非常有必要的,这样就能够防止线程每次都要进行上锁操作读取,性能大幅度的提升。
经过上面这段文字进行分析,这段代码似乎比较完美,程序应该是没有任何问题,恰恰在程序并发运行的过程中,种种可能都可能存在,该文就重点讲讲在并发情况下,它可能存在的潜在且致命的问题。
我这里先放上一张这段代码被编译后的字节码内容图片,方便后续的理解。
前面这段代码出现的问题在于 "test04 = new Test04();" ,它在底层进行指令操作并非是原子性操作,我上图标记的部分,就是该对象创建过程的指令编码,下面就来对该四行指令进行分析它们的意思
#创建一个新对象(创建 Test04 对象实例,分配内存)
19: new #3 // class com/dream/sunny/Test04
#复制栈顶部一个字长内容(复制栈顶地址,并再将其压入栈顶)[每个线程有属于自己的栈帧]
22: dup
#根据编译时类型来调用实例方法(调用构造器方法,初始化 Test04 对象)
23: invokespecial #4 // Method "<init>":()V
#将初始化后的对象赋值给静态变量
26: putstatic #2 // Field test04:Lcom/dream/sunny/Test04;
从字节码中可以看到创建一个对象实例,大致可以分为以下几步:
1.创建对象并分配内存地址
2.调用构造器方法,执行初始化对象
3.将对象的引用地址赋值给变量
在多线程情况下,上面三个步骤可能会发生指令重排(在一些JIT编译器中),编译器或处理器会为了提高代码性能效率,而改变代码的执行顺序。
上面三个步骤2和3之间可能会发生重排,但是1不会,因为2和3是要依托1指令的执行结果,才能继续往下走:
1.创建对象并分配内存地址
2.将对象的引用地址赋值给变量
3.调用构造器方法,执行初始化对象
当发生重排后,步骤2对象的引用地址赋值给了变量,然后步骤3在执行对象初始化,是不是显而易见的就看见到问题存在,步骤2的引用地址是为null的,因为对象还没有被执行完初始化,就先将对象的引用地址赋值给了变量。结果后续其他线程去读取该变量直接报错,然后又无法进行初始化,那不是就很尴尬的么。
模拟两个线程创建单例的场景,如下:
时间 | 线程A | 线程B |
---|---|---|
t1 | 创建对象 | |
t2 | 分配内存地址 | |
t3 | 判断对象是否为空 | |
t4 | 对象不为空,访问该对象 | |
t5 | 初始化对象 | |
t6 | 访问该对象 |
如果线程A获取到锁,进入到创建对象实例,这个时候发生了指令重排,线程A执行到t3时刻,此时线程B抢占了CPU执行时间片,但是由于此时对象不为空,则直接返回对象出去,然而使用该对象却发现该对象未被初始化就会报错,并且从始至终,线程B无需获取锁
针对以上情况,是否有解决方案,答案是有的,它问题出现在指令重排,我前面有文章专门提到过这个现象,为了读者方便,我这里简单说明一下指令重排是什么,具体可以查看 "JUC并发编程之Volatile关键字详解" 这篇文章
什么是指令重排序
指令重排序是指编译器或处理器为了优化性能而采取的一种手段,在不存在数据依赖性情况下(如写后读,读后写,写后写),调整代码执行顺序。举个例子:
int a = 1;
int b = 10;
int c = a * b
这段代码C依赖于A,B,但A,B没有依赖关系,所以代码可能有2种执行顺序:
1.A->B->C
2.B->A->C 但无论哪种最终结果都一致,这种满足单线程内无论如何重排序不改变最终结果的语义,被称作as-if-serial语义,遵守as-if-serial语义的编译器,runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。
双重检验锁问题解决方案
回头看下我们出问题的双重检查锁程序,它是满足as-if-serial语义的吗?是的,单线程下它没有任何问题,但是在多线程下,会因为重排序出现问题。
解决方案就是volatile关键字,对于volatile我们最深的印象是它保证了”可见性“,它的”可见性“是通过它的内存语义实现的:
重点:为了实现可见性内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来防止重排序!
volatile是Java虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用
由于 volatile 禁止对象创建时指令之间重排序,所以其他线程不会访问到一个未初始化的对象,从而保证安全性。
注意,volatile禁止指令重排序在 JDK 5 之后才被修复
最终优化后的代码如下
public class Test04 {
private volatile static Test04 test04;
public static Test04 getInstance() {
if (null == test04) {
synchronized (Test04.class) {
if (null == test04) {
test04 = new Test04();
}
}
}
return test04;
}
public static void main(String[] args) {
Test04 instance1 = Test04.getInstance();
Test04 instance2 = Test04.getInstance();
System.out.println(instance1);
System.out.println(instance2);
if (instance1 == instance2) {
System.out.println("true");
} else {
System.out.println("false");
}
}
}
//-----输出结果
com.dream.sunny.Test04@3f99bd52
com.dream.sunny.Test04@3f99bd52
true
我是黎明大大,我知道我没有惊世的才华,也没有超于凡人的能力,但毕竟我还有一个不屈服,敢于选择向命运冲锋的灵魂,和一个就是伤痕累累也要义无反顾走下去的心。
如果您觉得本文对您有帮助,还请关注点赞一波,后期将不间断更新更多技术文章