终于搞懂双重检验锁实现单例模式了

Talk is cheap. Show me the code.

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

这是业届奉为经典的 “双重检验锁-懒汉单例”,相信熟悉 Java 语法的人都能看懂,通过这段代码,我们实现了一个单例类,单例类的定义如下,

  1. 单例类只能有一个实例。
  2. 单例类必须自己创建自己的唯一实例。
  3. 单例类必须给所有其他对象提供这一实例。

通过代码可以看出,由于构造函数私有(private),我们获取 Singleton 类的实例对象只能通过 Singleton.getInstance() 的方式,而无法在外界通过 new 或其他方式创建此类的实例对象,并且由于此成员变量被 static 修饰,使得实例对象属于类本身且只有唯一一个。

也许你已经看懂了这段代码的整体结构,但这段经典代码中仍有几点细节值得思考,比如 volatile,synchronized 和两次出现的 if (instance == null)。
我想,这几个关键字并不陌生。
是的没错,这正是因为我们要保证在并发情况下的安全性问题。

先讲 synchroized ,如果没有 synchronized,

public static Singleton getInstance() {
    if (instance == null) {
        instance = new Singleton();
    }
    return instance;
}

在多线程情况下,对于 instance == null 的判断会出现同时多个线程识别到 instance 为 null,同时进行多个实例的创建,即使最终只会有一个实例被引用,可这与我们单例模式设计的初衷并不符合,并且造成大量的内存空间浪费,显然很不合理。
那么如果对方法加上 synchronized 的呢?

public class Singleton {
    private volatile static Singleton instance= null;
    
    private Singleton() {};
    
    public synchronized static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

这样保证只有一个线程进入 getInstance() 方法调用,执行判断与返回,是可以成功解决并发的安全性问题的!但是缺点在于锁的粒度太大了,多个线程同时调用 getInstance() 方法时,除一个线程之外,剩下所有线程都会被阻塞。我只是想读取一下呀,看看也不行么?我们更希望如果 instance 对象存在的话,每个线程都可以直接返回实例对象,所以让我们缩小同步代码的范围。

public class Singleton {
    private volatile static Singleton singelton = null;
    
    private Singleton() {};
    
    public static Singleton getSingleton() {
        if (singleton == null) {
            synchronized(Singleton.class) {
                singleton = new Singleton();
            }
        }
        return singelton;
    }
}

哈哈哈,是不是合理了很多,线程调用 getInstance() 方法时,只有在发现 instance 为 null 的情况下才会获取锁对象,阻塞其他进程,进行对象的实例化操作。如果 instance 不是 null 的话就直接返回啦。
看到这里,有没有忽然间想起点什么,我们代码的名字,双重检验呀有木有!

public static Singleton getInstance() {
    // 第一次检验
    if (instance == null) {
        synchronized(Singleton.class) {
            // 第二次检验
            if (instance == null) {
                instance = new Singleton();
            }
        }
    }
    return instance;
}

第一次检验是在线程执行 getInstance() 方法时,如果不为 null 就直接返回。那么第二次检验如代码所示,位于 synchronized 修饰的代码块中。
这是由于假设此刻 instance 为 null,如果A,B两个线程同时判断 instance == null 成立,那么两个线程都会进行锁资源的争夺,如果 A 获取到锁资源,则 B 进行阻塞,待 A 完成实例化操作释放掉锁资源后,B 被唤醒,而此刻必须重新判断 instance 的状态,否则 B 会依旧认为 instance 为 null,进行实例化操作,创建新的对象,那么便违背了单例模式只有一个实例对象的原则。

到此为止,我们已经搞懂了 synchronized 和 双重检验,只剩下一个小小的疑问,为什么要加 volatile 呢?

private volatile static Singleton singelton = null;

这就不得不提到有关 java 源码编译后指令执行顺序的两个知识点:

  • instance = new Singleton() 在编译后会被分解为 3 个指令。
  • volatile 的功能之一:禁止指令重排序

先说 instance = new Singleton() 会被分解为三个步骤,

  1. memory=allocate(); // 分配内存 相当于c的malloc
  2. ctorInstanc(memory) //初始化对象
  3. instance=memory //设置instance指向刚分配的地址

而 JVM (Java 虚拟机) 可能会对这三个指令进行重排序,将指令顺序重排为 1→3→2。
那么可能出现这样一种情况,A线程正在执行 instance = new Singleton() 中的 3 指令,即分配完内存空间,并将 instance 指向此内存空间,如果此时恰巧有一个 B 线程执行 getInstance() 方法,会判断 instance 不是 null,将 instance 返回,那么就会返回一个未初始化的对象,造成程序错误。
而用 volatile 就可以完美的解决这个问题,因为被 volatile 修饰的 instance 属性,会在操作其前后设置内存屏障(详见volatile原理),达到禁止其相关指令重排序的功能,使得 instance 一定会被初始化,避免了上述问题。

好啦~,以上就是本文的全部内容了,希望读完的你能够有所收获呀!

1)记住 “双重检验锁-懒汉单例” 的写法。
2)明白为什么 synchronized 在方法内使用。
3)理解双重检验中每次检验的意义。
4)搞懂 volatile 的使用原因。

参考资料:
双重检验的单例模式,为什么要用volatile关键字
双重检验锁思考
《Java并发编程之美》

你可能感兴趣的:(终于搞懂双重检验锁实现单例模式了)