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就可能拿到单例对象并返回使用,这时对象分配的内存空间中可能是没意义的数据,这时对象访问可能发生空指针异常等问题。

其次volatile关键字在实例初始化后会让共享变量对其他线程立即可见,防止过多线程进入外层循环导致过多线程在同步代码处排队的情况,有效提高并发性能。

总结

  • 为何是“双重”检查锁。
    • 单外层检查锁在高并发环境下会让对个线程进入同步代码块,保证不了单例性质。
    • 单内层检查锁所有线程都需要进行无意义的排队等待,不适用于并发环境。
  • volatile关键字的作用。
    • 在并发环境下,防止实例对象创建过程中指令重排导致的空指针和异常值问题。
    • 让单例创建后对其他线程立即可见从而减少过多线程竞争同步代码块造成的并发性能问题。

发表回复

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

Press ESC to close