享元模式:对象太多时,怎么节省内存

张开发
2026/6/11 15:44:33 15 分钟阅读

分享文章

享元模式:对象太多时,怎么节省内存
上一篇讲了组合模式。它解决的问题是当对象天然是树形结构时如何统一处理单个对象和组合对象。比如 HMI 菜单树、车辆配置树、诊断服务树、测试用例树、权限树都适合用组合模式来表达。组合模式的重点是让整棵树和单个节点看起来像同一种对象。但真实工程里还有另一类问题也很常见对象数量太多而且这些对象里有大量重复数据。比如车企软件里经常会遇到这些场景成千上万个信号点大量 CAN 报文描述大量诊断 DID / DTC 元数据大量配置项描述大量 UI 图标、字体、颜色、样式大量仿真对象大量日志事件模板大量测试用例参数模板这些对象单独看都不复杂。但数量一多内存就开始变得敏感。比如一个信号采集系统里每个信号样本都保存信号名 单位 缩放系数 偏移量 最小值 最大值 当前值 时间戳 来源 ECU其中信号名、单位、缩放系数、偏移量、最小值、最大值这些其实是“信号定义”通常不会频繁变化。而当前值、时间戳、来源才是每条采样数据真正变化的内容。如果每条采样数据都完整复制一份信号定义那对象数量一大内存会被大量重复信息吃掉。享元模式就是为了解决这类问题。一、先从一个车企场景说起假设我们现在要做一个车辆数据采集系统。系统会采集很多信号比如VehicleSpeed BatterySoc MotorTemperature BrakePressure SteeringAngle每个信号都有自己的描述信息名称 单位 最小值 最大值 缩放系数 偏移量 所属 ECU同时每次采样还会产生实时数据当前值 采样时间 信号来源 质量标志如果代码一开始比较直接可能会这样设计classSignalSample{public:std::string name;std::string unit;doubleminValue;doublemaxValue;doublefactor;doubleoffset;doublevalue;uint64_ttimestamp;};这样写当然能工作。但问题是每产生一条采样数据就复制一份信号描述信息。如果每秒采集 1000 条数据持续运行几十分钟、几个小时重复数据就会非常多。尤其是name unit minValue maxValue factor offset这些内容在同一个信号上通常是固定的。真正变化的只是value timestamp所以更合理的方式是把不变的信号描述抽出来共享把变化的采样值单独保存。这就是享元模式的典型思路。二、为什么对象太多会成为问题很多时候我们不是一开始就遇到内存问题。而是系统运行一段时间之后问题慢慢浮现。1. 单个对象不大但数量巨大一个对象可能只有几十字节、几百字节。看起来没什么。但如果数量是10 万 100 万 1000 万问题就不一样了。尤其是车企软件里数据采集、仿真、日志、配置、诊断结果都可能产生大量对象。对象数量上来之后内存、缓存命中率、分配开销都会受到影响。2. 很多字段其实是重复的比如 100 万条VehicleSpeed采样数据里下面这些信息可能完全一样信号名VehicleSpeed 单位km/h 最小值0 最大值300 缩放系数0.01如果每条采样数据都存一份就浪费了。这不是业务复杂。这是数据结构没有区分哪些状态是共享的哪些状态是每个对象独有的。3. 对象频繁创建和销毁也有成本对象太多不只是占内存。还会带来动态内存分配成本析构成本缓存局部性变差GC 或内存管理压力调试和统计困难C 里虽然没有传统 GC但频繁new/delete大量小对象也会带来明显开销。4. 重复数据会让一致性更难保证如果每个对象都保存一份信号定义那当信号定义变化时就会有一致性问题。比如单位从m/s变成km/h如果复制了很多份谁来保证它们都一致共享一份元数据就能避免这种重复维护问题。三、享元模式到底是什么享元模式可以这样理解通过共享大量细粒度对象中的相同状态减少内存占用。再说得直白一点如果很多对象里面有一部分数据是一样的就把这部分抽出来共享不要每个对象都存一份。享元模式里有两个非常重要的概念内部状态 外部状态1. 内部状态内部状态也叫 intrinsic state。它是对象内部可以共享的、不随使用场景变化的状态。比如信号定义里的信号名 单位 最小值 最大值 缩放系数 偏移量这些信息对于同一个信号来说通常是固定的。所以可以共享。2. 外部状态外部状态也叫 extrinsic state。它是每次使用时由外部传入的、不能共享的状态。比如采样数据里的当前值 采样时间 质量标志 来源通道这些信息每条数据都不同。所以不能共享。享元模式真正难的地方不是写一个缓存。而是分清楚哪些状态可以共享哪些状态必须由外部传入。四、享元模式解决的核心问题享元模式最核心的价值有三个。1. 减少重复对象占用的内存如果大量对象里有重复信息享元模式可以把重复信息集中起来。比如100 万条信号样本不再每条都保存完整信号描述。而是让它们共享同一个SignalDescriptor。这样内存占用会明显下降。2. 让共享数据集中管理共享的元数据只维护一份。比如VehicleSpeed 的单位、范围、缩放系数只在一个地方管理。这样比复制到每条采样数据里更安全。3. 提高对象创建效率当共享对象已经存在时调用方不需要重新创建。只要从工厂里取出来即可。比如autodescriptorfactory.Get(VehicleSpeed);如果已经存在就直接复用。如果不存在再创建。这比到处重复创建更可控。五、享元模式的核心角色享元模式通常有几个角色。1. Flyweight享元接口或享元对象也就是可共享的对象。比如SignalDescriptor MessageMetadata DtcDefinition ConfigDefinition UiResource它保存的是可以共享的内部状态。2. ConcreteFlyweight具体享元也就是具体的共享对象。比如VehicleSpeedDescriptor BatterySocDescriptor MotorTemperatureDescriptor工程里不一定非要为每个享元写一个子类。很多时候一个普通类加不同数据就够了。3. FlyweightFactory享元工厂负责创建和管理享元对象。它通常会维护一个缓存key - shared flyweight比如VehicleSpeed - SignalDescriptor BatterySoc - SignalDescriptor调用方通过工厂获取共享对象而不是直接到处 new。4. Client调用方调用方使用享元对象同时自己保存或传入外部状态。比如一条采样数据保存SignalDescriptor 引用 当前值 时间戳而不是保存完整信号定义。六、享元模式的结构可以用一个简化结构理解Client ↓ FlyweightFactory ↓ Flyweight具体到信号采集场景SignalSample ↓ 持有 SignalDescriptor SignalDescriptorFactory ↓ 管理 多个 SignalDescriptor结构大概是SignalDescriptorFactory ├── VehicleSpeedDescriptor ├── BatterySocDescriptor └── MotorTemperatureDescriptor SignalSample ├── descriptor 指针 ├── value └── timestamp最关键的是SignalSample不再复制完整信号定义而是引用共享的SignalDescriptor。七、一个 C 示例共享车辆信号元数据先定义一个信号描述对象。它保存可以共享的内部状态#includecstdint#includeiostream#includememory#includestring#includeunordered_map#includevectorclassSignalDescriptor{public:SignalDescriptor(std::string name,std::string unit,doubleminValue,doublemaxValue,doublefactor,doubleoffset):name_(std::move(name)),unit_(std::move(unit)),minValue_(minValue),maxValue_(maxValue),factor_(factor),offset_(offset){}conststd::stringName()const{returnname_;}conststd::stringUnit()const{returnunit_;}doubleMinValue()const{returnminValue_;}doubleMaxValue()const{returnmaxValue_;}doubleDecode(doublerawValue)const{returnrawValue*factor_offset_;}private:std::string name_;std::string unit_;doubleminValue_;doublemaxValue_;doublefactor_;doubleoffset_;};这里的SignalDescriptor适合共享。因为对于同一个信号来说这些描述信息通常是固定的。然后定义享元工厂classSignalDescriptorFactory{public:std::shared_ptrconstSignalDescriptorGetOrCreate(conststd::stringname,conststd::stringunit,doubleminValue,doublemaxValue,doublefactor,doubleoffset){autoitdescriptors_.find(name);if(it!descriptors_.end()){returnit-second;}autodescriptorstd::make_sharedSignalDescriptor(name,unit,minValue,maxValue,factor,offset);descriptors_[name]descriptor;returndescriptor;}std::size_tCount()const{returndescriptors_.size();}private:std::unordered_mapstd::string,std::shared_ptrconstSignalDescriptordescriptors_;};这里用信号名作为 key。实际项目里key 可能更复杂比如ECU MessageId SignalName或者VehiclePlatform SignalId接着定义采样数据。采样数据保存外部状态classSignalSample{public:SignalSample(std::shared_ptrconstSignalDescriptordescriptor,doublerawValue,uint64_ttimestamp):descriptor_(std::move(descriptor)),rawValue_(rawValue),timestamp_(timestamp){}doubleValue()const{returndescriptor_-Decode(rawValue_);}uint64_tTimestamp()const{returntimestamp_;}voidPrint()const{std::coutdescriptor_-Name() Value() descriptor_-Unit(), tstimestamp_\n;}private:std::shared_ptrconstSignalDescriptordescriptor_;doublerawValue_;uint64_ttimestamp_;};调用方可以这样使用intmain(){SignalDescriptorFactory factory;autospeedDescfactory.GetOrCreate(VehicleSpeed,km/h,0.0,300.0,0.01,0.0);autosocDescfactory.GetOrCreate(BatterySoc,%,0.0,100.0,0.1,0.0);std::vectorSignalSamplesamples;samples.emplace_back(speedDesc,5234,1001);samples.emplace_back(speedDesc,5240,1002);samples.emplace_back(speedDesc,5251,1003);samples.emplace_back(socDesc,805,1001);samples.emplace_back(socDesc,804,1002);for(constautosample:samples){sample.Print();}std::coutdescriptor count factory.Count()\n;return0;}输出类似VehicleSpeed 52.34 km/h, ts1001 VehicleSpeed 52.4 km/h, ts1002 VehicleSpeed 52.51 km/h, ts1003 BatterySoc 80.5 %, ts1001 BatterySoc 80.4 %, ts1002 descriptor count 2这里虽然有 5 条采样数据但信号描述对象只有 2 个。如果采样数据有 100 万条收益会更明显。八、这个例子里的内部状态和外部状态这个例子里内部状态是SignalDescriptor ├── name ├── unit ├── minValue ├── maxValue ├── factor └── offset这些信息可以共享。外部状态是SignalSample ├── rawValue └── timestamp这些信息每条样本都不同。享元模式最重要的设计点就在这里共享内部状态外部状态由调用方保存或传入。如果你没有分清楚这两类状态享元模式很容易写错。九、再看一个车企例子共享 CAN 报文元数据CAN 报文系统里也经常有类似问题。一条 CAN 报文通常有元数据报文 ID 报文名称 周期 DLC 发送节点 信号列表运行时数据则是当前 payload 接收时间 总线通道 是否超时如果每次接收到一帧 CAN 报文都复制一份完整报文定义就很浪费。更合理的做法是CanMessageDefinition共享报文定义 CanMessageFrame运行时帧数据结构可以是CanMessageFrame ├── definition ├── payload ├── timestamp └── channel其中definition由工厂统一管理。比如0x100 - VehicleStatusDefinition 0x101 - BatteryStatusDefinition 0x102 - BrakeStatusDefinition这样每次接收报文时只需要记录我是哪种报文 当前 payload 是什么 什么时候收到 从哪个通道收到而不是复制完整报文说明。十、享元模式和缓存有什么区别享元模式经常被误解成缓存。它们确实有相似点都会复用已有对象。但意图不同。缓存缓存关注的是避免重复计算或重复加载。比如读取文件很慢所以缓存文件内容 查询数据库很慢所以缓存查询结果 图片加载很慢所以缓存图片资源缓存的重点是性能。享元模式享元模式关注的是大量对象中有重复状态所以共享这部分状态减少内存占用。比如100 万条信号样本共享 100 个信号定义 100 万个 UI 元素共享一套字体和图标资源 大量诊断结果共享 DTC 定义享元的重点是共享状态。一句话区分缓存避免重复获取。享元避免重复存储。缓存可以用来实现享元工厂。但不是所有缓存都是享元模式。十一、享元模式和对象池有什么区别对象池也会复用对象。但它和享元模式也不是一回事。对象池对象池关注的是对象创建和销毁成本高所以复用对象实例。比如线程池 连接池 缓冲区池 报文对象池对象池里的对象通常会被借出、使用、归还。对象本身可能是可变的。享元模式享元模式关注的是对象内部有大量可共享的不变状态。享元对象通常应该尽量不可变。多个调用方可以同时共享它。一句话区分对象池复用对象生命周期。享元模式共享对象内部状态。对象池解决创建销毁成本。享元模式解决重复状态占用。十二、享元模式和单例模式有什么区别有些人会把享元写成单例。比如全局只有一个 Font 全局只有一个 ConfigDefinition但享元模式不等于单例模式。单例模式单例关注的是一个类在系统里只能有一个实例。比如日志中心 配置中心 全局调度器它强调唯一性。享元模式享元关注的是相同状态的对象可以共享不同状态的对象可以有多个。比如VehicleSpeedDescriptor BatterySocDescriptor MotorTemperatureDescriptor这些都是享元对象。它们不是全局只有一个。而是每种 key 对应一个共享对象。一句话区分单例整个类只有一个对象。享元同一种状态共享一个对象。享元工厂里可能管理很多个共享对象。不是一个。十三、车企项目里哪些地方适合享元模式1. 共享报文元数据比如CAN 报文定义 LIN 报文定义 以太网 SOME/IP 服务定义 诊断请求定义运行时只保存当前数据和时间戳。报文定义可以共享。2. 共享信号描述比如信号名 单位 范围 缩放系数 偏移量 枚举描述大量采样值可以共享同一个信号描述。3. 共享 DTC / DID 定义诊断系统里DTC 定义和 DID 定义通常比较稳定。比如DTC 编号 DTC 名称 故障描述 严重等级 恢复条件具体诊断结果只需要保存状态、时间和上下文。4. 共享配置项描述配置系统里配置项定义可以共享配置项名称 类型 默认值 范围 是否必填 描述信息具体车型配置只保存实际值。5. 共享 UI 资源HMI 里很多资源适合共享图标 字体 颜色 样式 背景图 主题资源不要每个控件都复制一份资源。6. 共享仿真对象模板仿真系统里很多对象有公共定义车辆模型参数 传感器模型参数 道路标志模板 行人模板 交通灯模板具体实例只保存位置、速度、状态等外部状态。7. 共享日志事件模板日志系统里大量日志可能共享同一个事件定义事件 ID 事件名称 模块名 严重等级 格式模板每条日志只保存参数和时间。十四、享元模式最容易被滥用在哪里1. 对象数量不多也强行享元享元模式是为“大量对象”服务的。如果对象只有几十个、几百个而且内存压力不大就没必要强行上享元。否则你只是增加了一层工厂和共享管理。复杂度反而上升。2. 共享了不该共享的状态这是最危险的问题。比如你把当前值也放进共享对象里classSignalDescriptor{public:doublecurrentValue;};那多个采样数据就会互相影响。享元对象里应该放可共享的内部状态。每次变化的外部状态不要放进去。3. 享元对象可变导致线程安全问题享元对象被多个地方共享。如果它是可变的就要考虑并发访问问题。比如多个线程同时修改一个共享的SignalDescriptor就会很危险。工程里更推荐享元对象尽量设计成不可变对象。创建后不再修改。需要变化时创建新版本。4. key 设计不清楚享元工厂通常通过 key 查找对象。如果 key 设计不清楚就会出现错误共享。比如只用信号名作为 keyVehicleSpeed但不同 ECU、不同车型平台可能都有同名信号定义却不一样。这时 key 就应该包含更多信息platform ecu signalName享元模式里key 设计非常重要。5. 工厂无限增长享元工厂如果只进不出可能会变成内存泄漏源。比如动态创建大量 keySignal_1 Signal_2 Signal_3 ...如果这些 key 不会复用享元就没有意义。还会让工厂越来越大。所以要确认这些对象是否真的会被大量复用。6. 为了省内存牺牲可读性享元模式会让对象结构多一层间接引用。如果过度使用代码会变得难理解。所以它更适合用于明确的高频、大量、重复对象场景。不要为了省一点点内存把业务对象拆得过于零碎。十五、工程中更推荐的用法1. 先确认是否真的有大量重复对象使用享元模式前先问对象数量是否足够大 重复状态是否足够多 内存是否真的有压力如果答案都是否定的就不要急着用。2. 明确区分内部状态和外部状态这是享元模式的核心。内部状态可共享 不随使用场景变化 尽量不可变外部状态每次使用不同 由调用方保存或传入 不能放进共享对象这一步没想清楚后面代码一定容易出问题。3. 享元对象尽量不可变比如std::shared_ptrconstSignalDescriptor这里使用const就是在表达这个共享对象创建后不应该被随便修改。不可变对象天然更适合共享。也更容易保证线程安全。4. 工厂统一管理创建调用方不要到处自己 new 享元对象。应该统一通过工厂获取autodescriptorfactory.GetOrCreate(...);这样才能保证相同对象真的被共享。否则每个地方都自己创建就失去了享元模式的意义。5. key 要能唯一表达内部状态享元工厂的 key 必须清楚。不要因为 key 过粗导致错误共享。比如signalName可能不够。更稳妥的 key 可能是platform ecu messageId signalNamekey 的粒度要根据业务来定。6. 注意生命周期和释放策略如果享元对象种类固定比如车辆信号定义、DTC 定义、UI 资源通常可以长期持有。但如果享元对象是动态生成的就要考虑是否需要淘汰是否使用 weak_ptr是否按版本清理是否按项目释放是否会无限增长享元模式不是把对象都塞进一个 map 就结束了。十六、享元模式的优缺点优点第一减少内存占用。大量对象共享相同内部状态可以明显降低重复数据。第二提高对象复用率。相同定义只创建一次后续直接复用。第三有利于集中管理元数据。比如信号定义、报文定义、DTC 定义可以统一维护。第四适合高频数据场景。数据采集、仿真、日志、诊断结果等场景都可能受益。第五可以提升性能。减少对象创建和重复存储有时也能改善缓存局部性和分配开销。缺点第一结构会更复杂。需要区分内部状态和外部状态还要引入工厂。第二状态边界容易设计错。一旦把可变状态共享出去就可能出现隐蔽 bug。第三调试会多一层间接引用。对象里不再直接保存完整信息而是引用共享对象。第四工厂可能成为内存增长点。如果 key 不可控享元对象可能越来越多。第五不适合小规模对象。对象不多时收益不明显复杂度却增加了。十七、使用享元模式前先问这 6 个问题1. 这个对象数量是否真的很多如果对象数量很少不要急着用享元。享元模式主要服务于大规模对象场景。2. 对象里是否有大量重复状态如果每个对象都完全不同没有可共享部分享元模式就没有意义。3. 哪些状态是内部状态也就是可以共享 不随场景变化 创建后尽量不变这些才适合放进享元对象。4. 哪些状态是外部状态也就是每次使用不同 会频繁变化 和具体上下文相关这些必须由调用方保存或传入。5. 享元对象是否应该不可变大多数情况下答案应该是肯定的。共享对象越可变风险越大。6. 工厂的 key 是否设计正确key 决定了什么对象会被共享。key 太粗会错误共享。key 太细共享效果变差。这需要结合业务认真设计。十八、总结享元模式解决的是当系统里存在大量相似对象时如何通过共享重复状态来节省内存。它不是简单地写一个缓存。它真正想解决的是对象数量巨大对象内部有大量重复数据重复元数据导致内存浪费对象创建和管理成本较高元数据复制多份后难以保持一致高并发、高频采集、高频仿真场景下内存压力明显一句话概括享元模式的重点不是“复用对象”而是“共享对象里那些不变的重复状态”。在车企软件里它很适合这些场景共享 CAN / LIN / Ethernet 报文元数据共享车辆信号描述共享 DTC / DID 定义共享配置项描述共享 UI 图标、字体、样式资源共享仿真对象模板共享日志事件模板但它也不适合所有场景。如果对象数量不多或者没有明显重复状态就不要为了“像设计模式”而强行享元。设计模式真正有价值的地方不是把代码写得更绕而是你能不能看见系统里真正浪费的地方并用合适的结构把浪费收回来。如果上一篇组合模式提醒我们不要把“明明是一棵树”的结构写成到处判断类型、到处手写递归的一堆散代码。那么这一篇享元模式提醒我们不要把“明明可以共享的不变状态”在成千上万个对象里重复存一遍。如果这篇对你有帮助欢迎点赞、转发、关注。我们下一篇继续拆设计模式。

更多文章