面试题2:实现Singleton模式

比较无锁、互斥、双重检查、静态字段与嵌套类型五种实现,理解延迟、并发和作用域边界。

从“只能生成一个实例”开始

先预测:把构造函数改成private,是否已经保证全程序只有一个对象?没有。私有构造只阻止类外部直接new;类内部仍可创建任意多个实例,反射、序列化或不同运行时加载作用域也可能改变边界。完整设计还要回答唯一引用存在哪里、谁负责初始化、并发访问如何同步,以及“一次”究竟限定在哪个作用域。

通常由三部分组成:私有构造函数阻止普通外部创建;静态字段保存唯一引用;公共静态属性返回该引用。作者源码用C#展示五个版本,重点不是背出类名,而是比较正确性、初始化时机和访问成本。

Singleton回答三个问题:谁能创建、存在哪里、作用域多大sealed Singleton私有构造函数阻止外部new静态instance保存唯一引用公共Instance提供受控入口调用者 A / B / C都经由同一个访问点作用域边界通常是当前进程 / 加载域不是机器或集群全局唯一也不自动保证字段线程安全创建一次 ≠ 状态并发安全 ≠ 分布式唯一 ≠ 适合所有全局服务
先定义唯一性的作用域和生命周期,再选择初始化机制。

sealed还能阻止通过派生类间接创建其他实例。它不是Singleton成立的全部条件,却能让“谁能构造”更明确。若类型允许继承,派生类构造必须调用基类构造,私有构造会直接阻断;把类声明为sealed则进一步表达设计意图。

版本一:无锁延迟创建

第一种实现只有在Instance首次被读取时才创建对象,这叫:

public sealed class Singleton1
{
    private Singleton1() { }
 
    private static Singleton1? instance;
 
    public static Singleton1 Instance
    {
        get
        {
            if (instance is null)
                instance = new Singleton1();
            return instance;
        }
    }
}

单线程里它看起来正确;并发下,“检查为空”和“创建并写入”是多个步骤。线程A和B都可能先看到null,然后各自执行构造。即使最后静态字段只保留其中一个引用,另一个实例也已经存在,构造副作用甚至可能已经发布到外部。

无锁的“先检查再创建”不是原子操作线程 A线程 B读取 instance == null读取 instance == null暂停 / 被调度new Singleton() → Bnew Singleton() → A写入 instance = B写入 instance = A返回 BA与B都曾被创建并可能逸出:唯一实例约束已经失败。
检查与创建必须由锁、类型初始化或经过验证的延迟容器统一保护。

这不是“概率很低所以可接受”的性能选择,而是违反题目唯一性要求。测试只在一个线程连续读取两次Instance,无法证明并发正确;需要让多个线程同时越过检查点,或直接依据内存模型和同步规则判断。

版本二:每次访问都加锁

第二种实现用同一个包住检查和创建:

public sealed class Singleton2
{
    private Singleton2() { }
 
    private static readonly object Sync = new();
    private static Singleton2? instance;
 
    public static Singleton2 Instance
    {
        get
        {
            lock (Sync)
            {
                if (instance is null)
                    instance = new Singleton2();
                return instance;
            }
        }
    }
}

lock把“检查、创建、发布”变成一个临界区,也建立线程间可见性,语义直接且容易审查。代价是实例已经创建后,每次读取仍要进入同一把锁。很多应用中这点成本不重要,正确和清晰优先;只有证据表明访问处于热点,才值得讨论更复杂方案。

锁对象必须是类型私有且只用于这条同步协议。锁住this、公开对象或字符串,会允许外部代码参与同一把锁,增加死锁和不可预测等待风险。readonly确保锁对象引用不会被替换。

版本三:双重检查锁定

作者第三种实现先在锁外检查一次,只有看到未初始化才进入锁,并在锁内再检查一次。这叫:

public static Singleton3 Instance
{
    get
    {
        if (instance is null)
        {
            lock (Sync)
            {
                if (instance is null)
                    instance = new Singleton3();
            }
        }
        return instance;
    }
}

第二次检查不能省略:线程A和B都可能通过第一次检查,A先持锁完成创建,B随后拿到锁;若B不再次检查,就会再构造一次。这个结构减少实例建成后的锁竞争,但正确性依赖语言和运行时的内存模型、字段发布语义及具体写法。

在现代C#中,与其手写并争论发布细节,通常优先使用运行时类型初始化、Lazy<T>或依赖注入容器提供的单例生命周期。跨语言照搬双重检查尤其危险:Java、C++和C#的内存模型与安全惯用法并不完全相同,代码形状相似不代表保证相同。

版本四:静态字段立即初始化

第四种直接在静态字段声明处构造实例。C#运行时保证一个类型的静态初始化只执行一次,因此无需自己加锁:

public sealed class Singleton4
{
    private Singleton4()
    {
        Console.WriteLine("Singleton4 created");
    }
 
    private static readonly Singleton4 instance = new();
 
    public static Singleton4 Instance => instance;
 
    public static void Print() =>
        Console.WriteLine("Singleton4 Print");
}

替代了手写锁。实现短、线程安全,适合构造成本低或程序很可能使用实例的情况。但初始化时机可能早于首次读取Instance:作者测试调用Singleton4.Print(),也会看到构造日志。也就是说,访问类型的其他静态成员可能触发实例创建。

“急切”不等于程序启动瞬间一定创建,具体触发受C#类型初始化规则影响;本题最关键的可观察差异是:外层类型普通静态方法的调用,会让该类型的静态字段进入初始化范围,而不必等到Instance属性被读取。

版本五:嵌套类型延迟初始化

第五种把实例字段放进嵌套类型Nested。读取外层Singleton5.Instance时才触及Nested.instance;调用外层Singleton5.Print()不会初始化嵌套类型,因此既依赖运行时获得线程安全,又保留延迟创建:

public sealed class Singleton5
{
    private Singleton5()
    {
        Console.WriteLine("Singleton5 created");
    }
 
    public static Singleton5 Instance => Nested.instance;
 
    public static void Print() =>
        Console.WriteLine("Singleton5 Print");
 
    private static class Nested
    {
        internal static readonly Singleton5 instance = new();
    }
}

作者原代码给Nested写了显式空静态构造函数,用来控制类型初始化语义;现代写法也常用静态嵌套类承载只读实例。面试中要能解释“为什么调用外层Print不构造实例”,而不是只背类模板。

现代Lazy<T>与依赖注入

标准库Lazy<T>直接表达“线程安全地在首次读取Value时构造”,减少手写同步代码:

public sealed class AppClock
{
    private AppClock() { }
 
    private static readonly Lazy<AppClock> lazy =
        new(() => new AppClock(), isThreadSafe: true);
 
    public static AppClock Instance => lazy.Value;
}

还要明确异常策略:不同LazyThreadSafetyMode对并发和构造异常缓存行为有不同约定。若工厂抛异常,调用方是否重试、持续看到同一异常,或需要显式恢复,都应按业务需求设计,不能因为用了Lazy<T>就忽略失败路径。

更常见的服务应用通过依赖注入容器注册“单例生命周期”,再把接口注入消费者。这样唯一实例仍由组合根管理,但业务类不需要调用隐藏的全局静态入口;测试可以替换实现,生命周期和释放也更集中。模式的核心需求与具体全局访问写法应分开。

唯一实例不等于所有问题都解决

Singleton只约束构造与访问,不自动让实例内部的可变字段线程安全。若多个线程拿到同一个对象并修改字典、计数器或连接状态,仍要设计锁、不可变快照、原子操作或消息串行化。创建一次和安全共享是两件事。

它通常也只在当前进程或相应类型加载作用域内唯一。启动四个服务进程,会得到至少四个实例;容器横向扩容后每个副本也各有一个。需要集群唯一任务时,应使用数据库唯一约束、租约、分布式锁或协调系统,并考虑故障、超时和脑裂,不能把静态字段当成分布式协议。

数据库连接更不适合被理解成“全程序只留一个连接”。并发请求会被单连接串行,断线影响所有调用,事务状态也容易互相污染。正确抽象通常是连接池:可以有一个池管理器,但池内维护多个受限连接,借出和归还遵守生命周期。

隐藏全局状态还会影响测试。一个测试修改Singleton,另一个测试可能继承残留状态;并行测试互相干扰,替换依赖困难。若对象有外部资源,何时释放也变得不清楚。使用Singleton之前,应先问是否只是想要“方便访问”;如果是,显式传递接口通常更可维护。

如何验证五种实现

测试首先验证引用身份:多次读取Instance应满足ReferenceEquals。并发测试用屏障让多个线程同时开始读取,记录构造次数和返回引用集合;只检查最终字段不够,还要确认构造函数实际只执行一次。

初始化时机测试应先调用普通静态方法。对Singleton4,作者示例会打印构造日志;对Singleton5,只调用外层Print不会创建实例,直到读取Instance。这组对照正是嵌套类型方案存在的理由。

[Fact]
public void Instance_is_shared_by_all_callers()
{
    var results = Enumerable.Range(0, 64)
        .AsParallel()
        .Select(_ => Singleton5.Instance)
        .ToArray();
 
    Assert.All(results, item =>
        Assert.Same(results[0], item));
}
 
[Fact]
public void Outer_static_method_does_not_touch_nested_instance()
{
    Singleton5.Print();
    // 构造计数仍为0;首次读取Instance后变为1。
}

并发测试本身不能证明所有交错都安全,但可以验证实现和回归;正确性依据仍来自运行时保证与同步协议。测试若依赖静态类型只能初始化一次,还要隔离进程或加载上下文,否则先运行的测试会污染“首次访问”条件。

本章回顾

  1. Singleton模式由私有构造、唯一静态引用和公共访问点构成,sealed表达不可派生。
  2. 无锁的检查后创建在并发下可能构造多个实例。
  3. 每次加锁实现直接正确,但实例建成后仍承担同步访问成本。
  4. 双重检查需要锁内第二次检查,且正确性依赖具体语言内存模型。
  5. 静态字段利用运行时类型初始化保证一次创建,但其他静态成员可能提前触发。
  6. 嵌套类型把实例初始化推迟到首次读取Nested.instance,兼顾延迟和线程安全。
  7. Lazy<T>和依赖注入容器通常比手写全局同步更清晰。
  8. Singleton不保证内部状态线程安全,也不保证多进程或集群全局唯一。
  9. 连接池、外部协调和显式依赖各自解决不同问题,不能由一个静态对象替代。

名词解释

讨论

评论区加载中…