← 返回题目列表

什么是注册表式单例?它和普通单例有什么区别?

高频 中等 第 7 / 26 题 更新于 2026/08/01
单例模式注册表容器多例管理

简化版

注册表式单例是把多个共享实例放进统一注册表,通过 key 获取对象,而不是每个类都写一个静态 INSTANCE。它适合管理一组同类资源或按名称选择实现,但要注意线程安全、生命周期、key 冲突和是否退化成全局变量仓库。

详细版

普通单例通常是“一个类维护自己唯一的实例”,注册表式单例则是“一个注册表维护多个对象的唯一实例”。例如系统里有多个导出器:pdfexcelcsv,可以用注册表按类型返回共享实例。

注册表式单例常见于插件管理、策略集合、对象池入口、Spring Bean 容器等场景。它的优势是集中管理和动态查找,缺点是依赖关系容易隐藏,key 的命名、并发创建和销毁顺序都需要设计。

一个基本实现要考虑:

  1. 注册表自身如何创建;
  2. 实例是否懒加载;
  3. 并发下同一个 key 是否只创建一次;
  4. 对象何时关闭和移除;
  5. 调用方是否应该直接访问注册表,还是通过依赖注入获得所需对象。

完整版教学

一、注册表式单例解决的不是一个对象,而是一组对象

传统单例围绕某一个类展开,例如 ConfigManager 只有一个实例。注册表式单例把问题扩大为:系统里有一组对象,每个 key 对应一个共享实例,并由统一入口负责查找和复用。

示意结构:

Registry
  ├── "pdf"   -> PdfExporter 实例
  ├── "excel" -> ExcelExporter 实例
  └── "csv"   -> CsvExporter 实例

这里的“唯一”不再是每个类自己维护,而是注册表按照 key 维护。面试中要说清楚:注册表是管理实例的容器,普通单例是类自身管理实例。

二、一个最小实现长什么样

注册表可以用 ConcurrentHashMap 管理实例,并用 computeIfAbsent 保证同一个 key 的创建逻辑只执行一次。下面是简化写法:

public final class ExporterRegistry {
    private static final ExporterRegistry INSTANCE = new ExporterRegistry();
    private final ConcurrentHashMap<String, Exporter> exporters = new ConcurrentHashMap<>();

    private ExporterRegistry() {}

    public static ExporterRegistry getInstance() {
        return INSTANCE;
    }

    public Exporter get(String type) {
        return exporters.computeIfAbsent(type, this::createExporter);
    }

    private Exporter createExporter(String type) {
        return switch (type) {
            case "pdf" -> new PdfExporter();
            case "excel" -> new ExcelExporter();
            default -> throw new IllegalArgumentException(type);
        };
    }
}

如果 100 个线程同时请求 "pdf",正确目标是只创建 1 个 PdfExporter。若用普通 HashMap 加先查后放,在并发下可能创建多次,甚至破坏 Map 结构。

三、它和简单工厂、对象池有什么区别

注册表、简单工厂和对象池经常被混在一起。简单工厂关注“根据条件创建哪个类”,不一定缓存实例;对象池关注“复用多个昂贵对象”,同一个类型可能同时存在很多个;注册表式单例关注“按 key 共享同一个对象身份”。

模式/结构核心关注是否通常缓存实例数量
简单工厂创建分支封装不一定每次可新建
注册表式单例key 到共享实例每个 key 一份
对象池借出和归还资源每类多份
DI 容器依赖装配和生命周期由 scope 决定

例如数据库连接不适合注册表式单例成一个连接,因为高并发需要连接池;而导出策略对象若无状态,每种格式共享一份就可以。

四、并发创建为什么是核心难点

注册表最大的问题是“查找”和“创建”不是天然原子操作。下面这种写法在单线程下看起来没问题,但在并发下会重复创建:

Exporter e = map.get(type);
if (e == null) {
    e = createExporter(type);
    map.put(type, e);
}
return e;

假设 T1 和 T2 同时查 "pdf",都看到 null,就可能各自创建一个 PdfExporter。最终 Map 里也许只留下最后 put 的对象,但创建副作用已经执行了 2 次。如果创建过程打开文件、注册回调或连接外部服务,重复副作用就会变成真实故障。

使用 computeIfAbsent、显式锁或容器托管可以把“同一个 key 的首次创建”收敛到受控临界区。面试中不要只说用 Map,要主动补上并发语义。

五、生命周期和资源释放不能被忽略

注册表式单例很容易变成长生命周期对象仓库。对象一旦放进去,如果没有移除和关闭规则,就可能长期占用线程、文件句柄、网络连接或大块缓存。

一个带数字的例子:假设每个租户实例持有 20 MB 缓存,系统动态接入 500 个租户,如果注册表只增不删,最多会占用约 20 MB * 500 = 10000 MB,也就是接近 10 GB 内存。这不是模式本身的问题,而是生命周期设计缺失。

register -> use -> refresh -> close -> remove

注册表应明确对象是否可淘汰、谁负责关闭、异常创建是否缓存失败结果、key 是否允许覆盖。没有这些规则,注册表会比普通单例更难维护。

六、与 Spring 容器的关系

Spring 容器本质上也有注册表能力:它用 BeanDefinition 描述对象创建规则,用 Bean 名称和类型进行查找,并根据 scope 决定是否缓存实例。默认 singleton Bean 可以理解为容器管理的“每个 Bean 定义一份”。

区别在于,成熟容器还提供依赖注入、生命周期回调、代理、条件装配和销毁管理。自己手写注册表只适合很小的插件点或框架底层;普通业务项目不应重复造一个全局 ServiceLocator。

记忆钩子:注册表式单例不是“更大的 static Map”,而是一个必须设计 key、并发、生命周期和依赖边界的实例管理器。

七、何时适合,何时该避免

适合使用注册表式单例的场景:

  1. 实例按固定 key 区分,数量有限;
  2. 对象无状态或状态受控;
  3. 创建成本较高,但每个 key 共享一份即可;
  4. 需要插件式扩展,但还没有完整容器需求。

不适合的场景包括请求上下文、用户会话、大量租户动态对象、需要复杂销毁顺序的资源,以及本来可以通过构造器注入解决的业务依赖。

如果注册表被任何代码随意读写,它就会退化成全局变量仓库。高质量实现通常会限制注册入口,把 key 定义成枚举或常量,并为测试提供清理或替换能力。

八、常见误区与追问

  • 误区:注册表式单例就是一个 static HashMap。 真正要点是并发创建、生命周期、key 约束和依赖边界。
  • 误区:用 ConcurrentHashMap 就一定不会重复创建。 还要看创建逻辑是否放在原子方法中,普通 get 后 put 仍可能重复。
  • 误区:注册表可以保存任何全局对象。 请求级、用户级和大量动态资源放进去会造成状态污染和内存泄漏。
  • 误区:注册表和工厂模式完全一样。 工厂关注创建,注册表还关注缓存和共享身份。
  • 追问:注册表式单例和 Spring 容器有什么关系? Spring 是更完整的注册表和生命周期管理容器,业务系统优先用容器能力。
  • 追问:key 用字符串有什么风险? 拼写错误、冲突和重命名困难,可用枚举、常量或类型安全 key 缓解。
  • 追问:失败创建要不要缓存? 通常要谨慎,缓存失败可能导致后续无法恢复;应结合重试、熔断和清理策略。

九、加强记忆

注册表式单例的答题主线是“一个入口管理多份按 key 唯一的共享实例”。讲清它区别于普通单例、简单工厂和对象池,再补上并发创建、生命周期释放、key 设计和容器替代方案,答案就不会停留在 static Map 的层面。