享元模式:对象共享的艺术
说实话,享元模式是我在实际项目中用得最多的设计模式之一。为什么?因为它解决了一个很实在的问题——内存不够用。你想想看,如果你的系统里要创建成千上万个相似的对象,每个对象都占一份内存,那服务器迟早得炸。
享元模式的核心思想就四个字:对象共享。说白了,就是把那些重复的、不变的东西提取出来,让大家共用。变化的部分呢,通过外部传入。这样既节省了内存,又提高了性能。
从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;
}
个人经验:我在做文本编辑器项目时,就用过这个模式。一篇文档可能有上万个字符,但字体样式就那么几种。如果不共享字体对象,内存消耗会非常恐怖。
享元模式的结构图
下面这张图展示了享元模式的核心结构。我画得比较简洁,重点突出内部状态和外部状态的关系:
什么时候用享元模式
我总结了几条经验,遇到这些情况可以考虑用享元模式:
- 大量相似对象:系统中有大量细粒度的对象,它们大部分状态是相同的
- 内存敏感:内存资源有限,需要优化内存使用
- 对象可分离:对象的状态可以分成内部状态和外部状态两部分
- 创建成本高:对象的创建和销毁代价较大,需要复用
避坑指南:我曾经在一个项目中过度使用享元模式,把所有对象都塞进池子里。结果发现,有些对象虽然相似,但外部状态变化频繁,每次都要传入大量参数,反而降低了性能。记住,享元模式不是万能的,它适合那些内部状态稳定、外部状态变化可控的场景。
享元模式 vs 单例模式
很多人会把享元模式和单例模式搞混。我简单说说区别:
| 对比维度 | 享元模式 | 单例模式 |
|---|---|---|
| 对象数量 | 多个,但有限 | 一个 |
| 共享方式 | 按需创建,池化管理 | 全局唯一 |
| 状态管理 | 区分内部/外部状态 | 所有状态共享 |
| 典型应用 | 线程池、连接池 | 配置管理器、日志器 |
实际项目中的思考
我在做游戏引擎时,享元模式帮了大忙。游戏里可能有成千上万的粒子、子弹、特效。每个粒子都有位置、速度、颜色等属性。但很多粒子的纹理、形状是一样的。我把这些不变的部分做成享元,每个粒子只保存自己的位置和速度。内存占用直接降了70%。
嗯,这里要注意一点:享元模式虽然节省内存,但会增加代码复杂度。你需要维护一个对象池,还要处理并发访问的问题。如果项目规模不大,或者内存不是瓶颈,没必要硬上享元模式。
我的建议:先分析你的对象,看看哪些属性是真正共享的。不要为了用模式而用模式。我见过有人把每个字符都做成享元,结果代码写得巨复杂,性能反而没提升。适可而止,才是高手。