在多线程环境下实现单例模式时,双重检查加锁(DCL) 是一种经典写法。它兼顾了线程安全性能优化,但常常让人困惑:“为什么要这样写?”、“锁的对象应该是什么?”、“为什么不锁 this 或 instance?” 本文将系统梳理这些问题,并重点分析锁对象的本质。


1. 单例与线程安全问题

最朴素的单例写法:

public class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton(); // 线程不安全
        }
        return instance;
    }
}

在多线程环境下,可能多个线程同时进入 if (instance == null),导致重复创建实例。


2. synchronized 的简单方案

直接对方法加锁:

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

虽然线程安全,但每次调用都要加锁,性能很差。


3. 双重检查加锁的思路

优化思路:只在第一次初始化时加锁,后续访问直接返回

public class Singleton {
    private static volatile Singleton instance;

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

4. 锁的本质

在 Java 中:

synchronized (lock) {
    // 临界区
}
  • 锁的对象:JVM 会加锁的是引用指向的 对象实例本身(堆区对象),而不是引用变量本身;
  • 引用只是找到对象的钥匙:访问引用不会加锁,加锁生效的是引用指向的对象;
  • 所以所有线程必须使用 同一个对象实例作为锁,才能保证互斥;
  • 这也是为什么 DCL 中锁对象通常使用 static final,保证引用唯一且不可修改。

总结一句话:双重检查加锁中,锁的是对象本身,而不是引用;引用只是用来找到这个对象。


5. 锁 class 对象 vs 锁堆区对象

5.1 锁 class 对象

synchronized (Singleton.class) {
    if (instance == null) {
        instance = new Singleton();
    }
}

特点

  • Class 对象在 JVM 中全局唯一;
  • 无需额外创建锁对象;
  • 教科书写法,直观。

优势

  • 简洁、天然唯一;
  • 所有线程共享同一把锁。

劣势

  • 可能被外部误用,锁粒度过大。

5.2 锁堆区对象

private static final Object lock = new Object();

synchronized (lock) {
    if (instance == null) {
        instance = new Singleton();
    }
}

特点

  • new Object() 创建对象在堆区,static final 引用指向它;
  • 专用锁对象,语义更清晰。

优势

  • 锁粒度小,封闭在类内部;
  • 不容易被外部代码误用。

劣势

  • 稍显啰嗦;
  • 如果不是 final,可能被修改,导致锁失效。

5.3 对比总结

锁对象类型 优势 劣势 适用场景
Singleton.class 简单、自然、全局唯一 可能被误用,锁粒度大 常见单例,代码简洁优先
堆区锁对象 锁语义清晰,作用域更小 代码稍复杂,非 final 有风险 强调锁粒度、避免外部干扰

6. 为什么不能锁 this?

  • 单例初始化前没有实例,也就没有 this
  • 即便已有实例,this 也不是全局唯一,无法保证互斥。

7. 为什么不锁 instance?

  • 初始化前 instance 为 null,无法加锁;

  • 初始化完成后,访问安全:

    • 引用赋值是原子操作;
    • volatile 保证可见性和禁止重排;
  • 锁只保护 初始化过程,而不是访问过程。


8. DCL 的边界

  • 解决问题:单例对象只初始化一次,线程安全;
  • 不能解决:单例对象方法内部的并发问题,内部共享可变状态仍需额外同步。

9. static final 的必要性

  • static:保证锁对象和类绑定,所有线程共享;

  • final:保证引用不可变,不会被指向别的对象;

  • 不用 static final 可能导致:

    • 实例字段无法使用(实例未创建,锁不存在);
    • 非 final 的 static 字段被修改,锁失效,DCL 崩溃。

10. 核心总结

  • DCL 锁 对象本身,引用只是找到对象的钥匙;
  • 锁的作用:保护 初始化过程,访问过程无需加锁;
  • Singleton.class:简单天然唯一,粒度大;
  • static final Object lock:专用堆对象,粒度小,语义清晰;
  • 不能锁 this
  • 方法内部线程安全问题需单独处理。

📌 比喻:

DCL 的锁就是“谁有权造工牌”的仲裁器。
工牌造好后,大家直接刷卡用,不需要每次都去找 HR 审批。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐