ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++享元模式实战:从内存优化到并发安全的完整指南

C++享元模式实战:从内存优化到并发安全的完整指南 写这篇文章之前我翻了翻盘里那些已经跑了三四年的老项目发现一个挺有意思的规律真正让性能从“能跑”变成“扛得住”的往往不是某个算法调整而是对对象复用的理解深度。C里的享元模式Flyweight Pattern就是这么个被低估的角色——很多人的认知停在“共享字符串”“共享贴图”的层面但真正把它在游戏服务器、渲染引擎、时序数据库写入链路这些场景里用到位之后我才意识到它远比教科书上那一页复杂。今天不聊理论空转直接把我实际落地过的方案、踩过的坑和设计取舍一起倒出来。这篇内容适合谁如果你写过几个月的业务C知道虚函数、模板、智能指针但总觉得设计模式是“面试八股”又或者你在做高性能服务、游戏服务器、图形相关的项目需要把内存占用砍下去、把对象创建开销压到最低——那这篇文章应该能给你一些可以直接抄作业的思路。我会先把享元模式的状态模型讲透再用三个真实场景拆解它的高级变形最后聊一聊并发环境下的坑和排查方法。1. 先理解享元模式的状态模型为什么共享能成倍降低内存1.1 一块共享字符画出来的内存账先说个最简单的例子。你用std::string给十万个NPC存名字如果每个NPC对象里都有一份“史莱姆”字符串那这十万份字符串的内容完全相同却各自占着独立的内存。哪怕现代std::string有短字符串优化SSO超过一定长度后仍会产生堆分配。用享元模式处理这类问题本质上是把“每个对象各存一份”改成“一份数据对象引用共享”肉眼可见的内存收益巨大。不过这里藏着一个关键点如果只是单纯共享一个对象那和全局单例没什么区别。享元模式真正的高级之处在于状态拆分——把对象的属性分成内部状态Intrinsic State和外部状态Extrinsic State。内部状态存储在享元对象内部可以被多个上下文共享且不会随外部场景变化。外部状态则由客户端保存在调用时传入享元对象的方法。我当时在项目里常用一个比喻帮助新人理解享元对象就像一枚刻好的印章印章本身的字形是内部状态每次盖章用的印泥颜色、盖在什么纸上是外部状态。字形不会变所以全公司共用一枚就够了印泥和纸张每次不同所以要由使用方自己携带。1.2 为什么“共享粒度”是高级应用的分水岭大部分初学享元模式的人脑子里只有“工厂缓存实例、有就返回没有就创建”这一步。这个理解在低并发、少变化的小工具里够用但一搬到生产环境就暴露出问题共享粒度过粗比如把整张纹理作为享元结果纹理内部只有一小块区域是重复用到的大量内存依然被无效占用。共享粒度过细比如把纹理里的每个像素作为一个享元对象管理开销反而压倒节省的内存。状态可变性问题如果内部状态在运行时被修改所有引用方都会受到影响这是最典型的生产事故。所以我在设计享元时会先用数据统计回答三个问题哪些数据重复度最高什么粒度下重复度收益最明显哪些状态必须从共享对象中剥离这套分析做完之后选型自然就清楚了。1.3 一个容易混淆的相邻概念对象池与享元我在团队评审代码时经常看到有人把对象池模式和享元模式混为一谈这两个确实容易混淆。简单梳理一下对象池Object Pool的出发点是避免重复创建和销毁对象的开销关注点是生命周期复用。比如数据库连接池、线程池连接对象在归还后会被清理状态、重新分配给下一个请求。享元模式的出发点是减少重复内存的占用关注点是并发共享。同一个享元对象可以同时被多个客户端使用每个客户端只是各自维护“外部状态”。两者可以结合使用享元工厂内部可以用对象池管理那些创建成本高的资源。我后面在游戏服务器示例里会展示这种结合写法的具体好处。2. 基础实现与工厂设计从一个可复用的C骨架说起2.1 经典三件套Flyweight、FlyweightFactory、Client为了后续讲清楚高级应用先把基础骨架垫在这里。我写代码的习惯是即使是个demo也会把内存生命周期管理一起考虑进去避免后面扩展时到处改。#include iostream #include memory #include string #include unordered_map // 享元基类定义了外部状态的传入方式 class Flyweight { public: virtual ~Flyweight() default; // operation接收外部状态内部状态保存在实现类成员中 virtual void operation(const std::string extrinsicState) const 0; }; // 具体享元类内部状态在这里定义 class ConcreteFlyweight : public Flyweight { public: explicit ConcreteFlyweight(std::string intrinsicState) : intrinsicState_(std::move(intrinsicState)) {} void operation(const std::string extrinsicState) const override { std::cout 内部状态: intrinsicState_ , 外部状态: extrinsicState std::endl; } private: std::string intrinsicState_; }; // 享元工厂负责创建与管理共享对象 class FlyweightFactory { public: // 使用shared_ptr是为了让客户端持有引用同时工厂侧可以安全析构 std::shared_ptrFlyweight getFlyweight(const std::string key) { auto it flyweights_.find(key); if (it ! flyweights_.end()) { return it-second; } auto flyweight std::make_sharedConcreteFlyweight(key); flyweights_[key] flyweight; return flyweight; } size_t size() const { return flyweights_.size(); } private: std::unordered_mapstd::string, std::shared_ptrFlyweight flyweights_; }; int main() { FlyweightFactory factory; auto flyweightA1 factory.getFlyweight(shared_data); auto flyweightA2 factory.getFlyweight(shared_data); // 两个对象的内部状态共享同一块内存 std::cout (flyweightA1 flyweightA2) std::endl; flyweightA1-operation(client-1); flyweightA2-operation(client-2); return 0; }2.2 内部状态与外部状态这样划分才是正确的我在评审代码时发现90%的糟糕实现问题出在状态划分上。这里给出我自己的判定准则第一看状态的“频率”。如果某个字段的取值总数有限且在系统里被反复使用优先考虑作为内部状态。比如角色类型、装备图标ID、Buff类型ID、技能特效ID。第二看状态的“归属”。如果某个字段的值属于某个调用现场或某个业务流程是临时产生的、随着请求变化而变化的那就必须作为外部状态。比如Buff的剩余时间、技能的目标坐标、装备的强化等级。第三如果状态本身值很小但数量巨大且高度重复如bool标记可以先用位压缩再决定要不要共享。共享本身也有成本小字段用std::bitset或位域压缩往往比引入享元更划算。一个容易踩的坑是把外部状态塞进了享元对象里。我曾经在某个模块里为了让调用方少传参数把一个动态变化的坐标写进了享元对象结果两个客户端同时调用时坐标互相覆盖线上出现了角色瞬移的诡异问题。排查了很久才发现是共享对象的状态被污染了。从那以后我的代码规范里明确写了一条享元对象的成员变量必须全部是内部状态外部状态只能通过函数参数传递。2.3 给工厂加上引用计数与弱引用基础版的享元工厂用unordered_mapstring, shared_ptr就够了但在大项目里共享对象的生命周期往往很微妙。场景A创建了对象场景B拿到引用后由于某种原因长期持有导致工厂里的缓存对象一直不释放。反过来如果工厂持有shared_ptr那么只要工厂不销毁所有享元对象都不会释放。我常用的改进方案是让工厂持有weak_ptr客户端使用shared_ptr对外持有class WeakFlyweightFactory { public: std::shared_ptrFlyweight getFlyweight(const std::string key) { auto it flyweights_.find(key); if (it ! flyweights_.end()) { if (auto shared it-second.lock()) { return shared; } // 弱引用已经失效重新创建并替换 } auto flyweight std::make_sharedConcreteFlyweight(key); flyweights_[key] flyweight; return flyweight; } private: std::unordered_mapstd::string, std::weak_ptrFlyweight flyweights_; };这背后的思路是工厂负责协调共享但不在客户端不需要时强行延长对象生命周期。如果所有引用方都释放了这个享元对象就应当销毁下次再需要时重新创建即可。这种方式在高频切换场景比如切换地图、切换UI面板中特别管用避免了大量“无效利用”的对象长期占用内存。3. 高级应用一游戏服务器中技能与状态的批量复用3.1 场景还原十万个野怪同时释放火球术我参与过的一个MMORPG服务器项目一次攻城战里面同时存在七八千个角色每个角色都会释放技能技能又带特效、音效、伤害公式、Buff效果等多份数据。最早版本里每次释放技能都会动态加载一份技能配置结果服务器内存从600MB直接飙到2.3GB而且创建对象的时间还拖慢了战斗服的逻辑帧。问题复盘下来有三大块技能配置里包含了大量不需要每次创建的对象特效路径、动画参数、伤害曲线点集合。同一技能在不同等级下只有数值不同结构完全一样。每个技能实例把内部的伤害计算器、效果脚本上下文都复制了一份。3.2 用享元模式重新设计技能模块我们最终的方案分为三层第一层技能静态模板享元对象。内部状态包括技能ID、技能类型、特效资源路径、音效资源路径、伤害公式ID、Buff效果模板列表。这些字段一旦加载后不再变化。第二层等级数值表外部状态容器。等级对应的伤害系数、施法时间、冷却时间等全部从表格中读取每次施法时按照技能等级取出对应数值与模板组合计算。第三层施法上下文纯外部状态。目标ID坐标位置施法者当前属性快照这些完全不在技能对象上保存。代码结构大致如下struct SkillTemplate { int skillId{}; SkillType type{SkillType::kNormal}; std::string effectPath; std::string soundPath; int damageFormulaId{}; std::vectorBuffEffect buffEffects; // 内部状态Buff结构模板 int calculateDamage(int skillLevel, const CombatSnapshot casterSnapshot) const; }; class SkillTemplateFactory { public: std::shared_ptrconst SkillTemplate getSkillTemplate(int skillId) { // 使用共享锁支持并发读 std::shared_lock lock(mutex_); auto it templates_.find(skillId); if (it ! templates_.end()) { return it-second; } lock.unlock(); // 加锁加载配置 std::unique_lock writeLock(mutex_); // double-check auto it2 templates_.find(skillId); if (it2 ! templates_.end()) { return it2-second; } auto tpl loadTemplateFromConfig(skillId); templates_[skillId] tpl; return tpl; } private: mutable std::shared_mutex mutex_; std::unordered_mapint, std::shared_ptrconst SkillTemplate templates_; };注意这里给享元对象加上了const限定。在游戏服务器这种并发环境下把享元对象设计成不可变Immutable对象是杜绝状态污染最推荐的方式。内部状态在构造时初始化之后再也不允许修改所有变化都通过外部状态传递。实测这样改完内存占用从2.3GB降到800MB左右技能创建对象的时间开销基本归零而且因为配置不再重复加载攻城战的加载帧率也稳定了。3.3 模板与“对象池”结合特效资源的复用上面说技能模板本身是享元那技能特效的渲染资源怎么处理呢特效资源是典型的创建开销大、内存占用大、重复使用频率高的对象。我们的做法是享元对象池组合享元部分特效网格、材质、纹理句柄这些数据全局共享一份。对象池部分每次释放技能时从池中取出一个特效播放器实例复用它的顶点缓冲、状态缓存等资源播放结束归还池中。这相当于把“数据共享”和“实例复用”两个层次分开。数据共享解决的是“同一份资源不要重复加载”实例复用解决的是“频繁创建销毁造成的堆分配碎片和CPU开销”。二者解决的问题不同但配合起来效果相当好——这也是我后来在设计任何高频系统时都会下意识采用的组合拳。4. 高级应用二渲染引擎中的字模与网格缓存4.1 文本渲染场景一个城市几十万条飘字另一个让我印象深刻的案例是UI系统的飘字模块。游戏里战斗飘字、任务提示飘字、系统公告一秒内可能生成大量短文本每个文本都要走一遍字体渲染管线。如果每个文字都独立保存一份渲染资源比如字体纹理、字形轮廓、着色器参数那内存开销和显存带宽都会爆炸。传统做法是使用字体图集Font Atlas但这只是把字形贴图共享起来进一步用享元模式是把“字符字形”这一层也抽象成共享对象每个不同的字符如’A’、’你’、’好’对应一个享元实例字符的像素尺寸、间距、位置则作为外部状态。class Glyph { public: virtual void draw(const GlyphContext context) const 0; virtual ~Glyph() default; }; class CharacterGlyph : public Glyph { public: CharacterGlyph(wchar_t ch, std::shared_ptrFontAtlas atlas) : char_(ch), atlas_(std::move(atlas)) {} void draw(const GlyphContext context) const override { // 使用外部状态中的位置、颜色、缩放 atlas_-drawChar(char_, context.position, context.color, context.scale); } private: wchar_t char_; // 内部状态字符编码 std::shared_ptrFontAtlas atlas_; // 内部状态共享图集 }; class GlyphFactory { public: std::shared_ptrconst Glyph getGlyph(wchar_t ch) { auto it glyphs_.find(ch); if (it ! glyphs_.end()) return it-second; auto glyph std::make_sharedCharacterGlyph(ch, atlas_); glyphs_[ch] glyph; return glyph; } private: std::shared_ptrFontAtlas atlas_; std::unordered_mapwchar_t, std::shared_ptrconst Glyph glyphs_; };这套设计看起来简单但在飘字这种高频场景里价值非常大。每个飘字实例不再持有字形数据而是持有一串字形ID引用和各自的位置信息。字符数量有限Glyph实例总量是收敛的而飘字实例可以成千上万。4.2 共享网格与材质实例GPU资源省一半渲染引擎里还有一种常见的享元应用网格Mesh与材质Material的共享。多个模型如果使用同一个静态网格数据我们可以让DrawCall只引用一个网格实例多个游戏物体如果复用同一套材质参数那么材质参数也可以做成共享对象。这里有三个细节值得注意模型网格数据是典型的内部状态项目里应尽量保证网格加载唯一化。我曾经见过一个项目同一个敌人模型因为走不同加载路径被加载了两遍内存直接翻倍。后来引入了模型资源全局登记表本质上就是享元工厂按资源路径做Key问题立即消失。贴图纹理资源是显存占用的主要来源把纹理按ID共享后显存占用立竿见影地下降。这里同样建议设计成不可变对象避免运行时修改像素数据导致所有引用方异常。材质参数中的实例属性如变色、透明度应作为外部状态不能写进共享材质对象里。比如大量怪物被打中时闪白的效果如果直接修改共享材质的颜色那所有同一材质的怪物都会同时变白。正确的做法是给单个怪物实例一个“材质覆盖参数”在渲染时临时叠加在共享材质之上。我经历过一次“所有怪物同时闪白”线上事故就是因为材质对象作为享元被共享时某个怪物死亡后把材质透明度改了。从那之后“享元必须只读”这条原则刻进了我的肌肉记忆。4.3 缓存失效策略LRU与引用计数的取舍网格和字模这类资源文件数量不会是无限的但也不是所有资源都会长期使用。如果享元工厂里的缓存只增不减随着关卡切换、UI面板变化垃圾资源会堆积。我在项目中尝试过两种方案。第一种是按引用计数回收当shared_ptr引用计数降为0时通知资源系统释放GPU侧内存。这要求工厂不能持有shared_ptr只能持有weak_ptr否则永远等不到0。第二种是LRU缓存淘汰给每个资源记录最后访问时间戳缓存超过N个时淘汰最久未使用的。这种方案在UI系统中更合适因为UI资源虽然会频繁切换但很多旧资源很快又会被用到LRU能够平衡内存和命中率。两种方案没有绝对优劣取决于业务场景。对于需要严格保证重复利用的对象推荐弱引用引用计数对于容量有上限、热切换频繁的场景推荐LRU。5. 高级应用三数据绑定中的参数模板与预编译批量写入5.1 场景向时序数据库批量写入同一结构的监测数据这个案例来自我做过的一个物联网监测平台。平台需要把几万个传感器的监测数据持续写入时序数据库当时用的TDengine。最初版本每条记录都单独拼接SQL字符串再执行写入两个问题特别刺眼CPU被字符串拼接逻辑烧掉一大截数据库端的预处理次数太多。后来调研时发现TDengine提供了参数绑定写入接口taos_stmt_prepare相关核心思路是预先准备一个写语句模板然后反复绑定不同的参数值批量提交。这和享元模式一拍即合写语句模板作为享元内部状态包括表名、字段名、数据类型结构。每次写入的具体值是外部状态通过taos_stmt_bind_param动态绑定。把这段逻辑抽成通用的“参数模板”类之后数据接入层的内存占用、CPU开销双双下降批量写吞吐量提升了三倍多。5.2 一个通用的参数模板封装下面我给一个简化片段展示如何把“模板”与“数据行”分离。这里故意把底层细节省略重点放在享元结构上。class InsertTemplate { public: InsertTemplate(std::string tableName, std::vectorFieldMeta fields) : tableName_(std::move(tableName)), fields_(std::move(fields)) {} // 外部状态通过参数传入模板内部只有结构 bool bindAndExecute(const std::vectorValue values, TAOS* stmtHandle) { // 与具体数据库绑定的逻辑这里略去 // 核心是每行数据来临时复用模板结构逐个绑定values return doBindAndExecute(tableName_, fields_, values, stmtHandle); } private: std::string tableName_; std::vectorFieldMeta fields_; bool doBindAndExecute(const std::string table, const std::vectorFieldMeta fields, const std::vectorValue values, TAOS* stmt) const { // 实际绑定逻辑... return true; } };5.3 为什么这套封装在数据链路里格外珍贵批量写入场景天然符合享元模式的两个前提结构相同、数据量巨大。如果把每个表、每个写入格式都当成一个独立的模板对象缓存起来那么几千张表的模板最多也就几千份而实时数据行是几百万上千万的量级。模板复用把“每行数据都要解析一遍表结构”变成了“每行数据只需携带纯值”。这里我还总结了一个小原则能用模板解决的问题不要用运行时反射或字符串拼接。模板不仅在编译期能验证类型在运行时也减少了字符串解析开销。你仔细想想数据库预处理语句、protobuf的MessageFactory、JSON序列化的Schema缓存本质上都是享元思想的某种变体——把“结构”存一份把“数据”反复填。6. 享元与并发线程安全下的共享对象管理6.1 为什么“共享”在并发下会翻车享元对象一旦被多个线程同时访问就必须考虑线程安全。你要记得对象被共享意味着竞态条件从源头引入。如果享元对象是只读的那并发访问天然安全这也是我反复推荐不可变设计的原因。但如果内部状态是可变且可修改的比如某个配置项在运行时热更新那就必须加同步机制。一种常见的妥协方案是“写时复制”Copy-on-WriteCOW。在C中可以用std::shared_ptrconst T配合写时替换线程想修改配置时先复制一份对象修改完成后原子替换指针。读线程无需加锁写线程通过std::atomic_load/store完成指针切换。这种方式在“读多写少”的场景下性能极高。6.2 一个带原子指针的读多写少享元工厂演示一下我最常用的COW写法class ConfigFlyweight { public: explicit ConfigFlyweight(int value) : value_(value) {} int getValue() const { return value_; } private: int value_; }; class ConfigFlyweightManager { public: ConfigFlyweightManager() : current_(std::make_sharedconst ConfigFlyweight(0)) {} std::shared_ptrconst ConfigFlyweight get() const { return std::atomic_load(current_); } void update(int newValue) { auto newConfig std::make_sharedconst ConfigFlyweight(newValue); std::atomic_store(current_, std::move(newConfig)); } private: std::shared_ptrconst ConfigFlyweight current_; };每次更新都会创建一个新对象但更新频率极低而读取频率极高所以总体开销可控。这种做法把“读锁”完全消除了——读线程拿到的始终是一个一致不变的快照。6.3 加锁粒度与哈希桶改造有些享元工厂必须在创建时加锁比如首次加载配置需要读文件、解析JSON。这种场景下用一个大锁std::mutex包住整个unordered_map在高并发下会形成瓶颈。我实测过一个QPS大约3w的工厂打开大锁后吞吐直接掉到8000等锁时间占了总耗时的60%。解决办法是分段锁。把Key哈希后分成N个桶每个桶一把锁class StripedFlyweightFactory { public: std::shared_ptrconst Flyweight get(const std::string key) { size_t idx hasher_(key) % kBucketCount; auto bucket buckets_[idx]; std::shared_lock readLock(bucket.mutex); auto it bucket.map.find(key); if (it ! bucket.map.end()) { return it-second; } readLock.unlock(); std::unique_lock writeLock(bucket.mutex); auto it2 bucket.map.find(key); if (it2 ! bucket.map.end()) { return it2-second; } auto fw loadFlyweight(key); bucket.map.emplace(key, fw); return fw; } private: struct Bucket { mutable std::shared_mutex mutex; std::unordered_mapstd::string, std::shared_ptrconst Flyweight map; }; static constexpr size_t kBucketCount 64; std::arrayBucket, kBucketCount buckets_{}; std::hashstd::string hasher_; };这里用了经典的“双检查锁”思路先读锁查缓存没有再用写锁加载并二次确认。分段锁让不同Key的创建互不阻塞实际效果几乎线性扩展。7. 常见问题与排查技巧实录7.1 状态污染所有引用方都看到同一个错误值现象某个全局属性被修改后所有使用同一享元对象的客户端都出现了异常数据报错位置还不固定排查起来极度迷惑。排查方法先看享元对象的成员变量是否被外部直接修改。在代码里搜索是否有非const方法被调到。如果你用的是C还可以临时把所有享元对象声明为const编译器会直接指出哪里违反规则。根治方案享元对象设计为不可变所有成员变量只允许在构造函数中初始化需要变化的数据放进外部状态结构体通过方法参数传入实在需要修改内部配置时采用6.2节的COW替换方式。7.2 生命周期泄漏工厂持有了不该持有的强引用现象内存持续上涨但具体对象似乎又不再被业务使用伸手进去分析堆看到大量享元对象无处释放。原因工厂内部用shared_ptr持有享元对象导致引用计数永远不为0。方案把工厂持有的指针换成weak_ptr见2.3节。同时给享元对象增加“最后访问时间”字段定期扫描清理长时间未使用的缓存项。补充经验在排查生命周期问题时我习惯用std::shared_ptr的use_count()快速检测当前持有者数量。若发现某个对象的use_count长时间异常高多半是工厂或者某个全局单例在暗中持有。7.3 过度共享共享方案让性能更差现象引入享元后内存确实降了但CPU反而上涨整体吞吐下降。原因共享粒度太细导致每次操作都要拼接外部状态、做哈希查找缓存命中率反而降低。排查方法对共享对象访问路径做热点统计。用perf或简单的计数采样看看每次调用在工厂查找、状态组装上的耗时占比。如果这部分超过15%就要考虑放大共享粒度或者改用对象池方案。经验不是所有重复对象都要共享。只有两种场景收益最大一是对象创建成本高涉及IO、解码、分配大内存二是重复实例数量极大且内存成本高昂。简单的PVC纯值对象共享反而得不偿失。7.4 外部状态的传参成本失控现象外部状态结构体越塞越大每次调用都要拷贝一份拷贝开销抵消了共享收益。方案外部状态用指针或const传递必要时可以把多个外部状态合并为一个上下文对象如DrawContext、SkillContext减少参数数量与拷贝成本。这里的取舍很实在外部状态和内部状态的划分不只是逻辑正确性问题还直接影响运行性能。我见过有人把一整个渲染参数结构体几十个字段作为外部状态传进去每次更新都是拷贝一个大对象最后得不偿失。8. 什么场景不适合享元模式我的取舍经验很多文章都在讲怎么用享元但真正让我项目受益的反而是知道什么时候不用它。第一种不该用的场景是对象数量本来就是少量且稳定的。比如某个全局配置类整个系统只有三五个实例完全没有共享的必要引入享元反而让代码结构变复杂。第二种是数据重复率极低的场景。如果大部分业务数据的key都不相同共享表会变成一个巨大的、永远命中不了的哈希表白白占用内存且每次查找都很慢。享元模式的好帮手是“重复”没有重复就没有收益。第三种是状态变化频繁、外部状态过重的场景。如果外部状态本身比内部状态还大那每次调用光组装外部状态就够呛共享的收益被蚕食殆尽。此时应该优先考虑值对象、结构体重组或者缓存整条计算结果。最后一种是我现在特别关注的过度设计。有些新人为了“展示设计模式”把所有配置都套上一层享元工厂。代码看起来高大上实际维护时到处找引用、担心状态污染反而拖慢迭代。我的原则是先衡量重复度再上享元如果数据生命周期短、数量可控直接用普通值对象最清爽。如果做减法之后确实需要享元我一般会走这几个步骤先统计重复度与内存占用确定状态划分再把对象设计为不可变最后考虑并发访问方式与缓存淘汰策略。一套流程走下来基本不会出大问题。最后再分享一个实操中的小技巧当你怀疑一个共享对象是否应该被设计成不可变时直接在类上加const限定再编译编译器报错的位置就是所有可能造成状态污染的地方。这个技巧在重构老代码时特别有用比代码评审和人肉排查高效得多。享元模式不是银弹但用对场景、用对粒度、管好状态和生命周期它能让你在高压场景下省出大量内存和CPU这是我在多个项目里反复验证过的一件事。
返回列表