前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >JUC并发编程之单例模式双重检验锁陷阱

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

作者头像
黎明大大
发布2021-04-16 16:27:19
4450
发布2021-04-16 16:27:19
举报
文章被收录于专栏:java相关资料java相关资料
1

前言

我在上一篇文章聊volatile的时候,埋下了一个问题,在并发情况下单例模式双重检验锁可能会存在的问题,那么本文就来详细分析分析它。

2

浅谈单例模式双重检验锁陷阱

首先看一段代码

代码语言:javascript
复制
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();" ,它在底层进行指令操作并非是原子性操作,我上图标记的部分,就是该对象创建过程的指令编码,下面就来对该四行指令进行分析它们的意思

代码语言:javascript
复制
#创建一个新对象(创建 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关键字详解" 这篇文章

什么是指令重排序

指令重排序是指编译器或处理器为了优化性能而采取的一种手段,在不存在数据依赖性情况下(如写后读,读后写,写后写),调整代码执行顺序。举个例子:

代码语言:javascript
复制
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 之后才被修复

最终优化后的代码如下

代码语言:javascript
复制
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

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

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

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-04-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 黎明大大 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档