spice and wolfspice and wolf Be the One you wanna Be

单例模式双重检查锁详解

单例模式的懒汉式的最大问题就是线程安全性保障,而为了实现线程安全性,懒汉式的最经典实现就是双重检查锁。这里用图表的形式分析一下为啥是双重的,以及为何单例静态变量需要加上volatile修饰。

为什么是”双重”检查锁?

下面代码只有单重外层检查:

public class SingleCheckLockSingleton {
    private static volatile SingleCheckLockSingleton instance;

    private SingleCheckLockSingleton() {}

    public static SingleCheckLockSingleton getInstance() {
        if (instance == null) {
            // 模拟多个线程同时竞争锁的情况
            try {
                TimeUnit.MILLISECONDS.sleep(1);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            synchronized (SingleCheckLockSingleton.class) {
                instance = new SingleCheckLockSingleton();
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        for (int i = 1; i <= 5; i++) {
            new Thread(() -> {
                SingleCheckLockSingleton ins = getInstance();
                System.out.println(Thread.currentThread().getName() + "\t:\t" + ins);
            }, "Thread" + String.valueOf(i)).start();
        }
    }
}

为了模拟多个线程同时进入instance == null语句时的场景,我们在语句内做了短暂的睡眠操作。可以预料到当前会有多个线程进入条件判断语句中,会创建多个实例,并且每次取出来的实例都不一定是同一个,这样肯定是不行的,所以为了避免多个线程进入条件判断语句后会创建多个实例,这就是二重检查锁之所以是二重的原因。

public class DoubleCheckLockSingleton {
    private static volatile DoubleCheckLockSingleton instance;

    private DoubleCheckLockSingleton() {}

    public static DoubleCheckLockSingleton getInstance() {
        // 一重检查
        if (instance == null) {
            synchronized (DoubleCheckLockSingleton.class) {
                // 二重检查
                if (instance == null) {
                    instance = new DoubleCheckLockSingleton();
                }
            }
        }
        return instance;
    }
}

加入了内层判断后,即使多个线程都进入到了synchronized语句块中,仍然只会有最初进入的线程会进入内层循环并执行创建实例的代码。

为何要加volatile修饰?

我们再提出一个假设,如果不加volatile关键字会发生什么?即探究volatile关键字在这起到了什么作用。

// 双重检查锁线程同步方式
public class DoubleCheckLockSingleton {
    private static DoubleCheckLockSingleton instance;

    private DoubleCheckLockSingleton() {}

    public static DoubleCheckLockSingleton getInstance() {
        if (instance == null) {
            synchronized (DoubleCheckLockSingleton.class) {
                if (instance == null) {
                    instance = new DoubleCheckLockSingleton();
                }
            }
        }
        return instance;
    }
}

getInstance方法对应的字节码:

 0 getstatic #2 <singletonPattern/lazyMode/DoubleCheckLockSingleton.instance : LsingletonPattern/lazyMode/DoubleCheckLockSingleton;>
 3 ifnonnull 37 (+34)
 6 ldc #3 <singletonPattern/lazyMode/DoubleCheckLockSingleton>
 8 dup
 9 astore_0
10 monitorenter
11 getstatic #2 <singletonPattern/lazyMode/DoubleCheckLockSingleton.instance : LsingletonPattern/lazyMode/DoubleCheckLockSingleton;>
14 ifnonnull 27 (+13)
17 new #3 <singletonPattern/lazyMode/DoubleCheckLockSingleton>
20 dup
21 invokespecial #4 <singletonPattern/lazyMode/DoubleCheckLockSingleton.<init> : ()V>
24 putstatic #2 <singletonPattern/lazyMode/DoubleCheckLockSingleton.instance : LsingletonPattern/lazyMode/DoubleCheckLockSingleton;>
27 aload_0
28 monitorexit
29 goto 37 (+8)
32 astore_1
33 aload_0
34 monitorexit
35 aload_1
36 athrow
37 getstatic #2 <singletonPattern/lazyMode/DoubleCheckLockSingleton.instance : LsingletonPattern/lazyMode/DoubleCheckLockSingleton;>
40 areturn

不加volatile时的问题实在很难成功模拟出来,所以我们通过时序图进行讲解,首先我们需要知道new操作并不是原子操作,其内部包含了复数条字节码指令(17、20、21、24)。

  • new,在内存中为新创建的对象开辟空间,如果首次加载类则进行类加载。
  • dup,赋值操作数栈栈顶数据并压入栈顶。
  • invokespecial,调用构造方法进行类初始化。
  • putstatic,将创建的对象地址赋值给静态变量instance。

而编译器和处理器会出于优化考虑将指令重排,这就会造成这几天指令的执行顺序改变的情况,先讨论putstatic指令在invokespecial之前的情况,即在对象进行初始化前就已经将对象地址赋值给instance变量:

在Thread1还未初始化完对象时,Thread2就可能拿到单例对象并返回使用,这时对象分配的内存空间中可能是没意义的数据,这时对象访问可能发生空指针异常等问题。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

Press ESC to close