结论:Lazy是当前C#中最稳妥、简洁、线程安全且支持懒加载的单例实现方式;static字段+private构造函数因“判断+创建+赋值”非原子操作,多线程下可能创建多个实例;DCL易出错且无性能优势,不推荐新手使用。

直接说结论:用 Lazy<t></t> 是当前 C# 中最稳妥、最简洁、线程安全且支持懒加载的单例实现方式;手写双重检查锁(DCL)容易漏掉 volatile 或锁粒度出错,不推荐新手硬刚。
为什么 static 字段 + private 构造函数会创建多个实例
这是最常见的误判点。很多人以为只要把构造函数设为 private、用 static readonly 字段存实例就万事大吉,但实际在多线程并发调用时,if (_instance == null) 这个判断不是原子操作——两个线程可能同时通过该判断,各自执行 new Singleton(),结果生成两个不同实例。
典型现象包括:
-
Singleton.Instance.GetHashCode()在压力测试中返回不同值 - 数据库连接池被重复初始化两次,导致连接数翻倍
- 日志里看到“Singleton 实例被创建”打印了多次
根本原因在于:.NET 静态字段赋值本身虽线程安全,但「判断 + 创建 + 赋值」这三步组合不是临界区,必须由更高层机制保护。
Lazy 为什么是首选方案
Lazy<t></t> 内置了线程安全保证,默认使用 LazyThreadSafetyMode.ExecutionAndPublication 模式,确保即使多个线程同时首次访问 .Value,也只执行一次工厂函数,并将结果原子性地发布给所有调用者。
关键实操要点:
- 必须用
readonly修饰Lazy<t></t>字段,防止被意外替换 - 工厂函数(
() => new Singleton())里可安全做耗时初始化,如读配置、建连接 - 类要加
sealed,防止子类绕过私有构造函数破坏单例语义 - .NET Framework 4.0+ / .NET Core 2.0+ 均原生支持,无需额外 NuGet
示例代码片段:
public sealed class ConfigService
{
private static readonly Lazy<ConfigService> _instance =
new Lazy<ConfigService>(() => new ConfigService());
<pre class='brush:php;toolbar:false;'>public static ConfigService Instance => _instance.Value;
private ConfigService()
{
// 这里可以加载 appsettings.json、初始化缓存等
}}
饿汉式(static 构造函数)适合什么场景
如果你确定这个单例对象启动就必须存在、且初始化开销不大,饿汉式反而是最轻量、最无脑安全的选择。它的线程安全性由 CLR 保证,连 Lazy<t></t> 的那点内部 Monitor 开销都省了。
但要注意几个硬限制:
- 静态构造函数一旦抛异常,整个类型将永久不可用(后续任何访问都会直接抛
TypeInitializationException) - 无法延迟加载——哪怕你从头到尾都没调用过
Instance,实例也会在类第一次被引用时创建 - 不适合依赖其他尚未初始化的服务(比如 DI 容器还没 build 完,你就想在静态构造里 resolve 服务)
写法示例:
public sealed class LoggerService
{
private static readonly LoggerService _instance = new LoggerService();
<pre class='brush:php;toolbar:false;'>static LoggerService() // 可选:显式静态构造函数,用于异常捕获或复杂逻辑
{
// 若这里 throw,类型将永久失效
}
private LoggerService() { }
public static LoggerService Instance => _instance;}
别碰双重检查锁(DCL),除非你真懂 volatile 和锁边界
网上大量教程教的 DCL 写法,看似优雅,实则极易出错。漏掉 volatile 修饰字段、lock 块范围写错、或者把锁对象声明成局部变量,都会让线程安全形同虚设。
真实排错经验告诉你:
- 即使加上
volatile,.NET JIT 优化仍可能在某些极端路径下重排序指令 - DCL 在 .NET 中性能并不比
Lazy<t></t>更好,反而更难验证正确性 - 单元测试几乎无法覆盖竞态窗口,上线后才暴露问题
一句话:除非你在维护一个不能升级到 .NET 4.0 的老项目,否则不要手写 DCL。
真正容易被忽略的点是:单例对象如果实现了 IDisposable,必须显式管理生命周期;而 Lazy<t></t> 本身不提供释放机制——这时候就得考虑是否该交给 DI 容器统一管理,而不是自己硬写单例。


















