Lazy<T> 是一个"用到时才创建,创建后就记住"的盒子。
你先告诉它怎么创建,它不会马上动手。等你第一次要用时,它才真正创建,并把结果保存起来。之后再要,直接给你保存好的那个。
一、为什么需要它?
有些对象创建起来很"贵",比如:
- 要查数据库
- 要读大文件
- 要请求网络
- 要占用很多内存
如果程序启动时就把它们全部创建好,但最后根本没用上,就白白浪费了时间和资源。
Lazy<T> 解决的就是这个问题:不用就不创建,用了只创建一次。
二、基本用法
csharp
var lazyUser = new Lazy<User>(() =>
{
Console.WriteLine("正在查数据库...");
return LoadUserFromDb("user:1");
});
Console.WriteLine("盒子准备好了,但还没查数据库");
var u1 = lazyUser.Value; // 第一次访问:输出"正在查数据库..."
var u2 = lazyUser.Value; // 第二次访问:什么也不输出,直接返回结果
Console.WriteLine(ReferenceEquals(u1, u2)); // True,是同一个对象
记住两个成员就够了:
| 成员 | 作用 |
|---|---|
.Value |
获取值。第一次访问时才创建 |
.IsValueCreated |
是否已经创建过 |
三、它天生线程安全
如果两个线程同时 第一次访问 .Value,会不会创建两次?
默认不会。 Lazy<T> 会让一个线程去创建,另一个线程等着,最后两个线程拿到同一个结果。
你也可以根据需要选择不同的模式:
csharp
new Lazy<User>(() => Load(), LazyThreadSafetyMode.ExecutionAndPublication);
| 模式 | 多个线程同时访问时 | 什么时候用 |
|---|---|---|
ExecutionAndPublication(默认) |
只有一个线程创建,其他线程等待 | 大多数情况 |
PublicationOnly |
每个线程都可能去创建,只采用最先完成的结果 | 创建很便宜,不想让线程等 |
None |
不做任何保护 | 确定只有一个线程使用 |
不确定就用默认的。
四、最常见的三种用法
用法 1:线程安全的单例
csharp
public sealed class Config
{
private static readonly Lazy<Config> _instance = new(() => new Config());
public static Config Instance => _instance.Value;
private Config()
{
Console.WriteLine("配置只加载一次");
}
}
第一次访问 Config.Instance 时才创建,之后一直是同一个对象。不需要自己写锁。
用法 2:配合缓存,保证昂贵操作只执行一次
先看问题。ConcurrentDictionary 的 GetOrAdd 在多个线程同时请求一个不存在的键时,创建方法可能会被执行多次:
csharp
// 两个线程同时进来,可能查两次数据库
var user = cache.GetOrAdd("user:1", key => LoadUserFromDb(key));
用 Lazy 包一层就解决了:
csharp
var cache = new ConcurrentDictionary<string, Lazy<User>>(StringComparer.Ordinal);
var user = cache.GetOrAdd("user:1",
key => new Lazy<User>(() => LoadUserFromDb(key))).Value;
为什么有效?
- 多个线程可能各自造了一个
Lazy盒子,但造盒子很便宜,里面的数据库查询还没执行。 - 字典保证只有一个盒子被存进去,所有线程拿到的都是这同一个盒子。
- 大家都访问这个盒子的
.Value,而Lazy保证只创建一次。
多造几个空盒子没关系,真正昂贵的工作只做一次。
用法 3:异步加载
查数据库通常是异步方法。这时把 Task 放进盒子:
csharp
var cache = new ConcurrentDictionary<string, Lazy<Task<User>>>(StringComparer.Ordinal);
async Task<User> GetUserAsync(string key)
{
var lazy = cache.GetOrAdd(key,
k => new Lazy<Task<User>>(() => LoadUserFromDbAsync(k)));
return await lazy.Value;
}
Lazy 保证异步加载只启动一次,所有调用者等待同一个 Task。
五、最重要的坑:异常会被"记住"
默认模式下,如果创建时抛出异常,Lazy 会把这个异常保存下来 。之后每次访问 .Value,都直接抛出同一个异常,不会重试。
csharp
var lazy = new Lazy<User>(() => throw new Exception("数据库连接失败"));
try { _ = lazy.Value; } catch { } // 第一次:执行创建,抛出异常
try { _ = lazy.Value; } catch { } // 第二次:不再执行,直接抛出上次的异常
放在缓存里尤其危险:数据库偶尔断开一下,这个键就永远坏掉了。
解决办法:失败时把它从缓存中移除。
csharp
User GetUser(string key)
{
var lazy = cache.GetOrAdd(key, k => new Lazy<User>(() => LoadUserFromDb(k)));
try
{
return lazy.Value;
}
catch
{
// 只移除这个失败的盒子,不误删别人刚放进去的新盒子
cache.TryRemove(new KeyValuePair<string, Lazy<User>>(key, lazy));
throw;
}
}
下次请求时,会重新创建一个新的 Lazy,重新尝试加载。异步版本的 Lazy<Task<T>> 也有同样的问题,失败的 Task 也要移除。
另外两种情况不会缓存异常:使用 PublicationOnly 模式时,以及不传创建方法、直接用 T 的无参构造函数创建时。
六、底层是怎么实现的?
理解原理后,上面所有行为都会变得很自然。
1. 只有三个字段
csharp
private volatile LazyHelper? _state; // 当前状态
private Func<T>? _factory; // 创建方法
private T? _value; // 创建好的值
最关键的设计是:_state 为 null,就表示值已经创建好了。
2. Value 属性只有一行
csharp
public T Value => _state == null ? _value! : CreateValue();
- 已经创建好了 → 直接返回,不加锁
- 还没创建 → 去创建
所以 Lazy 创建完成后,每次访问几乎没有额外开销。
3. 默认模式:加锁 + 再检查一次
两个线程同时来创建时,过程是这样的:
- 线程 A 和线程 B 都发现还没创建,都去抢锁。
- 线程 A 抢到锁,线程 B 在门外排队。
- 线程 A 进门后检查:"还没人创建吧?"确认没有,执行创建,把
_state设为null。 - 线程 A 离开,线程 B 进门。
- 线程 B 进门后也检查一次:"还没人创建吧?"发现已经创建了,什么也不做。
- 两个线程拿到同一个结果。
第 5 步就是"双重检查":排队的人进门后要再确认一次,避免重复创建。
4. PublicationOnly 模式:大家各做各的,谁先完成用谁的
这个模式不加锁:
- 每个线程都执行创建方法,得到自己的结果。
- 大家用一个原子操作
Interlocked.CompareExchange抢着"交卷"。 - 只有一个线程能抢成功,它的结果被保存。其他线程的结果被丢弃。
5. 异常为什么会被记住?
csharp
catch (Exception exception)
{
_state = new LazyHelper(mode, exception); // 把异常存进状态
throw;
}
创建失败时,异常被存进了 _state。因为 _state 不是 null,下次访问时 Lazy 会认为"还没完成",进去一看状态里存着异常,就直接再抛一次。
6. 两个巧妙的小细节
先写值,再改状态。 源码总是先写 _value,再把 _state 设为 null。加上 volatile 关键字的保证,其他线程只要看到"已完成",就一定能读到完整的值,不会读到一半。
调用前先清空创建方法。 源码会在执行创建方法之前,先把 _factory 设为 null。这样做有两个好处:
- 如果你的创建方法里又访问了同一个
Lazy的.Value,会立即发现_factory已经没了,于是抛出"递归调用"异常,而不是死循环。 - 创建方法引用的变量不再被占用,可以被垃圾回收。
7. 一个手写的简化版
去掉各种模式和优化,Lazy<T> 的核心逻辑大概就是这样:
csharp
public class SimpleLazy<T>
{
private volatile bool _created;
private Func<T>? _factory;
private T? _value;
private Exception? _error;
private readonly object _lock = new();
public SimpleLazy(Func<T> factory) => _factory = factory;
public T Value
{
get
{
if (_created) return _value!; // 已创建:直接返回,不加锁
lock (_lock)
{
if (!_created) // 进门后再检查一次
{
if (_error != null) throw _error;
var f = _factory ?? throw new InvalidOperationException("递归调用");
_factory = null; // 先清空,防止递归
try
{
_value = f();
_created = true; // 先写值,再标记完成
}
catch (Exception ex)
{
_error = ex; // 记住异常
throw;
}
}
}
return _value!;
}
}
}
官方实现把 _created、_lock、_error 三个字段合并成了一个 _state 字段,让每个 Lazy 对象更省内存,同时支持三种模式。思路是完全一样的。
七、使用建议速查
| 场景 | 建议 |
|---|---|
| 对象创建昂贵,且不一定会用到 | 用 Lazy<T> |
| 需要线程安全的单例 | static readonly Lazy<T> |
ConcurrentDictionary 缓存,加载很昂贵 |
值的类型用 Lazy<T> |
| 加载是异步的 | 用 Lazy<Task<T>> |
| 放在缓存中 | 失败时一定要移除,否则异常会被永久缓存 |
| 创建很便宜,不想线程等待 | LazyThreadSafetyMode.PublicationOnly |
| 确定只有单线程使用 | LazyThreadSafetyMode.None,最快 |
创建方法里访问自己的 .Value |
不要这样做,会抛出递归调用异常 |
总结
Lazy<T>= 用到时才创建,创建后就记住。- 默认线程安全,多个线程同时访问也只创建一次。
- 和
ConcurrentDictionary.GetOrAdd搭配,可以保证昂贵的加载只执行一次。 - 默认会记住异常,用在缓存里必须在失败时移除。
- 底层靠一个
_state字段判断是否完成:完成后直接返回,未完成时加锁并再检查一次。