题目
双重检查锁定单例模式在Java内存模型下的线程安全问题与解决方案
信息
- 类型:问答
- 难度:⭐⭐⭐
考点
Java内存模型(JMM),volatile关键字,双重检查锁定(DCL),指令重排序,可见性
快速回答
双重检查锁定(DCL)实现单例模式时,需要使用volatile修饰实例变量,原因如下:
- 防止指令重排序导致其他线程获取未初始化的对象
- 保证实例变量的修改对所有线程立即可见
- 避免使用同步锁带来的性能开销
正确代码示例:
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;
}
}
## 解析
问题背景
双重检查锁定(Double-Checked Locking)是常见的单例模式实现方式,旨在减少同步开销。但在Java内存模型(JMM)下,若不正确处理,会导致微妙的线程安全问题。
原理说明
关键问题在于对象实例化操作instance = new Singleton()并非原子操作,实际包含三个步骤:
- 分配对象内存空间
- 初始化对象(执行构造函数)
- 将引用指向内存地址(此时instance不再为null)
JVM可能进行指令重排序(步骤2和3交换),导致其他线程获取到未初始化的对象。Java内存模型规定:
- 普通写操作与后续加锁操作没有happens-before关系
- 非volatile变量不保证可见性和有序性
错误代码示例
// 错误实现:缺少volatile修饰
public class Singleton {
private static Singleton instance; // 缺少volatile
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查(有锁)
instance = new Singleton();
}
}
}
return instance;
}
}错误后果:
- 线程A执行到步骤3(引用已赋值)但未初始化对象
- 线程B在第一次检查时发现instance非null,直接返回未初始化的对象
- 导致NPE或状态不一致
正确解决方案
// 正确实现:使用volatile
public class Singleton {
private static volatile Singleton instance;
// 私有构造器
private Singleton() {}
public static Singleton getInstance() {
Singleton localRef = instance; // 减少volatile读取次数
if (localRef == null) {
synchronized (Singleton.class) {
localRef = instance;
if (localRef == null) {
instance = localRef = new Singleton();
}
}
}
return localRef;
}
}volatile关键作用:
- 禁止指令重排序:通过内存屏障(Memory Barrier)确保写操作顺序
- 保证可见性:写操作立即刷新到主内存,并使其他线程缓存失效
- 建立happens-before关系:保证初始化完成前不会返回对象引用
最佳实践
- 优先使用静态内部类方式(利用类加载机制保证线程安全):
public class Singleton { private Singleton() {} private static class Holder { static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } } - 枚举单例(Java 1.5+推荐):
public enum Singleton { INSTANCE; // 添加方法 } - 若必须使用DCL:
- 始终用volatile修饰实例变量
- 添加临时变量减少volatile访问
- 构造函数避免抛出异常
常见错误
- 忘记volatile修饰符
- 在synchronized块外进行非空检查后直接返回(未二次检查)
- 使用final字段但未正确发布
- 误认为synchronized能完全阻止指令重排序
扩展知识
- JMM的happens-before原则:volatile写操作先于后续的volatile读操作
- 内存屏障类型:LoadLoad, StoreStore, LoadStore, StoreLoad
- 替代方案对比:
方案 线程安全 延迟加载 性能 DCL+volatile 是 是 高 静态内部类 是 是 高 枚举 是 否 最高 同步方法 是 是 低 - Java 9+的VarHandle:提供更细粒度的内存控制替代部分volatile场景