在C#多线程场景下实现单例模式时,双重检查锁定是很多开发者会选择的写法,它试图在减少锁开销的同时保证单例的唯一性,但这种写法如果缺少volatile关键字修饰,会出现不符合预期的问题。

常见的双重检查锁定实现
先来看一段典型的没有使用volatile的双重检查锁定单例代码:
public class Singleton
{
private static Singleton _instance;
private static readonly object _lock = new object();
private Singleton() { }
public static Singleton GetInstance()
{
if (_instance == null) // 第一次检查
{
lock (_lock)
{
if (_instance == null) // 第二次检查
{
_instance = new Singleton(); // 创建实例
}
}
}
return _instance;
}
}
这段代码的初衷是:当_instance已经初始化后,后续线程不需要进入锁逻辑,直接返回实例,减少锁竞争带来的性能损耗。但这段代码在多线程环境下并不安全。
问题产生的核心原因
指令重排序问题
C#中创建对象的过程并不是原子操作,_instance = new Singleton()这行代码大致会被拆分成三个步骤:
- 分配Singleton对象所需的内存空间
- 调用Singleton的构造函数,初始化对象成员
- 将_instance引用指向分配好的内存空间
在没有volatile修饰的情况下,编译器和CPU可能会对这三个步骤进行指令重排序,步骤二和步骤三的执行顺序可能被调换。也就是说,可能出现_instance已经指向了内存空间,但构造函数还没有执行完成的情况。
线程安全漏洞场景
假设现在有两个线程A和B同时调用GetInstance方法:
- 线程A进入方法,第一次检查发现_instance为null,于是进入lock逻辑
- 线程A执行
_instance = new Singleton(),发生了指令重排序,先执行了步骤三,_instance已经指向了内存,但构造函数还没执行 - 此时线程B进入方法,第一次检查_instance不为null,直接返回_instance
- 线程B拿到的_instance是还没有完成初始化的对象,使用这个对象就会出现不可预期的错误
缺少内存屏障
volatile关键字的作用除了禁止指令重排序,还会保证变量的读写操作都有内存屏障。没有volatile修饰时,一个线程对_instance的写入操作,对其他线程的可见性无法保证,其他线程可能一直读取到旧的null值,或者读取到未初始化完成的引用。
正确的实现方式
要给_instance加上volatile修饰,禁止指令重排序,保证可见性:
public class Singleton
{
private static volatile Singleton _instance;
private static readonly object _lock = new object();
private Singleton() { }
public static Singleton GetInstance()
{
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
{
_instance = new Singleton();
}
}
}
return _instance;
}
}
除了这种方式,也可以使用静态构造函数或者Lazy<T>来实现线程安全的单例,这些方式不需要自己处理双重检查锁定的细节,也能保证线程安全,是更推荐的实现方案。
总结
双重检查锁定在没有volatile修饰时,会因为指令重排序和内存可见性问题导致线程安全漏洞,实际开发中如果要使用这种写法,一定要给单例引用加上volatile关键字,或者直接使用C#提供的Lazy<T>等更可靠的方式实现单例,避免踩坑。
C#双重检查锁定Double_Checked_Lockingvolatile线程安全修改时间:2026-07-05 13:51:21