如果你在项目里见过这样一种崩溃场景循环中创建了大量对象每个对象都带着一整套看似“自己专属”的数据结果内存曲线一路上涨运行速度也跟着变慢——那这篇就是写给你的。我之前在一个地图渲染 demo 上就栽过这种跟头地图上要种几千棵树我直接给每棵树都 new 了一个完整对象每个对象里都有模型、纹理、材质参数跑起来没几秒内存就爆了。后来重构时用了享元模式Flyweight Pattern才真正把“重复创建”的开销压了下去。这篇文章我打算用 C 把享元模式彻底讲透先从一个内存爆炸的实际案例入手拆解什么是内部状态和外部状态再给出可以直接抄的工厂实现、完整的 C 代码最后聊聊多线程、生命周期、以及它和对象池、缓存、单例这些模式的区别。适合刚开始学设计模式、或者写 C 项目时遇到内存和对象数量问题的读者看完可以直接动手重构自己的代码。1. 让我内存爆炸的那个树木 Demo对象重复到底浪费在哪先交代一下当时的问题背景。我在做一个小型地图渲染 Demo准备在场景里放上千棵树。最直白的实现是什么很简单遍历地图坐标遇到树的位置就 new 一棵树对象然后每棵树自己保存网格数据、纹理句柄、材质参数、碰撞体……听起来好像没什么不对每个对象各管各的嘛。但跑起来之后内存占用直接把我吓到了。一千棵树我的内存占用已经接近 1GB而且还在涨帧率也跟着往下掉。问题出在哪我冷静下来算了笔账。一棵普通的树如果它有自己的顶点数据假设 2000 个顶点每个顶点位置要 12 字节、法线 12 字节、UV 8 字节加起来差不多 80KB 左右再配一张 256×256 的 RGBA 纹理在 CPU 侧就是 256KB再加上材质参数、包围盒、物理碰撞参数单棵树一个完整对象干到 0.5MB 一点不夸张。一千棵树就是 500MB 到 1GB内存不爆才怪。但你仔细想这一千棵树的逻辑真的“各不相同”吗树的位置、缩放、旋转是每棵不一样的这没错可树的长相——三角网格、纹理、材质参数——在这个场景里只有那么几个树种绝大多数树的网格和纹理都是一模一样的。也就是说我复制了一千份完全相同的“树的样子”只是为了在每个对象里顺手存一个自己专属的坐标。这就像全班五十个同学每个人手里都拿着同一份教材的完整复印件只是为了在扉页上写各自的名字。更好的做法当然是把公共教材放在班里供所有人用每个人手里只需要拿自己的“姓名贴”和“座位表”。这个现象的本质就是对象内部存在两部分信息一部分是跟具体环境无关、可以大家共享的另一部分是跟着调用场景变化、不能共享的。我当时的做法是没区分这两类信息把所有东西一股脑塞进每个对象里造成了大量重复存储。享元模式要解决的正是这个问题。很多讲设计模式的书喜欢用“大量细粒度对象”这个说法来定义享元模式听起来很学术翻译成人话就是当你有一大堆结构类似的实体时不要每个实体都保存一份完整数据而是把公共的那部分提取出来做一个共享对象让所有实体引用它每个实体自己只保留那些“换一个场景就变一次”的少量参数。这样内存里就只有一份公共数据而不是上千份。在 C 项目的实际视角里这段话意味着两点第一要区分“状态”的类型第二要有一个统一的获取共享对象的入口。我在 tree demo 里乱来的方式就是没有第二点直接各 new 各的。而享元模式通过一个工厂来管理共享对象所有需要“树的样子”的地方都去工厂那里领取同一个对象再从外部把自己独有的位置传进去问题就解决了。2. 享元模式的核心思路把对象拆成“共享的”和“私有的”2.1 内部状态与外部状态怎么切才是对的在享元模式里这两个“状态”是有固定术语的。内部状态intrinsic state是对象自身固有的、不随环境变化的那些数据适合被共享例如树的网格和纹理、字符的字形、咖啡的配方。外部状态extrinsic state是随着使用场景变化的那些数据必须由调用方在调用时传入例如树的位置和缩放、字符在文档里的坐标和颜色。为什么这个区分这么重要因为一个对象如果既包含内部状态又包含外部状态你把它共享出去就非常危险A 处改一下坐标B 处看到的坐标也变了。所以设计时的纪律是共享对象里只放内部状态而且要让它尽量不可变所有外部状态不放进共享对象而是通过方法参数传给享元对象由调用方自己持有。我举个生活化的例子。大学里的选修课老师会把课件和教学大纲放在公共网盘里全班共享同一个下载链接但每个同学的选课时间、所选班级、考勤记录这些东西属于个人状态肯定不能写在共享课件里。上课时老师问“你是什么时候选的课”你得自己报出来而不能指望共享课件告诉你。把一个字符画在屏幕上的道理也是一样字符长什么样是共享的字形数据但画在哪个位置、用什么颜色必须调用方现场告诉它。2.2 三个角色一个都不能少用 C 落地享元模式需要三个核心角色Flyweight享元接口定义操作接口操作里接收外部状态参数。对应到 C通常是一个抽象基类接口方法带上下文参数。ConcreteFlyweight具体享元类实现共享对象保存内部状态内部状态通常是只读的。FlyweightFactory享元工厂维护一个池子按 key 查找对象找不到就创建新对象放进池子再返回给调用方。这个池子常见实现是unordered_mapKey, shared_ptrFlyweight。调用方的职责很明确它持有外部状态在使用享元对象时把外部状态通过参数传进去。也就是说C 版本里内存大头在共享对象里而真正数量庞大的小实体比如成百上千棵树只是一些坐标数据加上指向共享对象的引用或指针。这里有一个初学者最容易混淆的点外部状态到底放在哪里答案是不放在享元对象里。如果你发现某个字段在每次调用时都会变那就别把它放进共享类要么由调用方局部变量保存要么从参数传进去要么放进一个独立的“上下文”结构体。我自己刚开始学的时候就很想把坐标塞进共享树对象里结果画出来的树全堆在同一个位置因为所有实例共享的是同一份坐标字段。这个坑后面会在代码里体现得更具体做完一个完整示例你就能理解这个约束有多重要。3. 用 C 实现享元工厂从字符编辑器到树木渲染3.1 经典例子文本编辑器里的字符对象我最喜欢用文本编辑器来演示享元模式因为它是这个模式最初的经典案例也特别容易理解。假设你在做一个支持彩色富文本的编辑器文档里有十万个字符每个字符都要能渲染。最直白的做法是为每个字符创建一个对象里面存字符值、字体、字号、颜色、坐标。十万个字符就是十万个完整对象光是对象头加上动态内存分配的开销就够项目受的。改用享元模式后思路变成字符本身的字形是内部状态同一个字符在世界里只有一份共享对象而字号、颜色、坐标是外部状态由渲染循环临时传入。下面是一段可以直接编译运行的示例我用char当 key工厂负责维护共享池。#include iostream #include memory #include unordered_map #include string // 享元接口外部状态通过参数传入 class Glyph { public: virtual ~Glyph() default; virtual void draw(int x, int y, int color, int fontSize) const 0; }; // 具体享元对象只保存字形这个内部状态 class CharacterGlyph : public Glyph { public: explicit CharacterGlyph(char ch) : ch_(ch) {} void draw(int x, int y, int color, int fontSize) const override { std::cout draw ch_ at ( x , y ) color color fontSize fontSize \n; } private: char ch_; // 内部状态只读 }; // 享元工厂维护共享对象池 class GlyphFactory { public: std::shared_ptrGlyph get(char ch) { auto it pool_.find(ch); if (it ! pool_.end()) { return it-second; } auto glyph std::make_sharedCharacterGlyph(ch); pool_[ch] glyph; return glyph; } size_t size() const { return pool_.size(); } private: std::unordered_mapchar, std::shared_ptrGlyph pool_; }; int main() { GlyphFactory factory; const std::string doc hello world hello flyweight hello c; int x 0; for (char ch : doc) { auto glyph factory.get(ch); glyph-draw(x, 100, 0xFF0000, 12); // x、y、颜色、字号都是外部状态 } std::cout shared object count: factory.size() std::endl; return 0; }运行之后你会发现虽然文档里有三十几个字符但工厂池里共享对象数量只有十几二十个左右因为重复出现的字符都复用了同一个对象。这就体现出了核心价值十万字符的文档理论上只需要有限的字符字形对象其余全是坐标、颜色这类轻量参数内存和处理压力都小了很多。3.2 复合 key当共享维度不止一个上面例子里的 key 是单个字符现实中共享维度往往不止一个。比如渲染文本时你可能希望“字体 A 字号 12 红颜色”这样一个组合是共享的而不是每个字符都新建格式对象。这种情况下key 就要从char扩展成一个复合结构体。复合 key 的常见做法是自定义一个结构体然后为它提供哈希函数和相等比较让unordered_map能用它做查找。#include cstddef struct TextStyleKey { int fontId; int colorId; int fontSize; bool operator(const TextStyleKey other) const { return fontId other.fontId colorId other.colorId fontSize other.fontSize; } }; struct TextStyleKeyHash { std::size_t operator()(const TextStyleKey key) const { std::size_t h 17; h h * 31 static_caststd::size_t(key.fontId); h h * 31 static_caststd::size_t(key.colorId); h h * 31 static_caststd::size_t(key.fontSize); return h; } };这样工厂里就可以声明成std::unordered_mapTextStyleKey, std::shared_ptrFlyweight, TextStyleKeyHash。实际项目中使用字符串拼接来构造 key 也可以比如fontId_colorId_size简单但会有临时字符串开销自定义结构体更高效也更符合 C 的工程习惯。需要注意的是key 的哈希函数要保证稳定否则散列表性能会退化成链表。3.3 回到树木渲染一张图画明白外部状态的传法把上面的字符例子映射回树木渲染的场景就很清晰了。树的共享对象是TreeType存放网格、纹理、材质参数等内部状态工厂通过get(pine)或get(oak)返回对应树种而每棵具体的树是一个轻量结构体只保存坐标、缩放、旋转以及一个指向TreeType的指针或引用。struct TreeContext { float x 0.0f; float z 0.0f; float scale 1.0f; float rotationY 0.0f; std::shared_ptrTreeType type; // 指向共享对象 }; void render(const TreeContext tree) { tree.type-draw(tree.x, tree.z, tree.scale, tree.rotationY); }这样一来一千棵树里真正占内存的是几百个TreeType共享对象而一千个TreeContext加起来只是几十 KB 的坐标数据。当初我那种内存爆炸的情景在结构上彻底消失了。4. C 实现享元的几个魔鬼细节生命周期、线程安全与接口陷阱4.1 工厂返回什么指针、引用还是 shared_ptrC 里用享元模式绕不开一个资源管理问题工厂返回的对象该用什么所有权语义。如果共享对象和工厂生命周期相同、不需要销毁可以返回裸指针简单直接但调用方很难判断指针什么时候失效如果想让工厂统一持有对象、调用方只借用那么返回裸指针或者const引用就够了如果调用方可能比工厂活得久或者你想让对象在没人用时自动释放返回std::shared_ptr更稳妥。我个人的默认选择是工厂内部用shared_ptr持有享元对象对外返回shared_ptrconst Glyph或const Glyph。这样外部拿到的引用永远不会悬空而且因为接口是const外部没有编译期的机会去修改共享对象的内部状态。这个设计在代码review时非常好用谁要是想往共享对象里写外部状态编译器直接拦下来。4.2 内部状态一定要只读别暴露 setter这一条怎么强调都不为过。共享对象最怕被“顺手改一下”。内部状态一旦被修改所有引用它的对象都会一起变化而且这种 bug 往往特别隐蔽你只是在一处改了颜色结果整个文档的字符颜色全变了在一处改了树的纹理全地图的树都换了装。排查起来极其痛苦因为代码逻辑完全没有断点只是行为异常。C 层面的防护手段有两个一是把内部成员声明为const比如const char ch_构造函数里初始化后就不能再改二是不提供任何 setter只提供 getter 或者行为方法。两者的核心思路是相同的把“共享的内容”变成不可变数据外部状态永远走参数。我在项目里还有一个习惯就是把内部状态放到private区域并通过const引用提供访问进一步减少外部误改。4.3 多线程并发创建阶段上锁使用阶段免锁很多人一上来就问享元模式在多线程里会不会有问题。答案是如果你的内部状态是不可变的那么一旦共享对象创建完成所有线程并发读取它都是安全的真正需要加锁的只有工厂创建新对象的那个阶段。最稳妥的实现是让get函数全程加锁std::shared_ptrGlyph GlyphFactory::get(char ch) { std::lock_guardstd::mutex lock(mutex_); auto it pool_.find(ch); if (it ! pool_.end()) { return it-second; } auto glyph std::make_sharedCharacterGlyph(ch); pool_[ch] glyph; return glyph; }这段代码在并发调用下不会有问题而且因为锁粒度很细创建对象也只是一次unordered_map的插入开销完全可以接受。如果你对性能特别敏感可以使用先无锁查找、未命中再加锁二次查找的双检锁写法但要注意C 里手写双检锁很容易出错涉及内存序和指令重排建议除非有明确瓶颈否则就用简简单单的全局锁。很多场景下真正的热点根本不在创建上而在于外部状态的传递和渲染计算上。4.4 别让 key 的设计毁掉共享价值有一次我在某个图像处理 Demo 里看到有人用享元模式给像素点做缓存把每个像素的 RGB 和位置全都拼成 key最后池子里的对象数量和像素数量一样多共享了个寂寞。这个例子很极端但它说明了一个严肃的问题内部状态和外部状态切分错了key 的粒度就跟着错了。正确做法是key 里只放不会随场景变化、并且在多个地方重复出现的维度。比如文本渲染字体是共享维度颜色如果变化很多就不适合全部塞进共享对象否则池子爆炸相反如果颜色只有几种固定主题色把它放进 key 里做共享也是合理的。工程上没有绝对答案但有一条判断标准很好用如果某个 key 在系统里只出现一两次那它就不该作为共享维度的核心考虑把它挪到外部状态里。5. 三分钟区分享元、对象池、缓存别再搞混了写过几个项目之后你会发现身边很多模式长得都差不多。尤其是享元模式、对象池、缓存、单例经常被混为一谈但它们的核心目标完全不同。我把它们放在一起对比过下面这个思路对新手最友好。对象池的核心是复用“创建/销毁成本高”的对象使用模式是“借出去用完还回来”。典型例子是数据库连接池连接对象不能销毁用完之后归还下个请求继续用。它强调的是资源的周转和复用对象在一个时间段内只属于一个调用方。缓存的本质是“计算结果或加载结果的存储”避免重复计算或重复加载。比如一个函数每次计算都很耗时你把结果按输入参数存进unordered_map下次同样参数直接返回。缓存跟享元非常像但缓存里的对象不一定被多个调用方共享为同一个实体缓存更关注“用 key 换速度”。那么享元到底是什么它强调的是“多个地方共享同一个逻辑实体”。共享池里的对象不会被“归还”而是长期被持有调用方不拥有对象的所有权只是在需要时用同一个共享对象。以字符串驻留为例很多语言和运行时会把相同内容的字符串映射到同一块内存这就是典型的享元思路。C 标准库里虽然不会自动对std::string做驻留但如果你用static const char*常量池、或者手工维护一个字符串到共享对象的映射表本质上也在做同样的事。用一个口诀来区分问自己“这个对象用完要不要还回去”要还是对象池“这个 key 是否代表一个逻辑上可共享的实体”是享元“是不是只是避免重复计算”那就用缓存。单例更简单全局只有一份享元是“同一类实体共享一份不同 key 可以有多份”。把这几层理清楚就不会出现“用对象池的思路做享元”这类张冠李戴的问题了。6. 我在实际项目里用享元模式最顺手的位置以及一次完整的踩坑记录6.1 适合用享元的三个典型位置第一是图形和游戏领域。粒子系统是最好的例子一个场景里可能同时有几万粒子每个粒子的速度、位置、生命值都不同但粒子的外观类型、贴图、混合模式就那么几种。如果你为每个粒子都复制一份外观数据内存开销是灾难性的。把“粒子类型”做成共享对象每个粒子只保存一个类型指针加自己的动态属性是最经典的享元应用。第二是文本和表格处理软件。字符字形、段落格式、主题配色这类数据天然适合共享。甚至在服务端拼装报表时经常有大量行使用相同模板模板就是共享对象行的具体内容走外部状态。第三是网络和数据库服务端的连接配置对象。注意连接池本身是对象池但是连接所绑定的数据库地址、超时时间、认证参数这种配置信息可以被大量连接共享不需要每个连接对象都存一份完整连接串。6.2 我踩过的一个经典大坑把外部状态写进了共享对象有一次我做字符排版功能需要给“文字”加边框高亮效果。按享元思路字符的边框样式应该是外部传入的渲染时每次使用不同颜色。但我图省事直接在共享的字符字形类里加了一个currentHighlightColor成员想着渲染函数用到它时临时改一下。结果整个文档的高亮色全变成最后设置的那个颜色而且因为对象共享不同字符互相“串色”。当时排查了很久后来才意识到共享对象里根本不应该有current这类的可变字段。那次之后我对享元对象定了一个硬性规矩内部状态不是不可变就直接拆出去。任何看起来像“临时状态”的字段都不允许出现在共享类里。如果你想在渲染时带颜色就把它作为参数传给draw()如果参数太多就封装一个DrawContext结构体把颜色、偏移、缩放这些外部状态一股脑放进去。6.3 享元不是银弹什么情况下别用用享元模式前请先问自己四个问题你的系统里真的有大量结构相似的实体吗如果只有几十个对象共享节省的内存微乎其微反而增加了工厂和 key 的复杂度。能比较清晰地切分内部状态和外部状态吗如果两者纠缠不清硬拆反而会让代码更难以理解。共享对象的内部状态能保证只读吗如果这个对象经常需要修改享元模式会增加并发控制和缓存失效的成本。外部状态的传递成本可控吗如果每次调用都要构造一个很大的上下文对象省下的内存可能又被上下文开销抵消了。这四个问题如果有一个不满足我建议先别急着用享元。设计模式是工具不是勋章一个模式用得好不好不看你套用了多少个而看你的代码维护和性能有没有真正受益。最后分享一个小习惯在 C 项目里我会把享元工厂的get方法命名成类似acquire或lookup这样会读的词而不是create。因为create会暗示每次调用都会生成新对象容易让代码的调用方误以为自己在创建独立实例。而acquire清楚地表达了“从共享池里取一个”语义准确团队协作时也少踩坑。这些细节堆起来才是真正把一个模式用好的状态。
阅读完成 · 觉得有帮助?