资讯动态

C++享元模式实战:从2.8GB内存崩溃到1GB流畅渲染

发布时间:2026/9/7 23:41:28 来源:尧图企业网站定制
先说一个我上个月排到崩溃的内存问题。项目里有个文档渲染模块打开一份几百万字的技术手册内存从启动时的800MB一路涨到2.8GB。我一开始怀疑是字体库泄漏后来用分配器钩子加内存快照一查发现罪魁祸首是一堆结构完全相同的字符对象同一个汉字、同一种字体、同一个字号被当成独立对象new了上百万次。这个问题在C里有一个经典解法——享元模式Flyweight今天这篇就聊聊我在实战中怎么用它以及哪些坑不看源码根本想不到。网上很多人把享元模式当成C面试八股文来背GoF那几个字倒背如流但真到项目里能把这几个字落到代码上的人不多。这篇文章不是从零讲概念的设计模式教程更像一份排障笔记加重构记录。我会先把真实场景摆出来再拆解享元模式在C里的实现细节、完整重构过程最后说说只有实际动手才会踩到的那些坑。1. 一个让内存涨到2.8GB的真实场景1.1 问题最初看起来像字体库泄漏当时模块的职责很简单把一份排版好的文档渲染成一整页位图。文档里的每个字符都需要记录字体名、字号、颜色和坐标然后交给底层渲染引擎逐个绘制。第一版实现非常直观直接给每个字符建一个结构体对象塞进vector里。出问题的是长文档。当渲染批次里有两三千万个字符时内存开始失控。我一开始怀疑是某个第三方字体解析库有问题因为症状太像泄漏了连续打开几个文档内存只涨不降。但我把字体库换成最小复现用例之后内存一切正常于是注意力重新回到自己的业务代码上。真正让我定位到问题的是一个笨办法在创建字符对象的地方加了一个计数器然后按类型统计每个对象的数量。结果吓人——光SimSun这个字体名就在内存里重复存储了上千万次。不是同一个字符串对象的引用而是上千万个独立的std::string每个都占了几十个字节。1.2 问题本质把共享属性当成了个体属性这批字符对象里真正有区别的只有坐标。剩下那些属性——字符编码、字体名、字号、颜色——大量重复。朴素实现的问题是每个字符都持有一套完整的样式描述可实际上整篇文档可能只有几十种字体样式组合。打个不太恰当的比方一家公司几千名员工的工作证上每个人都手抄了一遍公司全称和地址。抄一遍不费劲抄几千份就很浪费而且后期公司改名你得把所有工作证重印一遍。正确的做法是把公司信息做成一块公共铭牌挂在墙上员工工作证只写自己的姓名和工号。在C里这块公共铭牌就是享元对象。这也是享元模式最核心的价值把对象的属性拆成两部分共享的部分只保留一份属于个体的部分由使用方自己保存。2. 享元模式的核心机制内部状态与外部状态怎么划分2.1 最简单的享元对象长什么样先看一个高度简化的字符样式类。它只包含样式的属性不包括坐标class Glyph { public: Glyph(char code, std::string font_name, unsigned int font_size) : code_(code), font_name_(std::move(font_name)), font_size_(font_size) {} void Draw(int x, int y) const { // 真正绘制时用字符编码、字体名、字号在 (x, y) 处渲染 } private: char code_; std::string font_name_; unsigned int font_size_; };这个类里所有的成员都是内部状态也就是无论这个字符出现在第几页、哪个位置它都保持不变。x和y没有放进类里因为它们是外部状态应该由调用方保存在需要绘制的时候作为参数传进来。内部状态和外部状态怎么分是享元模式设计里最关键、也最容易搞错的一步。判断标准很简单如果两个字符去掉坐标之后依然完全一样那它们就可以共享同一个对象凡是会随场景变化的部分都不能放进享元对象里。2.2 为什么外部状态一定要由客户端传入有人可能会问反正都要引用同一个Glyph为什么不把坐标直接塞进Glyph里答案是不行因为坐标是外部状态。如果坐标进了享元对象那同一个Glyph被两个不同位置的字符引用时坐标就得互相覆盖这会造成极其隐蔽的渲染错乱。正确的用法是Glyph对象只表示长什么样不表示出现在哪。所有共享的属性留在对象里所有上下文相关的属性由客户端保存使用时临时传入。这样做的好处是共享对象永远是只读的多个调用方同时使用也不会互相影响。我见过有人把享元模式理解成缓存对象觉得只要用了unordered_map就算享元了。其实不是。缓存只是手段状态划分才是灵魂。前面例子里的Glyph要是没有把x、y拆出去就算用缓存也不过是把不同位置的对象全塞进一个对象里必然出错。2.3 和单例、静态对象、缓存的区别把享元模式搞混的情况很常见。单例是全局唯一但享元不一定唯一同一种字体样式的Glyph共享不同字体样式的Glyph是两个不同对象。静态对象更像是一个对象从程序开始活到结束而享元对象可以按需创建、按需释放。缓存是手段享元是结构设计。更准确地说享元模式解决的是大量细粒度对象问题关键看对象能不能拆分状态。static Glyph glyph;只解决一个全局对象的问题解决不了十万个不同坐标的字符共享同一种样式的问题。3. 手写一个享元工厂C实现里的关键决策3.1 工厂接口与Key设计享元模式在C里通常会配套一个工厂类负责接收创建请求、查找已有对象、必要时创建新对象。工厂里最重要的设计是Key也就是用什么来唯一标识一个享元对象。对于上面的字符样式最简单的Key就是把所有内部状态打包struct GlyphKey { char code; std::string font_name; unsigned int font_size; }; inline bool operator(const GlyphKey lhs, const GlyphKey rhs) { return lhs.code rhs.code lhs.font_size rhs.font_size lhs.font_name rhs.font_name; } struct GlyphKeyHash { size_t operator()(const GlyphKey key) const { size_t seed std::hashchar{}(key.code); seed ^ std::hashstd::string{}(key.font_name) 0x9e3779b9 (seed 6) (seed 2); seed ^ std::hashunsigned int{}(key.font_size) 0x9e3779b9 (seed 6) (seed 2); return seed; } };这里的哈希函数直接用了一个常见的hash_combine思路避免简单异或导致不同Key撞出相同哈希。实际工程里也可以用boost::hash_combine但自己写一份也不难。3.2 用shared_ptr还是裸指针工厂返回类型的选择直接决定了享元对象的生命周期。如果使用裸指针工厂自己管理生命周期。这种方式要求调用方承诺不提前销毁、不自己delete一旦某个模块违规整个程序崩溃得莫名其妙。小项目里能靠约定约束大项目里很容易失控。我推荐优先返回std::shared_ptrconst Glyph并且类型加上const。const不是为了炫技而是从类型系统层面保证享元对象不可修改避免前面说的一个调用方改了共享状态所有使用者跟着遭殃的问题。class GlyphFactory { public: std::shared_ptrconst Glyph Get(const GlyphKey key); };调用方拿到的是只读句柄想改样式只能通过工厂重新创建新对象。这个约束带来的好处远大于shared_ptr那一点点原子引用计数的开销。3.3 weak_ptr缓存还是shared_ptr缓存工厂内部的缓存用shared_ptr还是weak_ptr是很多人容易忽略的关键点。如果直接用shared_ptr缓存那么一旦某个Key被创建过这个享元对象就永远不会销毁。这在Key空间很小的场景下没问题比如几十种固定样式全局存活也无所谓。但如果Key空间接近无限比如字号是浮点数、用户能输入任意值那缓存里的对象会无限增长内存问题只是换了一种形式出现。用weak_ptr缓存可以做到最后一个使用方释放后享元对象自动销毁。工厂持有的是弱引用不阻止析构。下次再需要时重新创建即可class GlyphFactory { public: std::shared_ptrconst Glyph Get(const GlyphKey key) { std::shared_ptrconst Glyph result; { std::lock_guardstd::mutex lock(mutex_); auto it pool_.find(key); if (it ! pool_.end()) { result it-second.lock(); if (!result) { pool_.erase(it); } } } if (result) { return result; } auto glyph std::make_sharedGlyph( key.code, key.font_name, key.font_size); { std::lock_guardstd::mutex lock(mutex_); pool_.emplace(key, std::weak_ptrconst Glyph(glyph)); } return glyph; } private: std::unordered_mapGlyphKey, std::weak_ptrconst Glyph, GlyphKeyHash pool_; std::mutex mutex_; };这段代码里有两个细节值得注意。一是lock()失败后一定要erase否则过期weak_ptr会在池子里堆积白白占内存。二是创建新对象放在锁外避免在持锁状态下做字符串拷贝和堆分配减少锁竞争。3.4 C多线程环境下的加锁策略如果是单线程程序上面的工厂可以不加锁。但很多C项目天生就是多线程的尤其是渲染相关模块多个线程可能同时调用Get请求同一个样式。我在实际项目里用的是最简单也够用的std::mutex因为Get内部的临界区很短就是一次哈希查找加一次weak_ptr::lock竞争开销可以忽略。另一个方案是std::shared_mutex读多写少的情况下理论上更好但实际提升往往有限还增加代码复杂度。我的建议是先用mutex跑通等profiler证明这里有瓶颈再优化。还有一个进阶思路如果工厂的查询频率极高可以对Key做整数化处理。比如把字体名注册到全局表里每种字体分配一个整数IDKey变成(code, fontId, font_size)三个整数组成的聚合哈希和比较都远快于字符串。4. 完整实战把字符渲染模块从朴素实现重构为享元模式4.1 朴素实现的代码与内存消耗当时线上跑的第一版核心结构长这样struct RenderedChar { char code; std::string font_name; unsigned int font_size; Color color; int x; int y; }; std::vectorRenderedChar chars; // 一个2300万字符的文档这里就会产生2300万个对象这个结构体在64位平台上的sizeof大概是56到64字节具体取决于std::string的SSO大小和内存对齐。2300万个字符光这个结构体数组就要占用1.3GB以上。更麻烦的是每个RenderedChar里的font_name都是一个独立的std::string哪怕内容完全相同底层的字符缓冲区也可能各自持有一份。从内存分布来看绝大部分字节存的是重复数据。整篇文档可能只有几十种字体样式但内存里存了2300万份。这个冗余是纯浪费因为样式信息本身没有变化只是坐标不同。4.2 享元重构后的结构重构思路非常直接把RenderedChar拆成两个部分共享的样式抽成CharStyle变动的坐标留在实例里。class CharStyle { public: CharStyle(char code, std::string font_name, unsigned int font_size, Color color) : code_(code), font_name_(std::move(font_name)), font_size_(font_size), color_(color) {} void Draw(int x, int y) const { // 使用内部保存的样式信息在 (x, y) 处绘制 } private: char code_; std::string font_name_; unsigned int font_size_; Color color_; }; struct CharInstance { std::shared_ptrconst CharStyle style; int x; int y; }; class CharStylePool { public: std::shared_ptrconst CharStyle Get( char code, const std::string font_name, unsigned int font_size, Color color) { CharStyleKey key{code, font_name, font_size, color}; auto it pool_.find(key); if (it ! pool_.end()) { if (auto result it-second.lock()) { return result; } pool_.erase(it); } auto style std::make_sharedCharStyle( code, font_name, font_size, color); pool_.emplace(key, style); return style; } private: std::unordered_mapCharStyleKey, std::weak_ptrconst CharStyle, CharStyleKeyHash pool_; };CharInstance只保存一个shared_ptrconst CharStyle和坐标。它的作用是充当外部状态容器坐标不能进CharStyle这个原则一定不能破。生成的字符数据也从std::vectorRenderedChar chars;变成了std::vectorCharInstance chars;数据量不变但每个元素占用的内存大幅缩小。更重要的是所有相同样式的字符共享同一个CharStyle对象字体名、字号这些数据在内存里只有一份。4.3 重构后的效果与需要注意的边界以我当时那个2300万字符的文档为例重构后字符实例数组本身的占用从1.3GB降到了大约400多MB整体峰值内存从2.8GB降到了1GB以内。实际绘制性能没有劣化甚至因为相同样式只需要加载一次字体资源底层渲染的准备时间反而变短了。这里要说明一下具体数字跟平台、字符分布强相关不一定能复现。特别是如果文档里每行都用不同字体、不同字号享元命中率会很低收益也会很小。所以不要拿我的数字当通用结论建议在自己的工程里用heaptrack或valgrind massif做前后对比。5. 使用享元模式最容易踩的四个坑5.1 坑一给享元对象提供了非const接口享元对象最怕被修改。如果不小心把成员变量的setter写出来比如SetColor一旦某个调用方改了颜色所有共享这个CharStyle的字符全部变色而且这种问题极难排查。因为调试器里看到的地址都是一样的你根本不知道是哪个模块改的。正确的姿势是让享元对象的所有数据成员都不可变接口只读。如果确实需要变就通过工厂创建一个新的享元对象并让旧对象自然释放。需要批量修改样式时可以先准备好新Key再统一替换也不要搞出临时修改一下再用的操作。5.2 坑二外部状态传参混乱导致渲染错乱外部状态是享元模式里最容易被搞乱的部分。我见过同事在绘制循环里写错坐标把第i个实例的坐标传给了第j个实例。因为所有实例共享同一个CharStyle对象log里打出来的样式信息完全一样根本看不出问题在哪。调试了半天最后发现是索引错位。后面我养成了一个习惯把外部状态封装成一个小结构体比如Position、Rect不要裸传一堆int参数。结构体字段有名字调用处一眼就能看出传的是什么。另外所有传入Draw的外部状态参数都加const引用从编译层面减少误改的可能。5.3 坑三缓存语义搞错导致内存泄漏享元工厂的缓存策略决定了这个模式到底是省内存还是换着方式浪费内存。前面提过用shared_ptr缓存所有创建过的享元对象永不释放Key空间小无所谓Key空间大就是灾难。用weak_ptr缓存则必须处理lock()失败的情况。实际排查这类问题先看工厂所在的模块是不是全局单例、生命周期有没有结束再看缓存容器是强引用还是弱引用最后看Key空间的增长速度。只要这三步走完基本能定位是哪种缓存语义导致的异常。5.4 坑四Key设计不合理享元变成了“空转”享元工厂的查询效率很容易被Key拖垮。最典型的问题是直接用std::string作为unordered_map的Key。每次Get都要创建一个临时字符串再走一遍字符串哈希开销其实不低。如果这个查询路径一秒钟执行几十万次性能损耗完全抵消了省下的内存。我的建议是能整数化就整数化。给字体、颜色等枚举量分配稳定ID把Key变成几个整数组成的结构与或者直接打包成一个uint64_t。如果没有现成的注册表可以在工厂内部维护一张std::unordered_mapstd::string, uint32_t把字符串Key映射成整数ID。这样享元池的查找从字符串哈希降成整数哈希效果立竿见影。6. 享元模式和其他设计模式的搭配以及什么时候别用它6.1 享元 对象池适合高频创建销毁的场景享元解决的是种类太多的问题对象池解决的是同一个种类反复创建销毁的问题两者并不冲突经常一起出现。举个例子粒子系统里粒子模板只有几种这是享元的适用场景但每一帧可能生成上万个粒子实例这些实例本身又会被反复创建和释放这时候就该用对象池复用ParticleInstance对象。享元管共享状态对象池管上下文对象的分配效率各司其职。6.2 享元 原型模式适合构建成本高的场景如果创建享元对象的成本很高比如要加载字体文件、编译shader、初始化大块数据可以在工厂里预置几个原型Get时先查缓存缓存没有就基于原型复制一份再注册进享元池。原型模式负责高效构造享元模式负责共享去重组合起来比较优雅。不过要提醒一句只有当初始化成本真的高到可以被profiler证明时再引入原型。单纯的感觉初始化很慢不能成为叠加模式的理由否则代码复杂度会上涨一截收益却很鸡肋。6.3 什么时候该主动放弃享元模式享元模式并不是越多越好。如果对象本身就很小比如只有一个int共享节省的那点内存完全抵不上工厂查询和状态拆分的复杂度如果Key空间接近实例数量命中率极低享元就成了一个昂贵的缓存如果团队里其他维护者不熟悉这个模式共享状态引入的隐性风险可能比性能收益更严重。我的判断标准就一句话先写朴素实现跑profiler看到重复对象占比确实高、内存占比确实大再上享元。不要因为面试题里考过就非要在项目里用一次。用下来的体会是享元模式真正难的不是背出内部状态、外部状态这八个字而是能在一个具体的工程场景里准确判断哪些状态该共享、哪些状态该独立。状态切分切错了后患无穷切对了代码结构反而更清晰。如果你手头正好有内存吃紧的C模块不妨先用这篇的思路做一次对象画像看看有多少数据是重复的——大概率会有惊喜。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价