延迟初始化是提升程序启动性能的常用手段:对象第一次被用到时才创建,而不是在构造时就分配好。但如果这段逻辑运行在多线程环境中,简单的if判断加new就会产生竞态条件,可能导致对象被创建多次,甚至其他线程拿到一个尚未初始化完成的实例。C#提供了LazyInitializer这个静态工具类,专门用来解决这个问题,它比Lazy<T>更轻量,不需要额外封装一层对象,适合追求极致性能的场景。

为什么普通写法在多线程下不安全
先看一段最常见的错误代码。很多开发者在写单例或者大对象延迟加载时,会不假思索地写出下面这种形式:
private MyClass _instance;
public MyClass GetInstance()
{
if (_instance == null)
{
_instance = new MyClass();
}
return _instance;
}这段代码在单线程环境下没有任何问题,但在多线程场景下有两个严重缺陷。第一个缺陷是竞态条件:两个线程同时通过if判断,都发现_instance是null,于是各自创建一个实例并赋值,最终返回的对象可能不同,MyClass的构造函数被执行了多次。如果构造函数里有副作用,比如建立数据库连接、写日志、注册事件,重复执行的后果往往不可控。
第二个缺陷更隐蔽,涉及内存可见性和指令重排。_instance字段如果没有加volatile修饰,一个线程写入的对象引用可能对其他线程不可见,或者更糟糕的情况:编译器和CPU可能将写引用和构造函数内部的写入重排,导致其他线程拿到引用时对象还没初始化完成,访问字段时读到空值或脏数据。这就是所谓的半初始化对象问题,排查起来极其困难。
LazyInitializer的基本用法
LazyInitializer位于System.Threading命名空间,是一个纯静态类,核心是EnsureInitialized方法。它不需要像Lazy<T>那样包装目标对象,直接操作字段即可,因此没有额外的一次堆分配,开销非常小。最基础的重载只需要传入字段的引用和工厂方法:
using System.Threading;
class Demo
{
private static ExpensiveService _service;
public static ExpensiveService Service =>
LazyInitializer.EnsureInitialized(ref _service, () => new ExpensiveService());
}EnsureInitialized内部基于Interlocked.CompareExchange实现:先快速检查引用是否为null,不为null直接返回,这个路径没有任何锁;如果为null,则调用工厂方法创建实例,再通过原子比较交换操作把新实例写入字段。如果此时别的线程已经抢先写入,CompareExchange会失败,当前线程丢弃自己刚创建的实例,返回那个已经存在的实例。这样保证了所有线程最终拿到的是同一个对象。
需要注意一个细节:这个基础重载要求工厂方法本身不抛异常、且目标类型是引用类型。如果工厂方法抛出异常,EnsureInitialized不会缓存异常,下次调用会重新执行工厂方法,这一点和Lazy<T>默认缓存异常的行为不同。另外还有一个接收Boolean类型引用的重载,适合初始化后的值可能再次为null的场景,比如依赖外部状态的对象。
LazyInitializer与Lazy<T>的对比与选择
Lazy<T>是另一个常用的延迟初始化方案,它把线程安全逻辑封装在包装类内部,用起来更省心。两者各有侧重,可以从几个维度来选型。
| 对比维度 | Lazy<T> | LazyInitializer |
|---|---|---|
| 额外开销 | 需要创建包装对象 | 无包装对象,直接操作字段 |
| 线程安全模式 | 支持多种发布模式可选 | 仅一种,基于原子操作 |
| 异常缓存 | 默认缓存异常 | 不缓存,每次重试 |
| 代码侵入性 | 字段类型需要改为Lazy类型 | 字段类型不变,访问处调用方法 |
| 适用场景 | 通用延迟加载、依赖注入 | 高性能热点路径、静态缓存 |
如果初始化逻辑比较复杂、需要控制异常语义或者希望封装干净,Lazy<T>是更好的选择。而如果是在性能敏感的库代码中,比如缓存数组的填充、共享缓冲区的分配,LazyInitializer的优势就体现出来了:热路径上只是一次null判断加一次volatile读,几乎可以忽略不计。实际上.NET内部很多地方也是这么用的,例如某些集合类的内部同步对象初始化就是调用EnsureInitialized。
还有一种情况值得说明:如果初始化完成后可能因为某种策略要重置回null以便重新初始化,Lazy<T>很难做到,而LazyInitializer配合那个带Boolean引用的重载可以自然支持,因为它本质上只是对一个字段做原子管理,字段状态的生命周期完全由你控制。
进阶用法与注意事项
EnsureInitialized还提供了一种避免闭包分配的写法,传入一个acquireLock的Boolean字段引用,当初始化需要执行时进入一个全局锁区域,这样可以在锁内执行任意复杂的初始化逻辑而不依赖工厂委托:
private static Dictionary<string, byte[]> _cache;
private static bool _cacheInitialized;
public static Dictionary<string, byte[]> Cache
{
get
{
LazyInitializer.EnsureInitialized(ref _cache, ref _cacheInitialized,
ref LazyInitializer.EnsureInitialized, // 占位,实际使用见下方标准写法
() => new Dictionary<string, byte[]>());
return _cache;
}
}上面的示例展示了带状态标记的重载形式,实际项目中更标准的四参数写法是EnsureInitialized(ref target, ref initialized, ref syncLock, valueFactory)。它的好处有两个:一是用一个bool标记代替null判断,允许目标值本身合法地为null;二是通过显式的syncLock对象控制锁粒度,多个字段可以共享同一把锁或各自独立。acquireLock参数还可以指定是否需要线程安全,单线程场景下传false可以获得接近零开销的行为。
使用时还有几点务必留意。第一,工厂方法应该尽量快且无副作用竞争,因为多个线程可能同时执行工厂方法,只是最终只保留一个结果,如果工厂内部打开了稀缺资源,多余的实例要能被垃圾回收,不要在构造函数里注册无法撤销的全局事件。第二,EnsureInitialized保证的是引用的原子发布,配合它写入的字段读取天然具备正确的内存屏障,不需要再手动加volatile。第三,这个类只适用于字段级别的延迟初始化,如果初始化逻辑依赖调用参数、需要按key分别延迟创建,那应该考虑ConcurrentDictionary的GetOrAdd,它解决的是另一类问题。
总结一下,LazyInitializer是.NET中轻量级延迟初始化的首选工具,核心记住三点:直接操作字段无包装开销、基于Interlocked保证原子发布、工厂方法可能被并发执行需要保持幂等。掌握它之后再回头看Lazy<T>的 various发布模式,就能理解两者只是同一问题在不同抽象层次上的解法。
LazyInitializer延迟初始化线程安全修改时间:2026-09-14 09:08:40