深入理解.NET中的 Lazy<T>

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;

为什么有效?

  1. 多个线程可能各自造了一个 Lazy 盒子,但造盒子很便宜,里面的数据库查询还没执行。
  2. 字典保证只有一个盒子被存进去,所有线程拿到的都是这同一个盒子。
  3. 大家都访问这个盒子的 .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. 默认模式:加锁 + 再检查一次

两个线程同时来创建时,过程是这样的:

  1. 线程 A 和线程 B 都发现还没创建,都去抢锁。
  2. 线程 A 抢到锁,线程 B 在门外排队。
  3. 线程 A 进门后检查:"还没人创建吧?"确认没有,执行创建,把 _state 设为 null。
  4. 线程 A 离开,线程 B 进门。
  5. 线程 B 进门后也检查一次:"还没人创建吧?"发现已经创建了,什么也不做。
  6. 两个线程拿到同一个结果。

第 5 步就是"双重检查":排队的人进门后要再确认一次,避免重复创建。

4. PublicationOnly 模式:大家各做各的,谁先完成用谁的

这个模式不加锁:

  1. 每个线程都执行创建方法,得到自己的结果。
  2. 大家用一个原子操作 Interlocked.CompareExchange 抢着"交卷"。
  3. 只有一个线程能抢成功,它的结果被保存。其他线程的结果被丢弃。

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 字段判断是否完成:完成后直接返回,未完成时加锁并再检查一次。
相关推荐
普通人61 小时前
宝塔中实现按地区过滤和官网跳转伪静态
服务器·开发语言·nginx·1024程序员节
Raas1001 小时前
AI网关能做调用审计吗?MAI Gateway(魔芋企业级AI网关)实战能力深度解读
java·服务器·网络·数据库·gateway·prompt·企业级产品
海盗12342 小时前
微软技术日报 2026-10-14:十月补丁 1411 个漏洞,.NET 11 交出 RC2
人工智能·microsoft·机器人·aigc·.net
智恒百亿2 小时前
RTX 5090 多卡集群工程实践:消费旗舰卡搭建 8 卡算力平台的坑与边界
服务器·人工智能·ai算力·rtx5090
IpdataCloud2 小时前
高精度IP定位服务怎么评估?从数据源、精度层级到实测验证的完整框架
服务器·网络·tcp/ip·ip
程序员-Benothing2 小时前
Linux CPU性能分析:top、mpstat、pidstat、perf实战
linux·运维·服务器
Evelvnk2 小时前
计算机学习资料
开发语言·人工智能·计算机·php
William.csj2 小时前
Bitvise SSH Client——配置跳板机:通过中转服务器连接内网服务器
运维·服务器·ssh
学以智用2 小时前
.NET WinForms / WPF / Blazor 模板详解
.net