享元模式(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 请求都做成享元,结果外部状态比内部状态还大,得不偿失。

知识体系结构图

下面这张图展示了享元模式的核心结构,以及它与连接池、字符串常量池的关系。

享元模式核心结构 客户端 享元工厂 Flyweight接口 请求 返回 单纯享元 内部状态: 固定 复合享元 包含多个单纯享元 现实案例 连接池 数据库/线程/HTTP连接复用 字符串常量池 Java/C++ 字面量共享 对象缓存池 Integer缓存等

总结

享元模式的核心就三句话:

  • 分离内部状态和外部状态
  • 用工厂管理共享对象
  • 单纯享元 vs 复合享元,按需选择

实际开发中,连接池和字符串常量池是最常见的应用。我个人建议,只要发现系统中有大量相似对象,先想想能不能用享元。但别过度设计——如果对象数量不大,或者状态变化频繁,硬套享元反而增加维护成本。

一句话记住:享元模式就是「共享不变,传入变化」。把不变的部分缓存起来,变化的部分交给客户端。

公众号:蓝海资料掘金营,微信deep3321