享元模式:对象共享的艺术

说实话,享元模式是我在实际项目中用得最多的设计模式之一。为什么?因为它解决了一个很实在的问题——内存不够用。你想想看,如果你的系统里要创建成千上万个相似的对象,每个对象都占一份内存,那服务器迟早得炸。

享元模式的核心思想就四个字:对象共享。说白了,就是把那些重复的、不变的东西提取出来,让大家共用。变化的部分呢,通过外部传入。这样既节省了内存,又提高了性能。

从Integer缓存说起

Java里有个经典例子,就是Integer的缓存机制。我刚开始学Java时就被这个坑过。来看段代码:

Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true

Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false

为什么会这样?因为Integer默认缓存了-128到127之间的值。在这个范围内,每次取的都是同一个对象。超出这个范围,就会创建新对象。这就是享元模式在JDK里的应用。

核心要点:享元模式区分了内部状态和外部状态。内部状态是共享的、不变的;外部状态是变化的、由客户端传入的。

线程池中的享元

线程池也是享元模式的典型应用。我记得有一次优化一个高并发系统,发现频繁创建和销毁线程导致CPU飙升。后来改用线程池,问题就解决了。

线程池里维护着一组工作线程,这些线程就是享元对象。任务来了,从池子里取一个线程去执行;执行完了,线程不销毁,放回池子里继续用。避免了反复创建线程的开销。

// Java线程池示例
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
    executor.submit(() -> {
        System.out.println("任务执行中...");
    });
}
executor.shutdown();

这里10个线程被复用了100次。如果不用享元模式,就得创建100个线程,内存和性能都会出问题。

C++中的享元模式实现

在C++里实现享元模式,我习惯用工厂类来管理共享对象。来看一个完整的例子:

#include <iostream>
#include <map>
#include <memory>
#include <string>

// 享元对象 - 字体
class Font {
private:
    std::string name;
    int size;
    std::string style;
    
public:
    Font(const std::string& n, int s, const std::string& st) 
        : name(n), size(s), style(st) {}
    
    void render(const std::string& text, int x, int y) {
        std::cout << "渲染文字: " << text 
                  << " 字体: " << name 
                  << " 大小: " << size 
                  << " 样式: " << style
                  << " 位置: (" << x << ", " << y << ")" << std::endl;
    }
};

// 享元工厂
class FontFactory {
private:
    std::map<std::string, std::shared_ptr<Font>> fontPool;
    
public:
    std::shared_ptr<Font> getFont(const std::string& name, 
                                   int size, 
                                   const std::string& style) {
        // 生成唯一键
        std::string key = name + "_" + std::to_string(size) + "_" + style;
        
        // 查找是否已存在
        auto it = fontPool.find(key);
        if (it != fontPool.end()) {
            std::cout << "复用已有字体对象: " << key << std::endl;
            return it->second;
        }
        
        // 创建新字体对象
        std::cout << "创建新字体对象: " << key << std::endl;
        auto font = std::make_shared<Font>(name, size, style);
        fontPool[key] = font;
        return font;
    }
    
    size_t getPoolSize() const {
        return fontPool.size();
    }
};

// 客户端使用
int main() {
    FontFactory factory;
    
    // 创建字体对象
    auto font1 = factory.getFont("Arial", 12, "Bold");
    auto font2 = factory.getFont("Arial", 12, "Bold");  // 复用
    auto font3 = factory.getFont("Times New Roman", 14, "Italic");
    
    // 渲染文字 - 外部状态(位置)由客户端传入
    font1->render("Hello", 100, 200);
    font2->render("World", 300, 400);
    font3->render("Flyweight", 500, 600);
    
    std::cout << "池中对象数量: " << factory.getPoolSize() << std::endl;
    
    return 0;
}

个人经验:我在做文本编辑器项目时,就用过这个模式。一篇文档可能有上万个字符,但字体样式就那么几种。如果不共享字体对象,内存消耗会非常恐怖。

享元模式的结构图

下面这张图展示了享元模式的核心结构。我画得比较简洁,重点突出内部状态和外部状态的关系:

享元模式结构图 客户端 享元工厂 享元接口 请求享元 返回享元 具体享元 内部状态(共享) 非共享享元 外部状态(传入) 享元对象池 对象A 对象B 对象C 对象D 管理

什么时候用享元模式

我总结了几条经验,遇到这些情况可以考虑用享元模式:

  • 大量相似对象:系统中有大量细粒度的对象,它们大部分状态是相同的
  • 内存敏感:内存资源有限,需要优化内存使用
  • 对象可分离:对象的状态可以分成内部状态和外部状态两部分
  • 创建成本高:对象的创建和销毁代价较大,需要复用

避坑指南:我曾经在一个项目中过度使用享元模式,把所有对象都塞进池子里。结果发现,有些对象虽然相似,但外部状态变化频繁,每次都要传入大量参数,反而降低了性能。记住,享元模式不是万能的,它适合那些内部状态稳定、外部状态变化可控的场景。

享元模式 vs 单例模式

很多人会把享元模式和单例模式搞混。我简单说说区别:

对比维度 享元模式 单例模式
对象数量 多个,但有限 一个
共享方式 按需创建,池化管理 全局唯一
状态管理 区分内部/外部状态 所有状态共享
典型应用 线程池、连接池 配置管理器、日志器

实际项目中的思考

我在做游戏引擎时,享元模式帮了大忙。游戏里可能有成千上万的粒子、子弹、特效。每个粒子都有位置、速度、颜色等属性。但很多粒子的纹理、形状是一样的。我把这些不变的部分做成享元,每个粒子只保存自己的位置和速度。内存占用直接降了70%。

嗯,这里要注意一点:享元模式虽然节省内存,但会增加代码复杂度。你需要维护一个对象池,还要处理并发访问的问题。如果项目规模不大,或者内存不是瓶颈,没必要硬上享元模式。

我的建议:先分析你的对象,看看哪些属性是真正共享的。不要为了用模式而用模式。我见过有人把每个字符都做成享元,结果代码写得巨复杂,性能反而没提升。适可而止,才是高手。


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