写在前面
单例是 23 个模式里名声最差的一个——不是因为它没用,而是因为它被滥用得最狠。新手最爱写单例(Instance 多酷),然后收获一堆"测试不了、并发炸了、依赖藏起来"的坑。
这篇不光讲怎么写单例,更要讲怎么别写单例——以及 .NET DI 容器提供的正确姿势。
一、问题:全局唯一实例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| 有些对象,整个进程只要一个:
- 配置读取器(Configuration)
- 日志工厂(LoggerFactory)
- 数据库连接池
- 全局缓存(IMemoryCache)
- 设备访问(打印机、硬件接口)
为什么必须唯一:
- 多个实例浪费资源(重复加载配置、多个连接池)
- 多个实例导致状态不一致(两个缓存各存各的)
- 有些资源本身是单点的(一个打印机)
→ 需求:保证一个类只有一个实例,全局可访问。
→ 这就是单例模式。
|
二、单例:保证全局唯一
1
2
3
4
5
6
7
8
| 意图:确保一个类只有一个实例,并提供全局访问点。
要点:
1. 构造函数私有(外部不能 new)
2. 类内持有一个静态实例
3. 提供一个静态访问点(Instance)
关键词:唯一 + 全局可访问。
|
三、.NET 线程安全的几种写法
单例最大的技术难点是多线程下也要保证唯一。几种写法,从糙到精:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| // ❌ 错:非线程安全,多线程下可能造出多个
public class BadSingleton
{
private static BadSingleton? _instance;
public static BadSingleton Instance =>
_instance ??= new BadSingleton(); // 竞态:两个线程同时进来
}
// ✅ 写法 1:双检锁(经典,但要 volatile + lock)
public class DclSingleton
{
private static DclSingleton? _instance;
private static readonly object _lock = new();
public static DclSingleton Instance
{
get
{
if (_instance is null) // 一检:已有就直接返,避免锁开销
lock (_lock)
if (_instance is null) // 二检:进了锁再确认一次
_instance = new DclSingleton();
return _instance;
}
}
}
// ✅✅ 写法 2:静态只读字段(最简洁,CLR 保证线程安全的懒初始化)
public class StaticSingleton
{
public static readonly StaticSingleton Instance = new();
private StaticSingleton() { } // 私有构造
} // CLR 在首次访问类时初始化 static 字段,且保证线程安全
// ✅✅✅ 写法 3:Lazy<T>(显式控制,延迟到首次访问 Instance)
public class LazySingleton
{
private static readonly Lazy<LazySingleton> _lazy = new(() => new LazySingleton());
public static LazySingleton Instance => _lazy.Value; // 线程安全 + 懒加载
private LazySingleton() { }
}
|
1
2
| 现代 .NET 推荐:写法 2(static readonly,最简洁)或写法 3(Lazy<T>,最灵活)。
双检锁是 Java 时代的老黄历,C# 里基本不用了。
|
四、但请优先用 DI 容器的 Singleton
上面三种手写单例,现代 .NET 开发几乎都不用。理由是 DI 容器给了更干净的方案:
1
2
3
4
5
6
7
8
9
10
| // 注册为 Singleton —— 容器保证全局唯一
services.AddSingleton<IConfigurationReader, JsonConfigurationReader>();
services.AddSingleton<ILoggerFactory, LoggerFactory>();
services.AddSingleton<IMemoryCache, MemoryCache>();
// 用:构造函数注入,不直接碰 Instance
public class OrderService(IConfigurationReader _config, IMemoryCache _cache)
{
...
}
|
1
2
3
4
5
6
7
8
9
| DI Singleton vs 手写单例:
✓ 全局唯一由容器保证(你不用操心线程安全)
✓ 显式依赖(构造函数注入,依赖一目了然,不藏)
✓ 可测试(单测时换 Transient/假实现,改注册即可)
✓ 生命周期集中管理(Singleton/Scoped/Transient 统一规则)
✓ 没有静态全局状态(Instance 全局可访问 = 反模式,下面讲)
→ 唯一性需求,首选 services.AddSingleton,别手写 Instance。
|
五、单例的反模式面(为什么名声差)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| 手写单例(静态 Instance)的五大罪:
1. 全局状态
Instance 是全局可变点,任何代码都能访问修改
→ 状态在程序各处飘,谁改的、什么时候改的,无从追踪
2. 隐藏依赖
public void DoWork() { Config.Instance.Get(...) }
→ 方法签名看不出依赖 Config,像个隐式全局变量
→ 代码可读性、可测试性都差
3. 测试困难
单元测试想隔离,但 Instance 是全局静态,没法 mock/替换
→ 测试时不得不连真实配置/真实缓存
→ 详见《.NET 单元测试(四):依赖注入与可测试性》
4. 并发陷阱
单例被多线程共享,状态必须自己加锁保护
→ 稍不留神就是竞态、死锁
5. 生命周期捕获(.NET DI 专属大坑)
services.AddSingleton<Foo>(); Foo 注入了一个 Scoped 服务
→ Singleton 持有 Scoped,Scoped 被强制升级为单例
→ 跨请求共享本不该共享的状态,数据串了
→ 容器会抛异常提醒(Cannot resolve scoped service from root provider)
所以社区共识:能不用手写单例就不用,用 DI Singleton 替代。
|
六、什么时候仍需要单例
1
2
3
4
5
6
7
8
9
10
11
12
| 不全是反模式,少数场景仍合理:
✓ 互斥的外部资源(硬件接口、单点打印机、全局信号量)
✓ 框架/底层组件的引导(LoggerFactory、Configuration 根)
✓ 性能敏感、确实只要一份的全局缓存
但即便这些,也优先用 DI Singleton 表达"唯一性",
只在"必须用静态全局访问点"时(如扩展方法里取服务、遗留代码)才手写。
判断:要的是"唯一性",还是"全局可访问"?
多数时候只要"唯一性" → DI Singleton 就够。
真需要"全局随便谁能取"才手写 Instance。
|
七、何时用 / 何时别用
1
2
3
4
5
6
7
8
9
10
11
12
13
| 该用(以 DI Singleton 形式):
✓ 确需进程内唯一的共享资源(配置、缓存、连接池、日志工厂)
✓ 构造昂贵、可复用的无状态服务
别用:
✗ 只是为了"方便取"就上单例 → 那是全局变量,用依赖注入
✗ 有状态的"上下文"对象 → 单例会跨请求/跨线程串数据
✗ 为了少传参数 → 把依赖藏进单例 → 测试灾难
✗ Singleton 注入 Scoped 服务(生命周期捕获)
信号:当你想写 `static Instance` 时,先问自己:
"我能不能用 services.AddSingleton + 构造函数注入 代替?"
99% 的答案是可以。
|
八、小结
- 意图:保证一个类只有一个实例,全局可访问
- 手写:私有构造 + 静态实例;线程安全用
static readonly 或 Lazy<T>(双检锁过时了) - 正确姿势:
services.AddSingleton<T>() —— 容器保证唯一,干净、可测 - 反模式五罪:全局状态、隐藏依赖、测试困难、并发陷阱、生命周期捕获(Singleton 注入 Scoped)
- 多数时候你要的是"唯一性"而非"全局可访问" → DI Singleton 足矣,别手写 Instance
- 要单例,先想 DI;手写静态 Instance 是最后手段
下一篇(收官)讲命令与状态模式——正好接上 DDD 里的 CQRS Command 和订单状态机,给整个模式系列收口。