这是一个非常经典且深入的Java面试题。它考察对多线程、JVM内存模型、类加载机制和设计模式的综合理解。下面我将从原理、演进、陷阱和最佳实践四个层面,彻底讲透双重检查锁定(Double-Checked Locking, DCL)单例。


一、 基础原理与演进史

1. 单例的终极目标
  • 保证一个类在任何情况下只有一个实例

  • 提供全局访问点

  • 在多线程环境下绝对安全

2. 方案的演进(从错误到正确)

版本1:天真的懒汉式(线程不安全)

java

public class Singleton {
    private static Singleton instance;
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) {                // 线程A和B可能同时通过这行检查
            instance = new Singleton();       // 导致创建多个实例
        }
        return instance;
    }
}

版本2:同步方法(线程安全但性能差)

java

public static synchronized Singleton getInstance() {
    if (instance == null) {
        instance = new Singleton();
    }
    return instance;
}
  • 问题:每次调用都加锁,性能瓶颈严重。

版本3:双重检查锁定(DCL)- 错误版本

java

public class Singleton {
    private static Singleton instance;  // ❌ 没有volatile
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) {                    // 第一次检查
            synchronized (Singleton.class) {       // 加锁
                if (instance == null) {            // 第二次检查
                    instance = new Singleton();    // ❌ 问题在这里!
                }
            }
        }
        return instance;
    }
}

 篇幅限制下面就只能给大家展示小册部分内容了。整理了一份核心面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafc

需要全套面试笔记及答案
【点击此处即可/免费获取】


二、 为什么需要volatile?——核心陷阱解析

问题出在 instance = new Singleton() 这行代码。它不是原子操作,在JVM中实际分三步执行:

java

memory = allocate();   // 1. 分配内存空间
ctorInstance(memory);  // 2. 初始化对象
instance = memory;     // 3. 将引用指向分配的内存地址

由于指令重排序(编译器和处理器优化),实际执行顺序可能是 1→3→2

并发问题场景模拟:

text

时间线:
1. 线程A进入synchronized块,执行到 instance = new Singleton()
2. JVM重排序:先执行了步骤3(instance指向内存),但步骤2(初始化)尚未执行
3. 此时instance != null,但对象还未初始化!
4. 线程B调用getInstance(),第一次检查发现instance != null
5. 线程B直接返回了未完全初始化的“半成品”对象!
6. 线程B使用这个对象 → 空指针异常或逻辑错误
正确的DCL版本(JDK 1.5+)

java

public class Singleton {
    // ✅ 必须添加volatile关键字
    private static volatile Singleton instance;
    
    private Singleton() {
        // 防止反射破坏单例
        if (instance != null) {
            throw new RuntimeException("Use getInstance() method to get the single instance.");
        }
    }
    
    public static Singleton getInstance() {
        if (instance == null) {                    // 第一次检查:避免不必要的同步
            synchronized (Singleton.class) {       // 加锁
                if (instance == null) {            // 第二次检查:防止重复创建
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

volatile的两大作用

  1. 禁止指令重排序:确保 new Singleton() 的步骤按 1→2→3 顺序执行。

  2. 保证可见性:一个线程修改instance后,其他线程能立即看到最新值。


三、 替代方案(更推荐)

虽然DCL正确,但在实际开发中,有更简洁安全的方案。

方案1:静态内部类(Holder模式)- 推荐

java

public class Singleton {
    private Singleton() {
        // 构造器私有化
    }
    
    // 静态内部类在第一次被引用时才加载
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;  // 触发类加载,线程安全地初始化
    }
}

优点

  • 懒加载(调用getInstance()时才初始化)

  • 线程安全(JVM保证类加载过程的同步)

  • 代码简洁,无需同步锁

方案2:枚举单例(最安全)- 《Effective Java》推荐

java

public enum Singleton {
    INSTANCE;  // 本身就是单例
    
    public void doSomething() {
        // 业务方法
        System.out.println("Singleton working...");
    }
}
// 使用:Singleton.INSTANCE.doSomething();

优点

  • 绝对防止多实例(JVM保证)

  • 防止反射攻击

  • 防止序列化破坏单例

  • 代码极其简洁


四、 面试回答要点与层次

面试时,建议按以下逻辑层次回答:

第一层:基本实现
“我理解DCL单例需要volatile修饰实例变量,并在getInstance方法中进行两次null检查,并在中间加锁。”

第二层:原理解析
“这是因为 new Singleton() 不是原子操作,可能发生指令重排序。如果没有volatile,其他线程可能看到一个未完全初始化的对象。volatile通过禁止重排序和保证可见性来解决这个问题。”

第三层:演进对比
“DCL在JDK 1.5之后才是完全正确的,因为Java内存模型的增强。不过实际开发中,我通常更推荐使用静态内部类或枚举的方式实现单例,它们更简洁且天生线程安全。”

第四层:生产环境考虑
“如果项目中必须用DCL,我会注意:

  1. 确保使用JDK 1.5+

  2. 使用volatile

  3. 考虑防御反射攻击

  4. 单例对象如果是重量级资源,还需要考虑销毁问题”


五、 完整代码示例(生产级)

java

/**
 * 双重检查锁定单例(生产可用版本)
 * 包含防御反射攻击和序列化安全的考虑
 */
public class DclSingleton implements Serializable {
    private static final long serialVersionUID = 1L;
    
    private static volatile DclSingleton instance;
    
    private DclSingleton() {
        // 防御反射攻击
        if (instance != null) {
            throw new IllegalStateException("Instance already exists.");
        }
    }
    
    public static DclSingleton getInstance() {
        DclSingleton result = instance;  // 减少对volatile的直接访问(性能优化)
        if (result == null) {
            synchronized (DclSingleton.class) {
                result = instance;
                if (result == null) {
                    instance = result = new DclSingleton();
                }
            }
        }
        return result;
    }
    
    // 防止序列化破坏单例
    protected Object readResolve() {
        return getInstance();
    }
    
    public void businessMethod() {
        System.out.println("Business logic here.");
    }
}

关键优化点

  • 使用局部变量result减少volatile读取次数(Joshua Bloch在《Effective Java》中建议)

  • 实现readResolve()防止序列化破坏

  • 构造器中检查防止反射攻击

篇幅限制下面就只能给大家展示小册部分内容了。整理了一份核心面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafc

需要全套面试笔记及答案
【点击此处即可/免费获取】​​​


总结

  1. DCL单例必须使用volatile(在JDK 1.5+)

  2. 理解指令重排序和内存可见性是关键

  3. 静态内部类和枚举是更优选择

  4. 生产环境要考虑反射、序列化等攻击

掌握这个问题的过程,体现了你对并发编程的理解深度对细节的关注程度,这正是高级Java工程师的核心能力。

Logo

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

更多推荐