双重检查锁单例为什么必须加 volatile?

#Java#面试八股

双重检查锁单例为什么必须加 volatile?

结论先行:因为 instance = new Singleton() 不是一步完成的,它包含"分配内存 → 初始化对象 → 把引用赋给 instance"三步,后两步可能被重排序。若没有 volatile,别的线程可能看到一个引用非空、但字段还没初始化的对象,直接拿去用就会踩到"半初始化"状态。

1. 指令重排的隐患

理想顺序:                          可能的重排:
① 分配内存                            ① 分配内存
② 执行构造器(字段赋值)                ③ instance = 引用(先挂上去)
③ instance = 引用                     ② 执行构造器(后执行)

重排后,另一个线程在 if (instance == null) 判断时看到引用已非空,直接返回并访问字段——此时构造器还没跑完,字段全是默认值(0、null)。

线程 A                          线程 B
────────────────────────────    ────────────────────────────
① 分配内存
③ instance = 引用(重排提前)
                                 if (instance == null) → false
                                 拿到 instance,访问 field → 0 / null ❌
② 执行构造器,field = 100

2. 正确写法

public class Singleton {
    // volatile 禁止 ② 与 ③ 重排,并保证写对读可见
    private static volatile Singleton instance;

    private Singleton() { }

    public static Singleton getInstance() {
        if (instance == null) {                 // 第一次检查:避免每次都加锁
            synchronized (Singleton.class) {    // 锁住类对象
                if (instance == null) {         // 第二次检查:防止重复创建
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

两次检查各有职责:外层是为了"已初始化后不再加锁"(性能),内层是为了"多个线程同时通过外层检查时只创建一个"(正确性)。synchronized 负责互斥创建,volatile 负责安全发布,两者缺一不可。

3. 五种单例写法对比

写法线程安全懒加载防止反射破坏防止反序列化破坏评价
饿汉式是(类加载保证)否否否简单,但不能延迟加载
懒汉式(方法加锁)是是否否每次调用都加锁,慢
双重检查锁是(需 volatile)是否否兼顾性能与懒加载
静态内部类是(类加载保证)是否否最推荐,实现最简洁
枚举是否是是《Effective Java》推荐
// 静态内部类:利用"类加载时初始化 + 只加载一次"的语义
public class HolderSingleton {
    private HolderSingleton() { }
    private static class Holder {
        private static final HolderSingleton INSTANCE = new HolderSingleton();
    }
    public static HolderSingleton getInstance() { return Holder.INSTANCE; }
}

// 枚举:JVM 保证实例唯一,且反射无法创建枚举实例
public enum EnumSingleton {
    INSTANCE;
    public void doSomething() { }
}

4. 常见追问

追问一:为什么静态内部类不需要 volatile? 因为它把"延迟加载"交给了 JVM:Holder 类只有在第一次访问 Holder.INSTANCE 时才被加载,而类加载由 JVM 保证只执行一次且对所有线程可见。没有手写检查,也就没有重排窗口。

追问二:DCL 里的 synchronized 能省吗? 不能。volatile 只保证可见性和有序性,不保证"判断 + 创建"的原子性。没有锁,两个线程可能同时判定为 null,各自创建一个实例。

追问三:JDK 5 之前为什么 DCL 是错的? JDK 5 之前 volatile 的语义不完整(没有禁止重排的保证),JMM 也未修正,所以 DCL 在那个年代确实有 bug。JSR-133 修订后(JDK 5 起)才成立。

追问四:枚举单例真的无懈可击吗? 几乎。反射 newInstance 对枚举会抛 IllegalArgumentException,反序列化也由 JVM 保证返回同一实例。唯一缺点是不能懒加载,且继承场景受限(枚举不能继承其他类)。

追问五:Spring 的单例 Bean 为什么不需要 DCL? Spring 的单例在容器启动时由 DefaultSingletonBeanRegistry 配合 synchronized 创建(并且用 singletonObjects 缓存),创建发生在容器刷新阶段,通常是单线程串行的,不走懒加载的 DCL 路径。

小结

DCL 的关键不是"双重检查"这个技巧,而是"检查与创建之间必须有一次安全发布"。volatile 提供这次发布;如果不愿背这个细节,就用静态内部类或枚举——它们把同步问题交给了类加载机制。

笔记加载中…