工厂方法创建对象时,缓存、异常和生命周期应该怎么设计?
简化版
工厂方法不只是 new 一下对象,还要明确是否每次新建、是否缓存复用、创建失败如何暴露、资源谁关闭、并发下同一对象是否只创建一次。对象轻量且无状态可以每次创建;昂贵资源、客户端、连接池类对象通常要交给容器、对象池或带生命周期的工厂管理。
详细版
很多面试回答只讲工厂方法封装创建,却忽略工程落地。真实项目里,创建对象经常涉及配置读取、连接外部服务、缓存复用、初始化失败、销毁回调和并发竞争。
设计时要回答几个问题:
- 产品对象是短生命周期还是长生命周期;
- 工厂返回新对象、单例对象、池化对象还是代理对象;
- 创建失败是抛业务异常、返回空对象,还是触发降级;
- 缓存 key 如何设计,是否线程安全;
- 产品持有资源时由谁关闭。
| 产品类型 | 常见策略 |
|---|---|
| 值对象 | 每次创建 |
| 无状态策略对象 | 可缓存或容器单例 |
| HTTP 客户端 | 复用并明确 close |
| 数据库连接 | 不应工厂单例,使用连接池 |
| 动态代理 | 可按接口和配置缓存 |
工厂方法模式只提供创建职责的结构,缓存、异常和生命周期是额外工程语义,必须单独设计。
完整版教学
一、工厂返回对象不等于每次都 new
工厂方法的返回语义可以很多样:每次创建新对象、返回缓存对象、返回单例对象、从对象池借出对象,甚至返回代理对象。模式本身没有规定必须 new。
create() -> new Product()
create() -> cached Product
create() -> pooled Product
create() -> proxy Product
例如 Integer.valueOf(100) 可能返回缓存对象,而解析器工厂可能每次返回一个新的解析器。面试中要主动说明:工厂封装的是创建决策,而不是固定创建动作。
二、什么时候应该缓存产品
缓存适合创建成本高、对象可复用、状态安全的产品。例如无状态策略、线程安全客户端、动态代理对象。缓存不适合保存请求级状态、用户上下文或不可复用资源。
一个数字例子:创建某个远程客户端需要 200 ms 握手,系统每分钟创建 300 次,单纯创建耗时约:
200 ms * 300 = 60000 ms = 60 s
这说明每分钟有 60 秒的累计时间浪费在重复初始化上。若客户端线程安全且配置相同,按配置 key 缓存或交给容器单例会更合理。
三、缓存 key 设计决定正确性
缓存不是简单放进 Map。key 必须包含影响产品行为的全部配置,否则不同需求会拿到错误对象。例如 HTTP 客户端若 region、timeout、credential 都影响行为,key 只用 region 就可能混用凭证。
record ClientKey(String region, int timeoutMillis, String credentialId) {}
如果 timeout=1000 和 timeout=5000 拿到同一个客户端,故障表现会非常隐蔽。面试里可以强调:缓存 key 少一维,复用就可能变成串配置。
并发下还要使用 ConcurrentHashMap.computeIfAbsent 或锁,避免同一个 key 被多个线程重复创建。
四、创建异常应该显式建模
工厂创建失败不要随意返回 null。返回 null 会把失败推迟到调用方某个位置变成 NPE,丢失上下文。更好的方式是抛出带原因的异常、返回 Result 类型,或提供明确的降级产品。
public PaymentClient create(ClientKey key) {
try {
return new PaymentClient(key);
} catch (IOException e) {
throw new ClientCreateException("create payment client failed: " + key, e);
}
}
如果业务需要降级,也应让降级产品在类型和日志上可识别。例如返回 NoopMetricsReporter 可以,但要记录告警,不能悄悄吞掉创建失败。
记忆钩子:工厂隐藏创建细节,不应该隐藏创建失败。
五、生命周期要和创建策略配套
如果工厂每次创建短生命周期对象,调用方可能负责关闭;如果工厂缓存长生命周期资源,工厂或容器就必须负责关闭。两者不能混着来。
每次 new 文件流 -> 调用方 try-with-resources 关闭
缓存 HTTP 客户端 -> 工厂/容器 shutdown 时关闭
连接池借连接 -> 调用方 close 表示归还池
假设工厂缓存了 20 个不同配置的客户端,每个客户端内部有 4 个线程。如果没有统一关闭,最多会留下 80 个后台线程。这个问题不属于工厂方法模式定义,但属于工厂方法落地必须处理的工程问题。
六、对象池不要和单例缓存混淆
数据库连接、线程、缓冲区这类资源常常需要对象池,而不是一个全局单例对象。池化资源的语义是“同类资源有多份,可借可还”,单例缓存的语义是“同一个 key 共享一份对象”。
| 方案 | 数量 | 调用方责任 | 典型对象 |
|---|---|---|---|
| 每次创建 | 多个 | 自己释放 | 值对象、命令对象 |
| 单例缓存 | 每 key 一份 | 通常不释放 | 无状态策略、代理 |
| 对象池 | 每类多份 | 使用后归还 | 数据库连接、缓冲区 |
| 容器管理 | 由 scope 决定 | 容器释放 | 服务、客户端 |
如果把数据库连接做成工厂缓存的单例,多个线程会竞争同一个连接,既不安全也不高效。正确做法通常是工厂创建连接池,业务从池中借连接。
七、并发创建要保证副作用只发生一次
如果产品创建会注册回调、启动线程或打开连接,重复创建即使最终只缓存一个对象,也可能造成副作用泄漏。普通的 get 后 put 不是原子流程。
Product old = map.get(key);
if (old == null) {
Product created = createExpensiveProduct(key);
map.put(key, created);
return created;
}
return old;
两个线程同时执行时,可能各自创建一个产品,其中一个被覆盖但线程或连接已经启动。使用原子计算、锁或容器单例可以避免副作用重复。创建失败时还要注意不要把半初始化对象放入缓存。
八、常见误区与追问
- 误区:工厂方法只是把 new 包一层。 工程上还要处理缓存、异常、生命周期、并发和资源释放。
- 误区:缓存所有工厂产品都能提升性能。 有状态对象、请求上下文和不可复用资源缓存后会产生串状态。
- 误区:创建失败返回 null 更灵活。 null 会丢失失败原因并制造延迟 NPE。
- 误区:用了 ConcurrentHashMap 就不会重复创建。 还要使用原子创建方法或外部锁,普通 get 后 put 仍有竞态。
- 追问:工厂返回的对象由谁 close? 取决于生命周期所有权;谁缓存和拥有长期资源,谁通常负责关闭。
- 追问:连接池和单例客户端怎么区分? 连接池管理多份连接,单例客户端通常是线程安全入口,内部可能持有池。
- 追问:创建失败是否要缓存失败结果? 要谨慎,短暂故障若被永久缓存会导致系统无法恢复。
九、加强记忆
工厂方法落地要从“创建动作”升级到“创建语义”:新建还是复用,失败如何表达,资源由谁释放,并发下副作用是否只发生一次。只要产品持有外部资源或创建成本高,就不能只写一个 create() 方法了事。