什么是注册表式单例?它和普通单例有什么区别?
简化版
注册表式单例是把多个共享实例放进统一注册表,通过 key 获取对象,而不是每个类都写一个静态 INSTANCE。它适合管理一组同类资源或按名称选择实现,但要注意线程安全、生命周期、key 冲突和是否退化成全局变量仓库。
详细版
普通单例通常是“一个类维护自己唯一的实例”,注册表式单例则是“一个注册表维护多个对象的唯一实例”。例如系统里有多个导出器:pdf、excel、csv,可以用注册表按类型返回共享实例。
注册表式单例常见于插件管理、策略集合、对象池入口、Spring Bean 容器等场景。它的优势是集中管理和动态查找,缺点是依赖关系容易隐藏,key 的命名、并发创建和销毁顺序都需要设计。
一个基本实现要考虑:
- 注册表自身如何创建;
- 实例是否懒加载;
- 并发下同一个 key 是否只创建一次;
- 对象何时关闭和移除;
- 调用方是否应该直接访问注册表,还是通过依赖注入获得所需对象。
完整版教学
一、注册表式单例解决的不是一个对象,而是一组对象
传统单例围绕某一个类展开,例如 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、并发、生命周期和依赖边界的实例管理器。
七、何时适合,何时该避免
适合使用注册表式单例的场景:
- 实例按固定 key 区分,数量有限;
- 对象无状态或状态受控;
- 创建成本较高,但每个 key 共享一份即可;
- 需要插件式扩展,但还没有完整容器需求。
不适合的场景包括请求上下文、用户会话、大量租户动态对象、需要复杂销毁顺序的资源,以及本来可以通过构造器注入解决的业务依赖。
如果注册表被任何代码随意读写,它就会退化成全局变量仓库。高质量实现通常会限制注册入口,把 key 定义成枚举或常量,并为测试提供清理或替换能力。
八、常见误区与追问
- 误区:注册表式单例就是一个 static HashMap。 真正要点是并发创建、生命周期、key 约束和依赖边界。
- 误区:用 ConcurrentHashMap 就一定不会重复创建。 还要看创建逻辑是否放在原子方法中,普通 get 后 put 仍可能重复。
- 误区:注册表可以保存任何全局对象。 请求级、用户级和大量动态资源放进去会造成状态污染和内存泄漏。
- 误区:注册表和工厂模式完全一样。 工厂关注创建,注册表还关注缓存和共享身份。
- 追问:注册表式单例和 Spring 容器有什么关系? Spring 是更完整的注册表和生命周期管理容器,业务系统优先用容器能力。
- 追问:key 用字符串有什么风险? 拼写错误、冲突和重命名困难,可用枚举、常量或类型安全 key 缓解。
- 追问:失败创建要不要缓存? 通常要谨慎,缓存失败可能导致后续无法恢复;应结合重试、熔断和清理策略。
九、加强记忆
注册表式单例的答题主线是“一个入口管理多份按 key 唯一的共享实例”。讲清它区别于普通单例、简单工厂和对象池,再补上并发创建、生命周期释放、key 设计和容器替代方案,答案就不会停留在 static Map 的层面。