单例双check两个非空判断的作用以及时机

66 阅读2分钟

两个 null 判断各做什么、发生在什么时候、去掉其中一个会怎样

public final class Singleton {
  private static volatile Singleton INSTANCE;

  public static Singleton getInstance() {
    if (INSTANCE != null) return INSTANCE;          // ① 第一次检查(无锁,快路径)
    synchronized (Singleton.class) {
      if (INSTANCE == null) {                       // ② 第二次检查(有锁,竞态兜底)
        INSTANCE = new Singleton();
      }
      return INSTANCE;
    }
  }
}

① 外层 null 检查(第一次)

  • 作用:在已完成初始化后,绝大多数调用可以直接返回避免进入同步块,降低开销。

  • 时机:每次 getInstance() 被调用时最先执行;初始化之后几乎所有请求都只走到这里就结束。

  • 前提:INSTANCE 必须是 volatile,保证对其他线程立即可见且避免指令重排(否则可能看到“半初始化对象”)。

② 内层 null 检查(第二次)

  • 作用并发初始化的竞态兜底。当多个线程几乎同时通过了外层检查并竞争到锁时,只允许第一个线程真的创建实例;后续线程再次检查到 INSTANCE 已被设置,就不会再创建。
  • 时机:只有在外层检查为 null 且成功拿到锁时才会执行。

去掉其中一个会怎样?

去掉外层检查(保留内层)

synchronized (Singleton.class) {
  if (INSTANCE == null) INSTANCE = new Singleton();
}
return INSTANCE;
  • 正确性:✅ 仍然线程安全,因为创建在同步块内且有二次检查。

  • 性能:❌ 每次调用都加锁,哪怕已经初始化,多线程下开销明显。适合极少被调用的场景;一般不推荐。

去掉内层检查(只留外层)

if (INSTANCE == null) {
  synchronized (Singleton.class) {
    INSTANCE = new Singleton(); // ❌
  }
}
  • 正确性:❌ 不安全。两个线程可能都通过了外层检查:

    T1 进锁创建完 → 释放锁;T2 随后获得锁再次创建,造成重复构造/覆盖,还可能泄漏第一次创建的对象。

  • 总结内层检查不能去

额外提醒:不加 volatile 即使保留两次检查也不一定安全(可能读到“半初始化对象”)。DCL 想正确,volatile 必须加。


可替代且更简单的安全写法

  • 静态内部类(按需加载)
class Singleton {
  private Singleton() {}
  private static class Holder { static final Singleton I = new Singleton(); }
  static Singleton getInstance() { return Holder.I; }
}
  • 依赖类初始化的线程安全与延迟加载,代码更简洁,性能也很好。
  • 枚举单例(若只需一个实例且可序列化):
enum Singleton { INSTANCE; }

一句话结论

  • 外层检查快路径,避免无谓加锁;内层检查竞态兜底,保证只创建一次;volatile 必不可少
  • 去掉外层:功能对、性能差;去掉内层功能错(会重复创建)。
  • 若不追求 DCL,静态内部类是最稳妥、最简洁的工程选择。