双重检查锁定DCL的正确写法:volatile为何缺它不可
双重检查锁定DCL的正确写法:volatile为何缺它不可
适读人群:Java中高级开发者、经常写单例模式的工程师 | 阅读时长:约14分钟
开篇故事
DCL(Double-Checked Locking)是Java面试里出现频率最高的并发话题之一,但我见过太多"背会了答案却不理解原因"的工程师。
2022年,我做代码评审,发现项目里有个DCL单例写法是这样的:
private static MyService instance; // 少了volatile
public static MyService getInstance() {
if (instance == null) {
synchronized (MyService.class) {
if (instance == null) {
instance = new MyService();
}
}
}
return instance;
}我问作者:为什么不加volatile?
他说:synchronized里面的操作不是有内存屏障吗?外面的第一次检查用volatile,里面的操作已经在synchronized保护下了,是不是volatile可以不加?
这个问题问得很有水平。他的逻辑是对的一半——synchronized内部的new MyService()确实有内存屏障;但问题出在第一次if检查:它在synchronized外面,读的是instance的值,没有任何内存屏障。
今天把DCL的正确写法和背后的JMM原理讲清楚,不只是给答案,而是讲清楚"为什么"。
一、new Object()不是原子操作
这是理解DCL问题的关键前提。
instance = new MyService()在字节码层面分三步:
// 对应的字节码
NEW MyService // 1. 分配内存空间,返回引用
DUP
INVOKESPECIAL <init> // 2. 调用构造方法,初始化对象字段
ASTORE instance // 3. 将引用赋值给instance变量步骤2和步骤3可能被JIT编译器或CPU重排序:先执行步骤3(引用赋给instance),然后执行步骤2(初始化对象字段)。
这种重排序在单线程下没有问题(结果一样),但在多线程下是致命的:
- 线程T1进入synchronized块,执行了步骤3但还没执行步骤2(instance不为null,但对象未初始化)
- 线程T2执行第一次if检查:
instance != null(因为步骤3已执行)→ 直接返回instance - T2使用未初始化的instance,NPE或数据错误
二、volatile如何解决这个问题
2.1 volatile写的内存屏障作用
instance加了volatile后,instance = new MyService()的字节码等价于:
NEW MyService
DUP
INVOKESPECIAL <init> // 初始化对象
// volatile写之前插入StoreStore屏障:
// 保证初始化(INVOKESPECIAL)不会被重排到volatile写之后
[StoreStore Barrier]
ASTORE instance // volatile写:instance引用赋值
// volatile写之后插入StoreLoad屏障(可选,防止后续读重排)
[StoreLoad Barrier]StoreStore Barrier保证:所有在volatile写之前的Store操作(包括对象字段的初始化),都会在volatile写完成前完成。
对于读线程(T2):volatile读之后插入LoadLoad和LoadStore屏障,保证读到的对象引用所对应的对象,其初始化操作对T2可见(因为T1的初始化happens-before T1的volatile写,T1的volatile写happens-before T2的volatile读,传递性:T1的初始化happens-before T2看到的状态)。
2.2 synchronized内存屏障不能替代volatile
有同学说:synchronized的monitorenter/monitorexit也有内存屏障,能不能替代volatile?
回答:不能。
synchronized的屏障只在进入和退出synchronized块时起作用:
monitorexit:刷新所有修改到主内存(StoreStore + StoreLoad语义)monitorenter:从主内存重新加载(LoadLoad + LoadStore语义)
T1退出synchronized时,instance的写操作确实被刷新到主内存了,happens-before关系建立。
但T2的第一次if检查在synchronized块外面,是一次普通读(无内存屏障)。T2的普通读不能保证从主内存读取最新值——CPU可能从本地缓存读到旧的null值,也可能读到一个"部分初始化完成的对象的引用"(在非x86架构上,CPU有更弱的内存一致性模型)。
volatile的作用是保护第一次if检查的读操作,让它能看到T1写入的经过完整初始化的对象引用。
三、完整代码实现
3.1 DCL的正确与错误写法对比
package com.laozhang.concurrent.singleton;
/**
* 双重检查锁定(DCL)的正确写法与各种错误写法对比
*
* 测试环境:JDK 11
* 验证方法:
* 1. 用javap -v查看字节码,确认volatile字段有ACC_VOLATILE标志
* 2. 用JVM参数 -XX:+PrintCompilation 观察JIT编译
* 3. 在ARM架构机器上高并发测试(x86因为强内存模型不容易复现问题)
*/
public class SingletonPatterns {
// ===== 错误写法1:缺少volatile =====
private static class WrongDCL {
private static WrongDCL instance; // 没有volatile!
public static WrongDCL getInstance() {
if (instance == null) { // 第一次检查(无屏障的普通读)
synchronized (WrongDCL.class) {
if (instance == null) {
instance = new WrongDCL(); // 可能重排序!
}
}
}
return instance;
}
}
// ===== 错误写法2:只加一层锁(性能差)=====
private static class SingleLockSingleton {
private static SingleLockSingleton instance;
public static synchronized SingleLockSingleton getInstance() {
if (instance == null) {
instance = new SingleLockSingleton();
}
return instance;
}
// 每次调用都加锁,高并发下synchronized成为瓶颈
}
// ===== 正确写法1:volatile DCL =====
private static class CorrectDCL {
private volatile static CorrectDCL instance; // volatile!
private final int value;
private CorrectDCL() {
this.value = computeExpensiveInit();
}
private int computeExpensiveInit() {
// 模拟昂贵的初始化
return 42;
}
public static CorrectDCL getInstance() {
if (instance == null) { // 第一次检查(volatile读,有屏障)
synchronized (CorrectDCL.class) {
if (instance == null) { // 第二次检查(持锁,安全)
instance = new CorrectDCL(); // volatile写,禁止重排序
}
}
}
return instance;
}
public int getValue() { return value; }
}
// ===== 正确写法2:静态内部类(推荐,更简洁)=====
private static class HolderSingleton {
private HolderSingleton() {}
// 静态内部类在第一次被引用时才加载(懒加载)
// 类加载是线程安全的(JVM保证),无需额外同步
private static class Holder {
static final HolderSingleton INSTANCE = new HolderSingleton();
}
public static HolderSingleton getInstance() {
return Holder.INSTANCE;
}
}
// ===== 正确写法3:枚举(最简洁,防反射攻击)=====
private enum EnumSingleton {
INSTANCE;
// 可以有方法
public void doSomething() {
System.out.println("EnumSingleton doing something");
}
}
public static void main(String[] args) throws InterruptedException {
// 并发测试CorrectDCL
int THREAD_COUNT = 100;
CorrectDCL[] results = new CorrectDCL[THREAD_COUNT];
Thread[] threads = new Thread[THREAD_COUNT];
for (int i = 0; i < THREAD_COUNT; i++) {
final int idx = i;
threads[idx] = new Thread(() -> {
results[idx] = CorrectDCL.getInstance();
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
// 验证所有线程拿到的是同一个实例
boolean allSame = true;
for (CorrectDCL result : results) {
if (result != results[0]) {
allSame = false;
break;
}
}
System.out.println("所有线程获得同一实例:" + allSame);
System.out.println("实例值:" + results[0].getValue());
// 静态内部类方式
HolderSingleton h1 = HolderSingleton.getInstance();
HolderSingleton h2 = HolderSingleton.getInstance();
System.out.println("Holder单例:" + (h1 == h2));
// 枚举方式
EnumSingleton.INSTANCE.doSomething();
}
}3.2 用volatile DCL实现懒加载缓存
package com.laozhang.concurrent.singleton;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
/**
* 将DCL模式应用于懒加载缓存
*
* 场景:配置项缓存,第一次访问时加载,后续直接返回缓存值
* 要求:线程安全、只初始化一次、读多写少时高效
*
* 测试环境:JDK 11
*/
public class LazyCache<K, V> {
// ConcurrentHashMap + volatile DCL
// 外层HashMap存key→Lazy<V>,内层Lazy用volatile DCL
private final ConcurrentHashMap<K, LazyValue<V>> cache = new ConcurrentHashMap<>();
private static class LazyValue<V> {
private volatile V value; // volatile!
private final Supplier<V> loader;
LazyValue(Supplier<V> loader) {
this.loader = loader;
}
V get() {
if (value == null) { // 第一次检查(volatile读)
synchronized (this) {
if (value == null) { // 第二次检查(持锁)
value = loader.get(); // volatile写
}
}
}
return value;
}
}
/**
* 获取缓存值,如果不存在则通过loader加载(线程安全,只加载一次)
*/
public V get(K key, Supplier<V> loader) {
// computeIfAbsent是原子的(ConcurrentHashMap保证)
LazyValue<V> lazy = cache.computeIfAbsent(key, k -> new LazyValue<>(loader));
return lazy.get();
}
/**
* 使配置缓存,每个key对应的配置只加载一次
*/
public static void main(String[] args) throws InterruptedException {
LazyCache<String, String> configCache = new LazyCache<>();
int[] loadCount = {0};
// 模拟10个线程并发获取同一个配置
Thread[] threads = new Thread[10];
String[] results = new String[10];
for (int i = 0; i < 10; i++) {
final int idx = i;
threads[idx] = new Thread(() -> {
results[idx] = configCache.get("database.url", () -> {
synchronized (loadCount) { loadCount[0]++; }
// 模拟从配置中心加载(耗时操作)
try { Thread.sleep(50); } catch (InterruptedException e) {}
return "jdbc:mysql://localhost:3306/mydb";
});
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
System.out.println("加载次数(应为1):" + loadCount[0]);
System.out.println("所有线程获取的值:" + results[0]);
boolean allSame = true;
for (String r : results) {
if (!r.equals(results[0])) { allSame = false; break; }
}
System.out.println("所有线程结果一致:" + allSame);
}
}四、踩坑实录
坑1:字段是volatile但局部变量缓存破坏了double-check
报错现象: 代码看起来正确,但实际上第一次检查用的不是volatile字段,而是缓存在局部变量里的值。
原因分析:
private volatile static MyService instance;
public static MyService getInstance() {
MyService tmp = instance; // 把volatile字段读到局部变量
if (tmp == null) { // 检查局部变量,不是volatile字段!
synchronized (MyService.class) {
tmp = instance; // 重新读volatile字段
if (tmp == null) {
tmp = new MyService();
instance = tmp; // volatile写
}
}
}
return tmp;
}有人认为这样能减少volatile读次数(因为volatile读有开销)。但第一次if检查用的是局部变量tmp,如果tmp是从volatile字段读出来的(MyService tmp = instance这行),这一行是volatile读,仍然有屏障。所以这个版本其实也是正确的——只要instance字段是volatile,读到tmp的那一刻就建立了happens-before关系。
但如果写成:
if (this.instance == null) { // 误用非volatile字段就是错误的。
坑2:DCL用于延迟初始化字段而不是单例,条件判断逻辑错了
报错现象: 用DCL初始化一个字段,但字段可能被合法地设置为null,导致每次检查都进synchronized重新初始化。
原因分析: DCL的if (field == null)假设null表示"未初始化"。如果null本身是一个合法的值,这个假设就不成立了:
// 错误:null是合法配置值
private volatile String config;
public String getConfig() {
if (config == null) { // null可能是合法值,不代表未初始化
synchronized (this) {
if (config == null) {
config = loadConfig(); // loadConfig()可能返回null
}
}
}
return config;
}
// 如果loadConfig()返回null,下次调用又进来重新loadConfig,性能问题解法: 用一个sentinel值(哨兵对象)区分"未初始化"和"null值":
private static final Object UNINITIALIZED = new Object();
private volatile Object config = UNINITIALIZED; // 初始状态
public String getConfig() {
Object v = config;
if (v == UNINITIALIZED) {
synchronized (this) {
if (config == UNINITIALIZED) {
config = loadConfig(); // 可以是null
}
}
v = config;
}
return (String) v; // 可能返回null,但不会重复加载
}坑3:静态内部类单例被反射破坏
报错现象: 通过反射创建了多个Holder单例的实例,单例性被破坏。
原因分析: 任何通过类加载保证单例的方式(静态内部类、饿汉式静态字段),都可以被反射的Constructor.setAccessible(true)绕过。
Constructor<HolderSingleton> c = HolderSingleton.class.getDeclaredConstructor();
c.setAccessible(true);
HolderSingleton s2 = c.newInstance(); // 创建了第二个实例!解法: 如果需要防反射,在构造函数里检查实例是否已存在:
private CorrectSingleton() {
if (Holder.INSTANCE != null) {
throw new RuntimeException("禁止反射创建单例!");
}
}或者使用枚举——枚举的构造函数被JVM保护,反射创建枚举实例会抛IllegalArgumentException。
坑4:在Android/早期JDK版本上DCL不安全
报错现象: 理论上正确的volatile DCL,在某些老版本平台上还是出现了并发问题。
原因分析: 这个问题出现在JDK 1.4及更早版本,早期JMM对volatile的语义定义不完整,不能保证"初始化操作的有序性"。JDK 5(JSR-133)之后,JMM重新定义了volatile的语义,才真正解决了DCL问题。
Android早期版本(API 8以前)基于Dalvik虚拟机,volatile的语义实现也有问题。
解法: 使用JDK 5+(JSR-133修订后的JMM),volatile DCL是安全的。Android API 9+也是安全的。对于很老的项目,直接用静态内部类或枚举,完全规避这个问题。
五、总结与延伸
DCL的本质:用第一次无锁检查提升性能,用volatile保证检查的正确性。
完整的内存语义链:
- T1:
new MyService()初始化对象字段 - T1:volatile写
instance(StoreStore屏障保证1在2之前) - T2:volatile读
instance(建立happens-before关系) - T2:
instance != null,使用对象(安全,1发生在3之前)
单例模式选型建议:
- 最简单、推荐:静态内部类(Holder模式),懒加载、线程安全、无额外开销
- 需要防序列化和反射破坏:枚举
- 需要理解底层原理、有特殊初始化逻辑:volatile DCL
- 不建议:饿汉式(如果初始化很重,可能浪费内存)
