享元模式(Flyweight):内部状态与外部状态、享元工厂、单纯享元与复合享元,以及连接池与字符串常量池
享元模式,说白了就是「共享对象」。你想想看,系统里如果有一万个相似的对象,每个都单独创建,内存肯定吃不消。我早年做游戏服务器时,就遇到过这种尴尬——每个玩家角色都带一套装备图标对象,结果内存直接爆了。后来用享元模式重构,内存占用降了70%。
这个模式的核心思想是:把对象的状态分成内部状态和外部状态。内部状态是共享的,不会变;外部状态是变化的,由客户端传入。这样,相同的内部状态就可以复用同一个对象。
内部状态与外部状态
这是享元模式最基础的概念。我习惯这样区分:
- 内部状态:存储在享元对象内部,不会随环境改变。比如字符的字体、颜色、大小。
- 外部状态:由客户端维护,随上下文变化。比如字符在屏幕上的位置(x, y)。
举个例子。一个文本编辑器里,有成千上万个字符「A」。如果每个字符都存一份字体、字号、颜色,太浪费了。实际上,这些属性可以共享。每个字符对象只存一个索引,指向共享的字体属性。而字符的位置、是否选中,这些是外部状态,由编辑器动态传入。
关键点:内部状态决定了对象能否被共享。设计时,要把不变的部分剥离出来,作为内部状态。
享元工厂
享元工厂负责创建和管理享元对象。它通常有一个池子(比如 HashMap),存储已创建的享元。客户端请求时,先查池子,有就直接返回,没有才创建。
我曾经在一个项目中,用享元工厂管理数据库连接配置。每个数据库地址对应一个配置对象,工厂保证相同地址只创建一个实例。这样,上千个线程共享同一个配置,省了不少内存。
// Java 示例:享元工厂
public class FlyweightFactory {
private Map<String, Flyweight> pool = new HashMap<>();
public Flyweight getFlyweight(String key) {
Flyweight f = pool.get(key);
if (f == null) {
f = new ConcreteFlyweight(key); // 内部状态
pool.put(key, f);
}
return f;
}
}
// C++ 示例:享元工厂
class FlyweightFactory {
std::unordered_map<std::string, std::shared_ptr<Flyweight>> pool;
public:
std::shared_ptr<Flyweight> getFlyweight(const std::string& key) {
auto it = pool.find(key);
if (it != pool.end()) return it->second;
auto f = std::make_shared<ConcreteFlyweight>(key);
pool[key] = f;
return f;
}
};
避坑指南:我曾经在工厂里忘记加线程安全控制,结果高并发下同一个 key 创建了多个对象。后来加了 ConcurrentHashMap 或双重检查锁,才搞定。
单纯享元与复合享元
单纯享元就是上面那种——一个享元对象对应一个内部状态。复合享元则是由多个单纯享元组合而成。比如一个段落由多个字符组成,每个字符是单纯享元,段落本身是复合享元。
复合享元通常用组合模式实现。它内部维护一个 List 或 Map,存储多个单纯享元。客户端调用时,复合享元会把请求委托给内部的每个单纯享元。
// 复合享元示例
public class CompositeFlyweight implements Flyweight {
private List<Flyweight> children = new ArrayList<>();
public void add(Flyweight f) { children.add(f); }
@Override
public void operation(String externalState) {
for (Flyweight f : children) {
f.operation(externalState);
}
}
}
嗯,这里要注意:复合享元本身不存储内部状态,它只是容器。内部状态仍然由子享元持有。
连接池与字符串常量池
这两个是享元模式最经典的现实案例。
连接池:数据库连接、线程池、HTTP 连接池,都是享元模式。连接对象是享元,内部状态是连接配置(URL、用户名、密码),外部状态是当前是否被占用。池子就是享元工厂。
我记得有一次,项目上线后数据库连接数飙升到 5000,DBA 差点找我喝茶。后来用连接池限制到 50 个,性能反而提升了。为什么?因为创建连接的开销太大了,复用才是王道。
字符串常量池:Java 和 C++ 里都有。Java 中,字符串字面量会放入常量池,相同的字符串只存一份。比如:
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2); // true,指向同一个对象
C++ 中,字符串字面量也是共享的,但要注意 std::string 是对象,不共享。不过现代 C++ 的 Small String Optimization 也算一种享元思想。
警告:不要滥用享元模式。如果内部状态太多,或者外部状态变化频繁,享元模式反而会增加复杂度。我见过有人把每个 HTTP 请求都做成享元,结果外部状态比内部状态还大,得不偿失。
知识体系结构图
下面这张图展示了享元模式的核心结构,以及它与连接池、字符串常量池的关系。
总结
享元模式的核心就三句话:
- 分离内部状态和外部状态
- 用工厂管理共享对象
- 单纯享元 vs 复合享元,按需选择
实际开发中,连接池和字符串常量池是最常见的应用。我个人建议,只要发现系统中有大量相似对象,先想想能不能用享元。但别过度设计——如果对象数量不大,或者状态变化频繁,硬套享元反而增加维护成本。
一句话记住:享元模式就是「共享不变,传入变化」。把不变的部分缓存起来,变化的部分交给客户端。