☰
C++享元模式实战:从粒子系统与文本渲染看内存优化
2026/10/17 20:33:26 网站建设 项目流程

最近做的一个渲染模块里,粒子数量从几千涨到五万之后,内存以肉眼可见的速度往上飙,每帧还在频繁地 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. 这个字段在不同实例间是否大量重复?重复率高,优先考虑放进内部状态。
  2. 这个字段是否创建后就不变?比如纹理路径,创建粒子类型时定了,后面不会再改,适合内部。
  3. 这个字段是否随实例变化、变化频繁或需要独立控制?比如粒子位置,每帧都在变,必须外部状态。

拿棋子举例:棋子的形状、颜色是内部状态,同一兵种所有棋子共享一份图形资源;而棋盘上的行、列坐标是外部状态,每个棋子自己持有。经典得有点俗,但确实是一句话就能讲清楚的分界线。

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_ptr<const CharStyle>。只要共享对象不可变,就从根本上避免了一个使用者改了内部状态、所有使用者一起被污染的问题。

2.2 工厂缓存的 Key 设计:字符串、浮点数和 hash 的坑

享元工厂本质是一个缓存:给定内部状态组合,返回已有对象,没有就创建。C++ 里最常用的容器是std::unordered_map,Key 就是内部状态的组合。

Key 设计有几个坑值得先说:

  • 不要把 Key 定义成一个大字符串拼接。我之前见过有人把“字体名+空格+字号+空格+颜色”拼成一个 string 当 Key,运行正常,但每个 lookup 都要做一次字符串拼接,缓存里还复制了一份完整字符串,凭空增加开销。正确做法是定义结构体,自己写operator==和哈希函数。
  • 哈希函数不要偷懒用std::hash<long 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::mutex:

std::shared_ptr<const ParticleType> get(...) { std::lock_guard<std::mutex> lock(mutex_); auto it = cache_.find(key); if (it != cache_.end()) return it->second; auto type = std::make_shared<ParticleType>(...); cache_[key] = type; return type; }

这个写法安全、简单,缺点是所有 get 全串行。并发量上来以后,读操作远多于写操作,可以用std::shared_mutex(C++17)做读写分离:

std::shared_mutex mutex_; std::shared_ptr<const 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_shared<ParticleType>(...); 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_ptr<const CharStyle>,也就是共享引用:

struct CharPosition { size_t row; size_t col; char ch; }; class Glyph { public: Glyph(std::shared_ptr<const 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_ptr<const CharStyle> style_; // 内部状态,共享 CharPosition pos_; // 外部状态,独立 };

工厂负责按内部状态组合去缓存区查对象:

class CharStyleFactory { public: std::shared_ptr<const CharStyle> getStyle( const std::string& font, int size, unsigned color, bool bold) { StyleKey key{font, size, color, bold}; std::lock_guard<std::mutex> lock(mutex_); auto it = cache_.find(key); if (it != cache_.end()) return it->second; auto style = std::make_shared<CharStyle>(font, size, color, bold); cache_[key] = style; return style; } size_t cacheSize() const { std::lock_guard<std::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::hash<std::string>{}(key.font); h ^= std::hash<int>{}(key.size) + 0x9e3779b9 + (h << 6) + (h >> 2); h ^= std::hash<unsigned>{}(key.color) + 0x9e3779b9 + (h << 6) + (h >> 2); h ^= std::hash<bool>{}(key.bold) + 0x9e3779b9 + (h << 6) + (h >> 2); return h; } }; mutable std::mutex mutex_; std::unordered_map<StyleKey, std::shared_ptr<const 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::vector<Glyph> 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:纹理路径、网格路径、粒子半径,属于内部状态,同一类型的粒子全部共享。
  • Particle:x、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_ptr<const 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_ptr<const 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::hash<std::string>{}(key.texture); h ^= std::hash<std::string>{}(key.mesh) + 0x9e3779b9 + (h << 6) + (h >> 2); h ^= std::hash<float>{}(key.radius) + 0x9e3779b9 + (h << 6) + (h >> 2); return h; } }; class ParticleTypeFactory { public: std::shared_ptr<const 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_shared<ParticleType>(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_map<ParticleTypeKey, std::shared_ptr<const 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::vector<Particle> 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_ptr)
5 万粒子内存约 3.5 MB约 1.5 MB
20 万粒子内存约 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_ptr<const 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 构建下打印这个计数器,能随时看到共享对象到底被多少实例引用。排查共享失效和生命周期问题的时候,这个计数器比任何分析工具都直观。

如果你手头也有一个“大量对象反复做同样的事情、内存随便涨”的项目,不妨先别急着上复杂算法,把重复数据共享出去,省下的内存可能比想象中多得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询