JUC并发编程之单例模式双重检验锁陷阱

黎明大大

共 5166字,需浏览 11分钟

 ·

2021-04-17 23:58

点击上方蓝字 关注我吧



1
前言

我在上一篇文章聊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@3f99bd52com.dream.sunny.Test04@3f99bd52true


如上是一段单例模式中的懒汉模式双重检验锁,我来解释一下为什么需要进行两次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修饰的变量时,JMM会把本地内存中值刷新到主内存

  • 读volatile修饰的变量时,JMM会设置本地内存无效

重点:为了实现可见性内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来防止重排序!


volatile是Java虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用

  • 保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被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@3f99bd52com.dream.sunny.Test04@3f99bd52true


我是黎明大大,我知道我没有惊世的才华,也没有超于凡人的能力,但毕竟我还有一个不屈服,敢于选择向命运冲锋的灵魂,和一个就是伤痕累累也要义无反顾走下去的心。


如果您觉得本文对您有帮助,还请关注点赞一波,后期将不间断更新更多技术文章


扫描二维码关注我
不定期更新技术文章哦



JUC并发编程之Volatile关键字详解

JUC并发编程之JMM内存模型详解

深入Hotspot源码与Linux内核理解NIO与Epoll

基于Python爬虫爬取有道翻译实现翻译功能

JAVA集合之ArrayList源码分析

Mysql几种join连接算法



发现“在看”和“赞”了吗,因为你的点赞,让我元气满满哦
浏览 36
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报