1. 场景先行一次字符渲染引发的内存危机前阵子接手了一个文本编辑器项目功能不复杂支持超长文档的渲染、滚动、搜索高亮。第一版实现很快无非就是每个字符保存字体、颜色、大小、位置信息然后在界面上逐个绘制。结果一测试就露馅了——打开一份几百页的文档内存直接飙升到几百MB甚至上GB滚动起来卡顿明显调试器里一看密密麻麻全是字符对象。问题的根源很直接文档里大量字符其实共享着相同的格式信息。比如一段正文几百上千个字符都是同一个字体、同一个字号、同一种颜色但第一版实现里每个字符都独立保存了一份完完整整的格式数据。重复创建的相似对象把内存吃干净了这就是典型的“共享资源没做复用”的坑。当时的第一反应是能不能把这些相同的格式抽出来让多个字符共用一份顺着这个思路查资料才发现这个场景正好是设计模式里享元模式Flyweight Pattern的主场。享元模式的核心思想就一句话把对象的共性状态内部状态抽出来共享把随环境变化的状态外部状态留给客户端维护。它专门用来解决大量细粒度、重复对象导致的内存与性能问题在C这种没有自动垃圾回收、内存全靠手动管理的语言里用对地方收益尤其明显。这篇文章就基于我这次的实际改造经验把享元模式从原理到C代码实现再到踩坑记录完整梳理一遍。适合对设计模式有一定了解、但还没在真实项目里用过享元模式的开发者也适合正被“同类对象创建太多、内存告急”折磨的人直接抄作业。2. 享元模式的设计思路与关键概念2.1 内部状态与外部状态的拆分逻辑享元模式最核心、也最容易理解偏差的就是内部状态Intrinsic State和外部状态Extrinsic State的区分。内部状态是存储在享元对象内部、不随环境变化的状态。比如字符的字体、颜色、大小。无论这个字符出现在文档的哪个位置只要是同一份样式定义这些属性就不会变。因此内部状态可以被多个上下文共享这也是“共享”两个字真正的含义。外部状态则是随使用场景变化的状态比如字符在文档中的坐标位置、属于哪一个段落、是否被选中。这些信息不能放进共享对象里否则一个对象被多个场景引用时状态就会互相踩踏。外部状态必须由客户端在调用时传入或者放在独立的上下文结构中。用生活化的例子来说共享单车就是很好的类比。单车本身的品牌、型号、颜色是内部状态所有用户骑的都是同样的车这部分可以“共享生产”而当前骑行者是谁、停在哪里、锁没锁是外部状态跟具体某辆车的使用场景绑定不能硬塞进车的“出厂配置”里。这个拆分是享元模式能否生效的胜负手。拆分得好共享粒度就合理内存省得明显拆得不好要么共享对象存了不该存的差异化数据导致状态污染要么外部状态设计得太重省下的内存又被外部状态对象抵消了。2.2 享元工厂唯一的管理入口享元对象不能像普通对象一样随手new必须通过一个享元工厂FlyweightFactory来获取。工厂内部维护一个对象池通常用哈希表或字典以内部状态组合作为键。当客户端请求一个享元对象时工厂先查池里有没有相同键的对象有就直接返回已有实例没有才创建新对象并放入池中。这本质上就是“缓存”思路用空间换时间在这里不成立恰恰相反是用“查询时间”换“存储空间”。在C里这个工厂的实现需要注意几点键的设计。内部状态的组合要能唯一标识一个享元对象通常用字符串、结构体或者哈希值做键。如果内部状态只有一两个字段直接用std::string拼接最快字段多了建议自定义哈希。对象存储。享元对象一般用std::shared_ptr管理保证多个客户端共享引用计数安全释放也可以用std::unique_ptr配合裸指针返回但风险较高不建议新手这么写。工厂单例化。整个程序通常只需要一个享元工厂实例否则池被拆成好几个共享效果就大打折扣。可以用局部静态变量实现线程安全的单例。2.3 什么场景适合引入享元模式不是所有对象都适合享元化。根据我的经验判断一个场景该不该用享元模式看三个条件对象数量巨大。至少是成千上万级别的否则省下的内存无感还白搭了工厂的维护成本。对象之间有大量可共享的重复状态。如果每个对象都是完全不同的状态一丁点共性都没有享元模式无从谈起。外部状态可以轻松地被剥离出来。也就是说这些可变状态能够由客户端方便地维护和传入。我的编辑器场景三个条件全中字符数量动辄几十万大量字符共享少数几种格式字符在画布上的位置完全可以由渲染循环动态计算不需要每个字符对象自己保存。反过来如果对象只有几十个、状态几乎全不同、外部状态剥离后还得再建一堆辅助对象那就别硬套享元模式。设计模式是为解决问题服务的不是为了在代码里凑齐一套全家桶。3. C完整实现从接口到工厂到客户端3.1 定义享元接口与具体实现先定义一个抽象接口给所有具体享元类统一的操作入口。接口里通常只暴露一个接收外部状态参数的函数内部实现使用共享的内部状态完成具体逻辑。#include iostream #include string #include unordered_map #include memory #include mutex // 字符格式作为享元类的内部状态 struct CharFormat { std::string font; int size; bool bold; // 重载 用于键比较搭配哈希使用 bool operator(const CharFormat other) const { return font other.font size other.size bold other.bold; } }; // 哈希函数让 CharFormat 可以用作 unordered_map 的键 struct CharFormatHash { size_t operator()(const CharFormat fmt) const { size_t h std::hashstd::string()(fmt.font); h ^ std::hashint()(fmt.size) 0x9e3779b9 (h 6) (h 2); h ^ std::hashbool()(fmt.bold) 0x9e3779b9 (h 6) (h 2); return h; } };具体享元类实现接口内部持有格式信息内部状态并提供渲染函数。渲染时需要的外部参数——比如字符内容、坐标位置——由调用方传入。// 享元接口 class Glyph { public: virtual ~Glyph() default; virtual void draw(const std::string text, int x, int y) const 0; }; // 具体享元格式化字符 class FormatGlyph : public Glyph { private: CharFormat format_; // 内部状态共享 public: explicit FormatGlyph(const CharFormat fmt) : format_(fmt) {} void draw(const std::string text, int x, int y) const override { std::cout 在 ( x , y ) 绘制文本 [ text ]字体 format_.font 字号 format_.size 加粗 (format_.bold ? 是 : 否) std::endl; } const CharFormat getFormat() const { return format_; } };注意看这个设计FormatGlyph内部没有存任何跟“具体在哪个位置”相关的信息draw()的x、y参数是运行时传进来的。这样同一个FormatGlyph实例就能被反复使用画在不同的坐标上。3.2 实现享元工厂工厂的关键逻辑是“有则返回无则创建”。用std::unordered_map保存已有实例以内部状态为键。// 享元工厂 class GlyphFactory { private: std::unordered_mapCharFormat, std::shared_ptrGlyph, CharFormatHash pool_; std::mutex mutex_; // 保证多线程安全 public: std::shared_ptrGlyph getGlyph(const CharFormat fmt) { std::lock_guardstd::mutex lock(mutex_); auto it pool_.find(fmt); if (it ! pool_.end()) { return it-second; } auto glyph std::make_sharedFormatGlyph(fmt); pool_[fmt] glyph; return glyph; } size_t getPoolSize() const { std::lock_guardstd::mutex lock(mutex_); return pool_.size(); } };这里有个C特有的设计决策为什么返回shared_ptr而不是裸指针因为享元对象被多个客户端引用引用计数管理生命周期最稳妥。如果返回裸指针一旦某个客户端提前delete了共享对象其他客户端就悬空了。shared_ptr让所有持有者共同控制生命周期最后一个使用者释放时才真正销毁。工厂内部加锁是为了防止多线程同时请求同一个键时重复创建对象这个细节后面在问题排查部分会详细展开。3.3 客户端如何维护外部状态客户端使用享元模式时要习惯“对象的身份归工厂管对象的变数归自己管”的思路。以文本编辑器为例渲染一屏文档时客户端不再为每个字符new一个对象而是只保存每个字符的格式键值或者一个轻量的索引渲染时通过工厂获取对应的享元对象再把字符内容、坐标等外部状态传进去// 轻量级的文本单元只保存字符内容和一个格式标识 struct TextUnit { wchar_t ch; CharFormat format; int x; int y; }; class TextRenderer { private: GlyphFactory factory_; public: explicit TextRenderer(GlyphFactory factory) : factory_(factory) {} void render(const std::vectorTextUnit units) const { for (const auto unit : units) { auto glyph factory_.getGlyph(unit.format); // 外部状态字符内容、坐标 glyph-draw(std::string(1, static_castchar(unit.ch)), unit.x, unit.y); } } };外部状态保存在客户端TextRenderer调用栈里享元内部状态保存在工厂池里。两者彻底分离这就是享元模式在结构上的核心约束。3.4 完整Demo代码与运行分析把上面的内容拼成一个可直接编译运行的完整示例#include iostream #include string #include vector #include unordered_map #include memory #include mutex // ... 上述所有类定义 ... int main() { GlyphFactory factory; // 模拟文档中的一段文字三种格式混排 CharFormat normal{SimSun, 12, false}; CharFormat heading{SimHei, 20, true}; CharFormat emphasis{SimSun, 12, true}; const int N 10000; std::vectorTextUnit doc; doc.reserve(N); for (int i 0; i N; i) { if (i % 100 0) { doc.push_back({L标, heading, i % 80, i / 80}); } else if (i % 10 0) { doc.push_back({L强, emphasis, i % 80, i / 80}); } else { doc.push_back({L测, normal, i % 80, i / 80}); } } // 渲染并统计实际创建的享元对象数量 TextRenderer renderer(factory); renderer.render(doc); std::cout 文档总字符数: N std::endl; std::cout 享元池中实际对象数: factory.getPoolSize() std::endl; return 0; }运行结果很直观一万个字符渲染函数照样执行一万次但享元池里只创建了3个对象。对比第一版实现的“一万个字符对象各存一份格式”内存占用从几MB降到几百字节的格式存储效果立竿见影。注意示例里故意用有限的格式种类来展示效果。实际项目中如果文档的每一种格式都是独一无二的——每字符一个字体一个颜色——享元池的大小就会逼近字符总数模式失效。所以使用前请先统计格式的种类分布。4. 常见问题与排查技巧实录4.1 线程安全多线程下的共享风险享元对象被共享后最直接的隐患就是并发访问。draw()这种只读方法在多个线程里同时调用是没有问题的但如果某个不小心设计的函数在内部修改了内部状态或者工厂在并发创建对象时没有同步就会出现数据竞争。我在改造过程中就遇到过多线程加载文档时每个线程都去工厂请求字体对象结果同一格式的字符被创建了多个实例池子形同虚设。排查半天发现是因为当时图省事没用锁两个线程同时进到pool_.find()都没找到就各自new了一个。解决办法就是在工厂里加互斥锁。但注意加锁的范围要控制好不能把整个渲染流程都锁住否则多线程渲染的性能优势全没了。正确做法是只在“查询-创建-插入”这段临界区加锁拿到共享指针后立即释放锁后续渲染操作在锁外执行。上面的getGlyph()实现就是这么设计的。4.2 外部状态传入的坑第二个容易踩的坑是把外部状态塞进了享元对象里。比如某个同事图省事在FormatGlyph里加了个int lastX, lastY成员说“反正渲染前设置一下就行”。结果两个字符同时使用同一个享元对象时一个字符刚设置完坐标就被另一个字符覆盖了画出来的文字位置全乱套。这个教训很深刻享元对象必须是天生只读的。内部状态一旦初始化就不应该再有任何修改的入口。最好把内部状态成员加上const或者通过getter只读访问。如果确实需要缓存一些计算结果那也是放在外部状态的上下文对象里而不是放进共享对象。4.3 生命周期与内存管理C里使用享元模式生命周期问题比Java、C#这类带GC的语言要更敏感。主要有两个方向一是池中对象何时释放。如果工厂用shared_ptr持有所有对象那么工厂不销毁、池不清理这些对象会一直活着。对于字符格式这类生命周期与整个程序一致的元数据来说这其实正是我们想要的——它们本来就是文档级共享的资源。但如果共享的是临时性资源就要考虑提供清理接口或者在合适时机清空池子。二是客户端错误销毁共享对象。如果工厂返回裸指针客户端一句delete就把共享对象销毁了其他客户端全部遭殃。我之前就见过这种事故——排查时发现某个模块的内存崩溃根源就是有人在析构函数里delete了一个从工厂借来的指针。采用shared_ptr后这个问题被彻底根治。4.4 享元模式失效的迹象怎么判断享元模式实际收益不如预期我总结了一套自查清单池中对象数量跟对象总数几乎一样大说明内部状态拆分失败对象之间没有足够多的共性。外部状态结构比原来的对象还复杂维护成本明升暗降。为了从池里查找对象所花的哈希计算时间超过了直接创建新对象的开销。这个在小对象、低创建成本的场景里尤其容易出现需要权衡。规则允许但直觉告诉你这地方其实用不着优化——过早使用享元模式跟过早优化一样有害无益。遇到以上情况我的建议是果断放弃享元模式回到普通对象创建的思路。设计模式不是装饰品不好用就不硬用。5. 实操心得与扩展思考5.1 几个让我印象深刻的经验这次改造给我的最大收获不是记住了享元模式的类图而是养成了一个习惯写代码之前先问一句这里是不是在重复造相似的对象具体来说可以从几个角度自查这个对象会被创建多少个如果数量级过万就得警惕。这些对象之间有没有字段值大量重复比如一万个对象里九千个的某个字段值相同。这个对象能不能拆成“固有属性”和“场景属性”两部分如果三个问题的答案都是肯定的那就说明享元模式大概率能派上用场。即使不完整实现享元模式单纯把重复的配置抽取成共享结构体用索引引用也能省下可观的内存。另外一个小小的实操技巧在使用unordered_map存享元对象时如果内部状态字段较多可以直接用std::string拼接字段做键比如SimSun|12|0。查找速度很快代码也更直观。代价是每次请求都要做一次字符串拼接如果查询频率极高字符串拼接的开销会累积。这种情况下自定义结构体键配合哈希函数反而更高效。5.2 享元模式与其他技术的联动享元模式在实际工程中很少孤立存在它经常和以下几种技术配合使用与对象池Object Pool区别。享元模式强调“多个场景共享同一实例”对象池强调“实例复用但同一时刻只属于一个使用者”。数据库连接池就是典型对象池连接不能被两个请求同时使用字符格式则是典型的享元可以被所有字符共享。与单例模式配合。享元工厂自身通常实现为单例保证整个进程只有一个对象池。局部静态变量实现简洁且线程安全C11及以上标准保证局部静态变量的初始化线程安全。与原型模式对比。原型模式通过复制现有对象来创建新对象每个实例是独立的享元模式则完全避免复制让所有实例共享同一个。一个文件系统里“复制一份文件内容”用原型语义“多个目录项引用同一物理文件”就是享元的思路。Linux的硬链接就是典型的享元思想。现代C的静态存储优化。对于类似字符格式这种小型不可变对象还可以结合constexpr和静态存储让它们在编译期就确定唯一实例运行期连工厂查询的开销都省了。不过这对代码结构要求较高工程上用得不算多了解即可。5.3 回到最初的项目前面提到的那台文本编辑器最终改造后的内存表现是一百万字以上的文档格式相关内存从原先的几百MB降到几KB级别渲染初帧耗时也大幅下降。享元池里始终只有十几种格式因为再复杂的文档格式种类也是有限的。这里其实隐藏着享元模式最底层的逻辑有限种类的配置被海量实例穷举式地重复使用。凡是符合这个特征的数据都可以考虑用它来优化。个人体会是设计模式不能只停留在“背定义、画类图”的层面而是要在真实的性能瓶颈中反复锤炼。享元模式是我在C项目里用过成效最明显的一个结构型模式也希望这篇复盘能帮你少走一些弯路。
阅读完成 · 觉得有帮助?