最近做的一个渲染模块里粒子数量从几千涨到五万之后内存以肉眼可见的速度往上飙每帧还在频繁地 new 对象。排查到最后问题不在算法而在于大量粒子把同样的纹理路径、网格信息、半径参数一股脑地复制了五万份。这应该是我第一次在生产代码里认真用享元模式Flyweight Pattern也是我第一次真正理解“内部状态”和“外部状态”这条分界线有多重要。这篇文章我会把 C 里实现享元模式的完整思路写下来包含状态怎么划分、智能指针怎么选、工厂缓存怎么做、线程安全怎么处理还有两个可以直接跑的实战例子文本渲染字符样式共享、粒子系统类型缓存最后是我踩过的四个典型坑。适合写过一阵子 C、或者在做游戏/渲染/编辑器类项目的朋友看完应该能直接落地。1. 先算一笔内存账享元模式为什么能救场1.1 两个典型场景的“重复对象账单”先看文本渲染。假设一个文档有 100 万个字符每个字符对象如果都保存一份完整的排版样式字体名字符串32 字节左右的 std::string 开销、字号 int、颜色 unsigned、加粗 bool加上 padding一个字符可能轻松达到 60 字节以上。100 万个字符就是 60MB。但实际文档里可能只有标题、正文、引用几种样式几千种已经算多。也就是说海量内存都花在复制一堆完全相同的字体、字号、颜色数据上。再看粒子系统。一个粒子有纹理路径、网格路径、半径、位置、速度、生命值。前三个字段属于“描述信息”在同一个粒子类型里基本一样。五万个粒子如果不共享每个粒子都存一份两个字符串的拷贝光路径字符串就占掉好几 MB粒子越多浪费越大。我当时的项目里粒子数上到五万之后光这部分描述数据就占了 60MB 左右。这两个场景本质是同一个问题大量对象拥有重复的特征数据而真正随实例变化的字段反而很少。内存账算清楚之后方案就明显了——把不变的特征数据抽出来共享把变化的数据保留在每个对象自己身上。1.2 内部状态与外部状态划线的标准享元模式把对象状态分成两类内部状态intrinsic state存储在共享对象内部创建时确定整个生命周期不变可以被多个对象共用。外部状态extrinsic state不属于共享对象由使用方持有每次操作时作为参数传进去。判断一个字段到底属于哪一边我习惯问三个问题这个字段在不同实例间是否大量重复重复率高优先考虑放进内部状态。这个字段是否创建后就不变比如纹理路径创建粒子类型时定了后面不会再改适合内部。这个字段是否随实例变化、变化频繁或需要独立控制比如粒子位置每帧都在变必须外部状态。拿棋子举例棋子的形状、颜色是内部状态同一兵种所有棋子共享一份图形资源而棋盘上的行、列坐标是外部状态每个棋子自己持有。经典得有点俗但确实是一句话就能讲清楚的分界线。1.3 和对象池、单例、原型模式的区别这几个模式经常被放在一起比较但解决的问题完全不同。模式核心目的与享元模式的区别对象池复用已经创建的对象避免频繁分配/释放池里的对象每次取出来可能是同一个但状态独立享元是多个逻辑对象共享同一块状态单例保证一个类只有一个实例单例是全局唯一享元可以有多个不同内部状态的实例只是相同内部状态只保留一份原型通过克隆现有对象创建新对象原型复制出一份独立对象享元不复制直接引用同一份享元通过共享相同状态减少内存占用—实际项目里它们经常组合使用比如享元工厂本身可以做成一个单例缓存查询失败时再从资源库加载并创建。但脑子里还是要把边界想清楚享元关心的是“相同数据不再重复存”对象池关心的是“对象分配次数变少”。2. C落地前的三个关键设计决策2.1 共享对象该用裸指针、shared_ptr 还是唯一拥有权这是 C 搞享元模式第一个绕不开的问题。共享对象会被外部大量引用生命周期比单个外部对象长如果外部对象销毁了共享对象就没了麻烦很大。所以不能只靠某个使用方持有所有权。我见过三种做法工厂持有 unique_ptr外部保存裸指针性能最好内存最省但前提是工厂生命周期必须长于所有外部使用者。一旦工厂被销毁所有裸指针全部悬垂排查起来非常痛苦。工厂持有 shared_ptr外部也持有 shared_ptr安全写起来舒服缺点是每个引用都要原子递增计数外部对象里多一个 16 字节的 shared_ptr 开销。工厂持有 weak_ptr外部持有 shared_ptr兼顾安全与自动清理缓存不会无限膨胀但每次查找都要 lock 一次并且如果外部引用很短命共享对象会被频繁回收重建。我的通用建议是内部工具、生命周期可控、性能敏感比如每帧几百万次调用用第一种业务模块、多线程、安全优先用第二种缓存不想失控就升级成第三种。下面两个实战代码文本示例用 shared_ptr 演示粒子示例会用 shared_mutex shared_ptr 的线程安全版。还有一个一定要养成的习惯共享对象对外暴露的接口尽量返回 const或者干脆让外部持有shared_ptrconst CharStyle。只要共享对象不可变就从根本上避免了一个使用者改了内部状态、所有使用者一起被污染的问题。2.2 工厂缓存的 Key 设计字符串、浮点数和 hash 的坑享元工厂本质是一个缓存给定内部状态组合返回已有对象没有就创建。C 里最常用的容器是std::unordered_mapKey 就是内部状态的组合。Key 设计有几个坑值得先说不要把 Key 定义成一个大字符串拼接。我之前见过有人把“字体名空格字号空格颜色”拼成一个 string 当 Key运行正常但每个 lookup 都要做一次字符串拼接缓存里还复制了一份完整字符串凭空增加开销。正确做法是定义结构体自己写operator和哈希函数。哈希函数不要偷懒用std::hashlong long强行转换。一个const char*指针转成整数当哈希等于用地址做 Key永远命中不了。浮点字段作为 Key 要小心。业务里radius可能是1.0000001f和1.0f这种微小差异严格来说不同 Key工厂会创建两个几乎一样的共享对象。如果内部状态本来就应该是离散的建议在进 Key 前先做规范化比如按 0.01 精度取整或者干脆用整数枚举代替浮点数。注意float的 NaN 问题NaN ! NaN哈希也稳定不了一旦 Key 里有 NaN缓存永远找不到旧对象池子会持续膨胀。如果内部状态允许 NaN必须提前过滤。2.3 getFlyweight 的线程安全从 mutex 到 shared_mutex享元工厂通常在多线程环境使用渲染线程、加载线程、逻辑线程都会来取共享对象。unordered_map本身不是线程安全的并发读写轻则数据竞争重则直接崩溃。最简单的版本是直接加std::mutexstd::shared_ptrconst ParticleType get(...) { std::lock_guardstd::mutex lock(mutex_); auto it cache_.find(key); if (it ! cache_.end()) return it-second; auto type std::make_sharedParticleType(...); cache_[key] type; return type; }这个写法安全、简单缺点是所有 get 全串行。并发量上来以后读操作远多于写操作可以用std::shared_mutexC17做读写分离std::shared_mutex mutex_; std::shared_ptrconst ParticleType get(...) { { std::shared_lock lock(mutex_); // 读锁可并发 auto it cache_.find(key); if (it ! cache_.end()) return it-second; } // 读锁释放 std::unique_lock lock(mutex_); // 写锁 auto it cache_.find(key); // 双检防止重复创建 if (it ! cache_.end()) return it-second; auto type std::make_sharedParticleType(...); cache_[key] type; return type; }第一次读锁找不到释放读锁再拿写锁进去后必须再查一次缓存否则两个线程可能同时创建同一内部状态的对象虽然功能上不算错但缓存里会存两份浪费内存也让“共享”失效。这就是双检锁思路的变体在享元工厂里很实用。3. 实战一文本渲染系统的字符样式共享3.1 需求拆解哪些字段能进内部状态做一个简化版文本渲染 Demo。一篇文章有大量字符字符之间只有几种排版样式。我把字符拆成两个对象字段归属理由font字面字体名内部状态同一段落大量重复创建后不变size字号内部状态标题、正文、注释各一种重复率高color颜色内部状态排版主题色有限bold加粗内部状态高亮段落共享row、col行列位置外部状态每个字符必然不同ch实际字符内容外部状态每个字符内容不同文字排版里“字体样式”信息天然适合当内部状态。修改一个标题样式时只要修改共享的 CharStyle 对象所有相同样式的字符自动跟着变这其实也是很多渲染引擎里的标准做法。3.2 代码实现style 对象、Glyph 对象、工厂字符样式对象只保存内部状态并提供应用渲染的方法#include iostream #include memory #include mutex #include string #include unordered_map #include vector class CharStyle { public: CharStyle(std::string font, int size, unsigned color, bool bold) : font_(std::move(font)), size_(size), color_(color), bold_(bold) {} void apply() const { std::cout 样式: font font_ size size_ color std::hex color_ std::dec bold (bold_ ? yes : no) \n; } private: std::string font_; int size_; unsigned color_; bool bold_; };外部状态包含位置和实际字符每个 Glyph 持有同一个shared_ptrconst CharStyle也就是共享引用struct CharPosition { size_t row; size_t col; char ch; }; class Glyph { public: Glyph(std::shared_ptrconst CharStyle style, CharPosition pos) : style_(std::move(style)), pos_(pos) {} void render() const { style_-apply(); std::cout 字符 pos_.ch 位于 ( pos_.row , pos_.col )\n; } private: std::shared_ptrconst CharStyle style_; // 内部状态共享 CharPosition pos_; // 外部状态独立 };工厂负责按内部状态组合去缓存区查对象class CharStyleFactory { public: std::shared_ptrconst CharStyle getStyle( const std::string font, int size, unsigned color, bool bold) { StyleKey key{font, size, color, bold}; std::lock_guardstd::mutex lock(mutex_); auto it cache_.find(key); if (it ! cache_.end()) return it-second; auto style std::make_sharedCharStyle(font, size, color, bold); cache_[key] style; return style; } size_t cacheSize() const { std::lock_guardstd::mutex lock(mutex_); return cache_.size(); } private: struct StyleKey { std::string font; int size; unsigned color; bool bold; bool operator(const StyleKey other) const { return font other.font size other.size color other.color bold other.bold; } }; struct StyleKeyHash { size_t operator()(const StyleKey key) const { size_t h std::hashstd::string{}(key.font); h ^ std::hashint{}(key.size) 0x9e3779b9 (h 6) (h 2); h ^ std::hashunsigned{}(key.color) 0x9e3779b9 (h 6) (h 2); h ^ std::hashbool{}(key.bold) 0x9e3779b9 (h 6) (h 2); return h; } }; mutable std::mutex mutex_; std::unordered_mapStyleKey, std::shared_ptrconst CharStyle, StyleKeyHash cache_; };哈希函数里那个0x9e3779b9是经典的黄金分割比例常数用于混合多个哈希值避免简单异或导致的分布不均匀属于 C 社区常用的经验写法。main 函数里生成 100 万个字符对象int main() { CharStyleFactory factory; auto styleTitle factory.getStyle(黑体, 24, 0xFF0000, true); auto styleBody factory.getStyle(宋体, 12, 0x000000, false); std::vectorGlyph glyphs; glyphs.reserve(1000000); for (size_t row 0; row 1000; row) { for (size_t col 0; col 1000; col) { if (row 0) { glyphs.emplace_back(styleTitle, CharPosition{row, col, T}); } else { glyphs.emplace_back(styleBody, CharPosition{row, col, B}); } } } std::cout 样式工厂缓存对象数: factory.cacheSize() \n; std::cout 文本字符数: glyphs.size() \n; std::cout 每个Glyph对象大小: sizeof(Glyph) 字节\n; std::cout Glyph数组总内存: (glyphs.size() * sizeof(Glyph)) / 1024.0 / 1024.0 MB\n; glyphs[0].render(); glyphs[100].render(); return 0; }3.3 验证共享真的生效缓存大小与内存占用测量编译运行后可以看到缓存对象数是 2文本字符数是 100 万。在我的环境里x86_64 常见标准库实现sizeof(Glyph)大概是 32 字节shared_ptr 16 字节CharPosition 里两个 size_t 加一个 char对齐后 16 字节。100 万个 Glyph 总内存大约 32MB。如果不用共享每个字符都保存“字体字符串 字号 颜色 加粗 位置 内容”单对象轻松超过 72 字节100 万个就是 72MB 以上。共享后少了 40MB而这还是只压缩了样式部分没有动位置信息的前提下。有人会问省下这 40MB 值得吗值得。因为真正的文本编辑器字符数更多而且这是乘数关系字符数从 100 万涨到 1000 万差距就变成 400MB。内存优化领域的经验是复合对象的重复大字段越早共享后面越省心。4. 实战二粒子系统的类型缓存4.1 粒子内存结构纹理网格是共享大头位置速度是外部状态粒子系统比文本例子更贴近游戏场景。这里的关键是“粒子类型”和“粒子实例”两层的区分ParticleType纹理路径、网格路径、粒子半径属于内部状态同一类型的粒子全部共享。Particlex、y、vx、vy、life属于外部状态每个粒子独立。渲染一个粒子时Particle 只需要把自己的位置传给共享类型对象让类型对象完成绘制。纹理、网格这些重量级资源不用复制。这么设计还有个额外好处同一共享粒子类型对象内部可以持有已经加载好的纹理句柄和网格缓冲区加载一次后续所有粒子零成本引用。如果每个粒子都重复加载相同资源那就不是内存问题而是 IO 和加载时间一起爆炸。4.2 代码实现ParticleType、Particle、线程安全工厂共享类型对象#include iostream #include memory #include shared_mutex #include string #include unordered_map #include vector class ParticleType { public: ParticleType(const std::string texture, const std::string mesh, float radius) : texture_(texture), mesh_(mesh), radius_(radius) {} void render(float x, float y) const { std::cout 渲染: texture texture_ mesh mesh_ radius radius_ position( x , y )\n; } private: std::string texture_; std::string mesh_; float radius_; };粒子实例只保存自己独立的数据外加一个对共享对象的引用class Particle { public: Particle(std::shared_ptrconst ParticleType type, float x, float y, float vx, float vy, float life) : type_(std::move(type)), x_(x), y_(y), vx_(vx), vy_(vy), life_(life) {} void update(float dt) { x_ vx_ * dt; y_ vy_ * dt; life_ - dt; } void render() const { type_-render(x_, y_); } private: std::shared_ptrconst ParticleType type_; float x_, y_, vx_, vy_, life_; };工厂使用 shared_mutex 做读写分离实现上面说过的双检逻辑struct ParticleTypeKey { std::string texture; std::string mesh; float radius; bool operator(const ParticleTypeKey other) const { return texture other.texture mesh other.mesh radius other.radius; } }; struct ParticleTypeKeyHash { size_t operator()(const ParticleTypeKey key) const { size_t h std::hashstd::string{}(key.texture); h ^ std::hashstd::string{}(key.mesh) 0x9e3779b9 (h 6) (h 2); h ^ std::hashfloat{}(key.radius) 0x9e3779b9 (h 6) (h 2); return h; } }; class ParticleTypeFactory { public: std::shared_ptrconst ParticleType get(const std::string texture, const std::string mesh, float radius) { ParticleTypeKey key{texture, mesh, radius}; { std::shared_lock lock(mutex_); auto it cache_.find(key); if (it ! cache_.end()) return it-second; } std::unique_lock lock(mutex_); auto it cache_.find(key); if (it ! cache_.end()) return it-second; auto type std::make_sharedParticleType(texture, mesh, radius); cache_[key] type; return type; } size_t cacheSize() const { std::shared_lock lock(mutex_); return cache_.size(); } private: mutable std::shared_mutex mutex_; std::unordered_mapParticleTypeKey, std::shared_ptrconst ParticleType, ParticleTypeKeyHash cache_; };使用端int main() { ParticleTypeFactory factory; auto fireType factory.get(fire.png, fire.mesh, 1.0f); auto smokeType factory.get(smoke.png, smoke.mesh, 2.0f); std::vectorParticle particles; particles.reserve(50000); for (int i 0; i 50000; i) { if (i % 2 0) { particles.emplace_back(fireType, 0.f, 0.f, 0.1f, 0.2f, 1.0f); } else { particles.emplace_back(smokeType, 0.f, 0.f, -0.1f, 0.1f, 2.0f); } } std::cout 粒子类型缓存对象数: factory.cacheSize() \n; std::cout 粒子总数: particles.size() \n; std::cout 每个Particle大小: sizeof(Particle) 字节\n; particles[0].render(); return 0; }4.3 实测对比内存、耗时与一点点额外开销我的测试环境不是很关键重点是这组数量级关系指标不共享每个粒子带两份字符串float共享Particle 只带 shared_ptr5 万粒子内存约 3.5 MB约 1.5 MB20 万粒子内存约 14 MB约 6 MB创建时对字符串的深拷贝次数20 万次只发生在少量类型对象创建时渲染时额外开销无每次粒子渲染多一次 shared_ptr 读取几乎可忽略表格里的“不共享”是按每个粒子都持有两个 std::string 和若干 float 估算的真实项目如果纹理路径很长差距会更夸张。代价也要承认多了 16 字节的 shared_ptr并且每次从工厂取类型时要做一次哈希查找。但哈希查找只在创建粒子时发生渲染时粒子已经持有共享指针没有额外查表。所以对在线逻辑的影响基本可以忽略。4.4 进阶缓存清理和自动回收方案工厂缓存如果只增不减内部状态组合很多时还是可能膨胀。两个思路工厂持有 weak_ptr 外部持有 shared_ptr最后一次使用者释放后缓存里的 weak_ptr 自动失效下次 get 时重新创建。优点是不需要手动清理缺点是如果对象生命周期短会被反复重建反而增加创建开销。适合“共享对象重但使用频繁”的场景。LRU 淘汰 引用检查缓存记录最近访问时间超过阈值且当前没有外部引用时可以用 weak_ptr 判断才允许淘汰。适合长期运行的服务端进程或编辑器进程。实战里我是这么取舍的游戏运行期对象装载稳定直接让工厂持有 shared_ptr不清理编辑器类工具用户可能打开大量文件就用 weak_ptr 版本避免缓存无限增长。5. 踩坑复盘共享失效、对象被污染、缓存失控5.1 症状一缓存池大小等于对象总数我最初在某项目里接了一个“资源去重模块”跑起来发现内存根本没降打印工厂缓存池大小发现池里对象数和粒子总数一样。问题出在 Key 设计上有人把外部状态位置、速度也加进了内部状态组合相当于每个粒子都为自己创建了一个独一无二的“类型对象”共享自然失效。排查链路很简单先确认池数量和业务对象数量是否一致一致就说明 Key 一定混入了随实例变化的数据。定位后把位置、速度移出 Key池子立刻缩到个位数。这个坑最隐蔽的地方是代码看起来一切正常只是内存不降。所以写享元模式时我记得一直强调任何导致 Key 数量线性增长的字段都不属于内部状态。5.2 症状二改了一个对象所有对象跟着变这是“共享”最经典的副作用。某个编辑器 Demo 里我把样式对象设计成可变对象还暴露了 setFont 接口。QA 在调试时改了某个字符的字体结果整篇文档所有字符的字体都变了因为所有 Glyph 引用的都是同一个 CharStyle。解决办法两件事同时做共享对象内部状态全部私有不提供 setter外部统一用shared_ptrconst T从类型系统上禁止对共享状态做修改。真要改样式就按新样式组合去工厂取一个新的共享对象而不是在旧对象上打补丁。“旧对象不能改”这个原则和函数式编程里不可变数据结构的思想是一致的。5.3 症状三缓存无限膨胀有次服务进程运行几天后内存持续上涨打印缓存池大小发现涨到了几十万。查日志发现 key 里的浮点数半径不是预期的固定值而是经过一系列浮点运算后带上了微小误差比如1.0000001f、1.0000002f。每次运算结果略有不同缓存永远积累新对象。修复方式是给半径字段做规范化按 0.01 精度四舍五入取整后再进 Key。浮点字段进享元 Key 前必须规范化这是我现在写工厂代码的第一条纪律。另一个相关教训是从外部配置读取的浮点值也要先过滤 NaN否则会出现“永远找不到旧对象”的隐藏膨胀。5.4 症状四多线程偶发崩溃加了缓存之后逻辑线程和加载线程同时 get崩溃现场在 unordered_map 内部。原因很直白我一开始图省事读路径不加锁只给写路径加锁以为“读多写少没事”。但标准环境下并发读同一个被写入的 unordered_map 本身就是数据竞争。修复方式就是我前面给的 shared_mutex 版本。这里强调一点不要自己手写无锁哈希表条件允许直接用经过考验的并发容器或 shared_mutex把复杂交给成熟工具。崩溃这种东西一次都别想赌。6. 一点个人实践体会享元模式的核心从来不是代码结构多优雅而是对数据的理解。我现在的习惯是写一个新系统前先列字段清单把每个字段按“重复程度、可变性、变化频率”打一遍标签再决定是否引入共享层。这样做的好处是内存问题往往在设计阶段就被挡掉了而不是等压测出来再加班补优化。还有一个每天都在用的小技巧在 Share 对象里放一个静态计数器构造时自增、析构时自减。Debug 构建下打印这个计数器能随时看到共享对象到底被多少实例引用。排查共享失效和生命周期问题的时候这个计数器比任何分析工具都直观。如果你手头也有一个“大量对象反复做同样的事情、内存随便涨”的项目不妨先别急着上复杂算法把重复数据共享出去省下的内存可能比想象中多得多。
阅读完成 · 觉得有帮助?