享元工厂为什么重要?对象池和缓存应该怎么设计?
简化版
享元工厂负责统一创建、缓存和复用享元对象,是享元模式能否生效的关键。它通常用 Map 根据内部状态 key 管理对象池,并需要考虑 key 设计、线程安全、缓存大小和生命周期。
详细版
如果客户端可以随意 new 享元对象,共享就无法保证。因此享元模式通常需要一个工厂:
- 根据内部状态生成唯一 key。
- 查询对象池是否已有实例。
- 已有则返回。
- 没有则创建、缓存并返回。
设计享元工厂时要注意:key 必须能准确代表内部状态;缓存不能无限膨胀;多线程场景要使用线程安全容器或同步控制;享元对象最好不可变。
完整版教学
一、为什么需要享元工厂
享元模式的核心是共享。如果对象创建入口散落在各处,系统很难保证同样内部状态只创建一份。
享元工厂把创建入口集中起来:
Flyweight fw = factory.get(key);
客户端不直接关心对象是否已经存在,只向工厂请求。工厂决定返回已有对象还是创建新对象。
二、对象池的基本结构
最常见的对象池是一个 Map:
class FlyweightFactory {
private final Map<String, Flyweight> pool = new HashMap<>();
public Flyweight get(String key) {
return pool.computeIfAbsent(key, ConcreteFlyweight::new);
}
}
key 代表内部状态。相同 key 返回同一个享元对象。
三、key 设计很关键
key 必须完整表达内部状态。
例如树类型由树种、颜色、纹理决定,key 就不能只用树种。如果只用树种,绿色松树和黄色松树可能被错误复用。
常见 key 设计方式:
- 简单字符串拼接,适合字段少、格式稳定的场景。
- 使用不可变值对象作为 key,适合字段多的场景。
- 使用枚举,适合内部状态集合固定的场景。
值对象作为 key 时,要正确实现 equals() 和 hashCode()。
四、线程安全怎么处理
如果多个线程同时访问享元工厂,普通 HashMap 可能出现并发问题。
可以使用 ConcurrentHashMap:
class FontFactory {
private final ConcurrentHashMap<FontKey, FontStyle> pool = new ConcurrentHashMap<>();
public FontStyle get(FontKey key) {
return pool.computeIfAbsent(key, FontStyle::new);
}
}
computeIfAbsent 能减少重复创建风险。需要注意的是,创建函数本身不应该有复杂副作用,否则并发下排查问题会变麻烦。
五、缓存大小和生命周期
享元池不是越大越好。如果 key 种类无限增长,享元池可能变成内存泄漏源。
比如以用户输入的任意字符串作为 key,如果字符串种类非常多,缓存会持续膨胀。
可以考虑:
- 限制享元对象种类。
- 使用 LRU 缓存。
- 使用弱引用或软引用,但要理解回收时机不稳定。
- 定期清理长时间不用的享元。
- 只对高频重复对象使用享元。
六、享元对象是否允许修改
享元对象最好不可变。如果享元对象允许修改内部状态,所有共享它的客户端都会受到影响。
例如共享字体样式对象,如果某个地方修改了字号,其他所有使用该样式的字符都会被影响。这种问题非常隐蔽。
因此享元对象的字段通常用 final,并避免暴露可变集合。
七、工厂和业务逻辑的边界
享元工厂只负责对象复用,不应该承载复杂业务流程。
例如通知模板享元工厂可以根据模板 ID 返回模板对象,但不应该在工厂里判断用户权限、发送频率、业务状态。那些逻辑属于应用服务或业务对象。
工厂越纯粹,享元池越容易测试和维护。
八、常见误区与追问
享元工厂不仅保存对象,还必须保证同一键映射到同一语义实例。并发线程同时请求 key=A 时,如果用普通 get 后 put,可能短暂创建2个对象;computeIfAbsent 可让映射创建原子化,但映射函数本身仍应轻量且无递归更新。键还必须包含全部内部状态维度。
| 检查维度 | 判定依据 |
|---|---|
| 缓存命中 | 返回已有不可变享元 |
| 缓存未命中 | 按完整键创建并安全发布 |
key -> Factory Map -> shared Flyweight
易错点:唯一性是“按语义键唯一”,不是整个系统永远只有一个享元。
- 误区:享元工厂必须永久保存所有实例。 键空间无界时需要容量限制、弱引用或淘汰策略。
- 追问:为什么键不能只用显示名称? 若字体大小也属于内部状态,漏进键会返回语义错误的共享对象。
- 误区:ConcurrentHashMap 能让享元内部线程安全。 它只保护映射访问,共享对象自身仍应不可变或同步。
- 追问:computeIfAbsent 会执行几次创建函数? 并发实现以映射结果原子性为目标,函数不应依赖可观察副作用。
- 追问:如何测试工厂? 相同键断言同一引用,不同内部状态键断言语义不同,并做并发请求测试。
九、加强记忆
享元工厂是“共享入口”:它用 key 找内部状态,用 Map 管对象池,用线程安全和生命周期策略保证共享可控。没有统一工厂,享元模式很容易退化成各处随意创建对象。