并发情况下,单例模式之双重检验锁陷阱

时间:2022-07-27
本文章向大家介绍并发情况下,单例模式之双重检验锁陷阱,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

在我前面有写过一篇关于单例模式的几种创建的文章,最近在看多线程的时候,发现如果使用双重检验锁则可能会发生问题,接下来看我细细道来

单例模式的几种创建方式文章地址:https://www.jianshu.com/p/8ec72e016275

首先看一段代码

public class SingletonV4 {

    private static SingletonV4 singletonV4;

    private SingletonV4() {
        System.out.println("--初始化--");
    }

    /**
     * 双重检验锁
     * 能够保证线程安全,且效率高
     * @return
     */
    public static SingletonV4 getInstance() {
        if (null == singletonV4) {
            synchronized (SingletonV4.class) {
                if (null == singletonV4) {
                    singletonV4 = new SingletonV4();
                }
            }
        }
        return singletonV4;
    }

    public static void main(String[] args) {
        SingletonV4 instance1 = SingletonV4.getInstance();
        SingletonV4 instance2 = SingletonV4.getInstance();
        System.out.println(instance1);
        System.out.println(instance2);
    }
}


--初始化--
com.dream.sunny.SingletonV4@1716361
com.dream.sunny.SingletonV4@1716361

如上是一段单例模式中的懒汉模式双重检验锁,可能会有所疑惑,为什么需要两次if判断才进行初始化对象 第一次if判断主要是为了减少性能开销,之所以这么说,如果不加第一个if判断,每次进入getInstance()方法,synchronized关键字会将整个代码进行锁住,加锁操作,在进行判断是否已经初始化,在进行释放锁,加锁和释放锁是有较大的性能开销,所以在最外层包裹一层if判断实例是否被初始化,这样就不会每次加锁和释放锁了

既然synchronized锁增加了性能开销,为什么要加锁呢 当然在单线程情况下,是没有必要加锁,而多线程情况下,多个线程同时进行初始化对象操作,这样就会有线程安全性问题,为了防止这种情况,我们需要使用synchronized,这样该方式在多线程情况下就是线程安全的

第二次if判断目的在于有可能其他线程获取过锁,已经初始化改变量。第二次检查还未通过,才会真正初始化变量。 这个方法检查判定两次,并使用锁,所以形象称为双重检查锁定模式。 这个方案缩小锁的范围,减少锁的开销,看起来很完美。然而这个方案有一些问题却很容易被忽略。

问题点: 这个被忽略的问题在于 singletonV4 = new SingletonV4();在java中创建一个对象并非是一个原子操作,可以查看如下字节码代码

#创建一个新对象(创建 SingletonV4 对象实例,分配内存)
19: new           #6                  // class com/dream/sunny/SingletonV4
#复制栈顶部一个字长内容(复制栈顶地址,并再将其压入栈顶)
22: dup
#根据编译时类型来调用实例方法(调用构造器方法,初始化 SingletonV4 对象)
23: invokespecial #7                  // Method "<init>":()V
#设置类中静态字段的值
26: putstatic     #5                  // Field singletonV4:Lcom/dream/sunny/SingletonV4;
#从局部变量0中装载引用类型值(存入局部方法变量表)
29: aload_0

从字节码中可以看到创建一个对象实例,大致可以分为以下几步:

1.创建对象并分配内存地址
2.调用构造器方法,执行初始化对象
3.将对象的引用地址赋值给变量

在多线程情况下,上面三个步骤可能会发生指令重排(在一些JIT编译器中),编译器或处理器会为了提高代码性能效率,而改变代码的执行顺序。 上面三个步骤2和3之间可能会发生重排,但是1不会,因为2和3是要依托1指令的执行结果,才能继续往下走:

1.创建对象并分配内存地址
2.将对象的引用地址赋值给变量
3.调用构造器方法,执行初始化对象

Java 语言规规定了线程执行程序时需要遵守 intra-thread semantics。 不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。 这个重排序在没有改变单线程程序的执行结果的前提下,可以提高程序的执行性能。 虽然重排序并不影响单线程内的执行结果,但是在多线程的环境就带来一些问题。

模拟两个线程创建单例的场景,如下:

时间

线程A

线程B

t1

创建对象

~

t2

分配内存地址

~

t3

~

判断对象是否为空

t4

~

对象不为空,访问该对象

t5

初始化对象

~

t6

访问该对象

~

如果线程A获取到锁,进入到创建对象实例,这个时候发生了指令重排,线程A执行到t3时刻,此时线程B抢占了CPU执行时间片,但是由于此时对象不为空,则直接返回对象出去,然而使用该对象却发现该对象未被初始化就会报错,并且从始至终,线程B无需获取锁

指令重排 前面已经分析到,出现错误的原因在于“指令重排”,那什么是指令重排呢?它什么在并发情况下指令重排会直接影响到程序的执行结果呢?首先我们看一下“顺序一致性内存模型”概念。

顺序一致性理论内存模型 顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

  • 一个线程中的所有操作必须按照程序的顺序来执行。
  • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

实际JMM模型概念 但是,顺序一致性模型只是一个理想化了的模型,在实际的JMM实现中,为了尽量提高程序运行效率,和理想的顺序一致性内存模型有以下差异: 在顺序一致性模型中,所有操作完全按程序的顺序串行执行。在JMM中不保证单线程操作会按程序顺序执行(即指令重排序)。 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。 顺序一致性模型保证对所有的内存写操作都具有原子性,而JMM不保证对64位的long型和double型变量的读/写操作具有原子性(分为2个32位写操作进行,本文无关不细阐述)

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

int a = 1;
int b = 10;
int c = a * b

这段代码C依赖于A,B,但A,B没有依赖关系,所以代码可能有2种执行顺序:

  • A->B->C
  • 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 之后才被修复

对之前代码加入volatile关键字,即可实现线程安全的单例模式。

public class SingletonV4 {

    private static volatile SingletonV4 singletonV4;

    private SingletonV4() {
        System.out.println("--初始化--");
    }

    /**
     * 双重检验锁
     * 能够保证线程安全,且效率高
     * @return
     */
    public static SingletonV4 getInstance() {
        if (null == singletonV4) {
            synchronized (SingletonV4.class) {
                if (null == singletonV4) {
                    singletonV4 = new SingletonV4();
                }
            }
        }
        return singletonV4;
    }

    public static void main(String[] args) {
        SingletonV4 instance1 = SingletonV4.getInstance();
        SingletonV4 instance2 = SingletonV4.getInstance();
        System.out.println(instance1);
        System.out.println(instance2);
    }
}


--初始化--
com.dream.sunny.SingletonV4@1716361
com.dream.sunny.SingletonV4@1716361

总结 对象的创建可能发生指令的重排序,使用 volatile 可以禁止指令的重排序,保证多线程环境内的系统安全。

参考博客:https://www.cnblogs.com/lkxsnow/p/12293791.html