双重检查锁定案例

wen java案例 2

双重检查锁定(DCL)实战案例剖析:从失效陷阱到内存模型终极解法

目录导读

  1. 什么是双重检查锁定?一个经典的单例模式案例
  2. 看似完美的代码,为何在并发下“翻车”?——DCL失效的根源
  3. Java内存模型(JMM)与指令重排序:看不见的“隐形杀手”
  4. 终极解决方案:volatile、静态内部类与枚举的博弈
  5. 实战问答:面试官最爱的5个DCL连环追问
  6. 何时该用DCL?何时该放弃?

什么是双重检查锁定?一个经典的单例模式案例

双重检查锁定(Double-Checked Locking,简称DCL)是并发编程中用于延迟初始化单例对象的一种优化策略,核心思想是:先不加锁检查实例是否已创建(第一次检查),若未创建则加锁,在锁内再次检查(第二次检查),从而避免每次获取对象都进行同步。

双重检查锁定案例

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

这个案例在JDK 1.5之前被认为是有缺陷的,但在理解内存模型后,我们可以修复它。

看似完美的代码,为何在并发下“翻车”?——DCL失效的根源

问:为什么上述代码在JDK 1.5之前会失败?

答: 问题出在 instance = new Singleton() 这行代码并非原子操作,它实际可以分为三步:

  1. 分配内存空间
  2. 在内存中初始化Singleton对象(字段赋值)
  3. instance 引用指向该内存地址

但在JIT编译器或CPU指令重排序的影响下,步骤2和3可能交换顺序,线程A执行完步骤1和3(引用已赋值但对象未完全初始化),线程B进入第一次检查,发现 instance != null,直接返回了一个未初始化完成的对象,导致程序行为异常。

关键词深入: 这个现象的本质是无同步情况下的数据竞争,因为第一次检查没有加锁,线程B无法保证看到线程A的完整写入。

Java内存模型(JMM)与指令重排序:看不见的“隐形杀手”

Java内存模型规定了主内存与工作内存的交互规则,在DCL场景中,核心矛盾在于:

  • 可见性:一个线程对共享变量的修改,何时对另一个线程可见?
  • 有序性:编译器/CPU重排序后,程序执行顺序是否与源码一致?

修复案例(volatile版本):

public class Singleton {
    // volatile的关键作用:禁止步骤2和3的重排序,并保证可见性
    private static volatile Singleton instance;
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

为什么volatile有效? 自JDK 1.5起,volatile变量的写操作会插入内存屏障,在写之前禁止前面的重排序,在读之后禁止后续的重排序,这确保了:当线程B读到 instance 非null时,必然能看到其内部字段已初始化完毕。

终极解决方案:volatile、静态内部类与枚举的博弈

除了volatile DCL,还有更优雅的替代方案:

静态内部类(推荐)

public class Singleton {
    private Singleton() {}
    private static class Holder {
        static final Singleton INSTANCE = new Singleton();
    }
    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

利用JVM的类加载机制保证线程安全,既延迟加载又无需同步,是性能最优解

枚举(最佳实践)

public enum Singleton {
    INSTANCE;
    // 业务方法...
}

枚举天然防止反射攻击和序列化破坏,是Effective Java作者Joshua Bloch极力推荐的方式。

原子引用 (AtomicReference) 适用于需要原子更新单例对象(如可替换的单例)的场景,但相对少用。

实战问答:面试官最爱的5个DCL连环追问

Q1:volatile修饰的变量保证线程安全吗? A:不完全,volatile仅保证可见性和有序性,不保证原子性(如 count++ 操作),DCL中它只用于安全发布单例对象。

Q2:为什么static内部类能实现延迟加载且线程安全? A:Holder 类只有在 getInstance() 第一次被调用时才会被JVM加载,且类加载是线程安全的,加载完成后,INSTANCE 的初始化就是安全的。

Q3:如果不用volatile,还有别的办法修复DCL吗? A:可以用局部变量暂存引用,但本质问题不变;或者直接用内部类/枚举方案。

Q4:DCL在移动端(Android)上还有用吗? A:Android的Dalvik/ART也遵守JMM,但现代Android开发中更推荐 Lazy<T>(Kotlin)或单例框架,DCL仍可用于理解底层原理。

Q5:如果单例需要参数(如Context)怎么办? A:DCL方案不支持带参数构造,可以改用“初始化时传递参数”或使用依赖注入容器。

何时该用DCL?何时该放弃?

适合用DCL的场景:

  • 需要严格的延迟加载(如对象创建开销大)
  • 需要控制实例的创建时序
  • 熟悉内存模型且愿意承担复杂度

应该放弃DCL的场景:

  • 99%的普通单例——直接用枚举或静态持有者
  • 需要可序列化或反射安全时——枚举秒杀所有方案
  • 在Bean管理容器(如Spring)中——交给容器管理生命周期

最后的建议: 编写代码时,优先选择最简单且线程安全的方案,双重检查锁定是并发理论的“高级练习”,但在实际工程中,静态内部类和枚举已经能覆盖95%的需求,理解DCL的底层原理,能帮你更好地理解Java内存模型,但不要让复杂的技术成为日常开发的负担。

延伸思考: 你知道在JDK 9之后,SafePointJIT 优化对DCL还有哪些新的影响吗?欢迎在评论区探讨。

抱歉,评论功能暂时关闭!