多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

享元模式实战:共享对象,告别游戏内存爆炸

享元模式实战:共享对象,告别游戏内存爆炸 做游戏最怕的就是内存告警。功能都正常结果一压测内存曲线直接起飞帧率跟着往下掉。你把一个个对象new出来用完也不释放或者释放了又疯狂创建后果就是GC压力爆表甚至OOM。这种时候最容易被想起来的就是享元模式Flyweight。它的思路特别直白一样的对象不重复建共用一份。别小看这层思路。真正理解享元能让你对整个系统的对象划分方式产生新的感觉——什么样子的数据应该拆出去什么样子的数据必须留在对象内部以及“共享”到底共享到什么程度。这篇文章我结合自己实际项目中的用法把享元模式掰开揉碎讲清楚包括核心设计、代码实验、实战场景还有我在真实开发里踩过的一些坑。1. 内存爆炸的本质不必要的重复对象网上讲设计模式的开头常常是一套理论定义但你既然搜到这个标题大概率是被内存问题逼来的。我先用最实际的角度切入。1.1 一个真实的场景最普通的“像素树”问题想象你在做一个开放世界游戏。地图上要画一万棵树每棵树有位置、高度、树的颜色、树叶密度、树干纹理、状态动画帧。很多初学游戏开发的人会直接写一个Tree类所有属性塞到一起public class Tree { private double x; private double y; private double height; private Color leafColor; private Texture trunkTexture; private Texture leafTexture; private ListAnimationFrame growAnimation; }然后每生成一棵树就new Tree(...)一次。一万棵树就是一万个独立对象每个对象里都存着同样的树干贴图、同样的树叶纹理、同样的动画帧序列。等场景里还有五十万颗草、三万个粒子、几百个NPC的时候内存就彻底失控了。这正是内存爆炸最常见的来源不是数据量真的好大而是同一份数据反复占内存。1.2 爆炸的本质对象身份和数据被捆绑在了一起上面Tree类的例子说明一个关键问题我们把每个对象都当成了“独一无二”的个体但个体本身并不需要把全部数据都带在身上。一万棵树位置坐标各自不同这个是可变的部分。但树干贴图是三张、树叶纹理是五张、动画帧序列就那么几套这些是不变的公共数据。你在同一片森林里看到的所有树共享同一套美术资源。如果每个树对象都存一份完整的贴图引用内存当然会崩。用生活里的例子类比一下你去大学图书馆如果每个学生进图书馆都要自己带一套《百科全书》副本图书馆早就塌了。现实里不会这样因为公共资源被共享人人按需借用。享元模式做的就是这件事——如果一类对象里有一部分数据是公共不变的那就把它们抽出来作为共享资源让各种“个体”按需引用。因此享元模式解决的不是“对象太多”而是可共享的重复数据太多。它的核心做法是把一个对象的属性拆成两类内部状态Intrinsic State不随外部环境而变化属于对象固有的、可以被共享的数据。外部状态Extrinsic State依赖具体使用场景必须由调用方传入不能被共享。想判断某块数据该放内部还是外部主要看它是否是“所有同类对象本质共有的、不会变的”。这属于典型的从需求分析出发的建模思路但实际拆的时候很多人还是会卡。下面详细说。2. 核心概念拆解内部状态、外部状态、享元工厂2.1 内部状态与外部状态拆分对象的分界线我们把树再拆一下。内部状态共享部分树干纹理、树叶纹理、树的高度范围、颜色组合、动画帧。同一品种的树完全用同一套。外部状态非共享部分树的坐标X/Y/Z、当前生长阶段、是否被砍倒、是否正在播放动画。这些数据每棵树都不同如果也塞进共享对象里那共享就毫无意义了。正确做法是共享对象里只保存纹理、动画帧这类公共资源。外部状态在使用时作为参数传入或者由调用方另外维护一份轻量数据。一个容易被忽视的关键点是外部状态不能存进享元对象内部。否则第一个使用它的人把这个状态改了第二个使用的人就拿到被污染的数据了。很多享元模式用不好就是栽在这一步。2.2 享元工厂让共享机制真正生效的“管理器”有了拆好的内部状态对象还得有地方让它“只创建一次”。这个职责通常交给享元工厂Flyweight Factory。工厂内部维护了一个池子通常是MapString, Flyweight或者ConcurrentHashMap。调用方每次要一个“某品种的树”工厂先看池子里有没有有就直接返回已有的没有就创建一个放进池子再返回。这种基于缓存的思路和实际工作中常见的缓存、池化思想完全一致。你在业务代码里可能早就写过类似的东西只是没有意识到它正是享元模式的经典形态。2.3 判断标准什么时候该拆什么时候不该拆我自己通常按三条标准来判断数据是否具有高度重复性一万棵树只有 5 套纹理高度重复适合共享。如果每棵树的纹理都完全不同那就没有任何共享价值硬套模式反而多此一举。创建成本是否够高纹理、模型、动画这些资源创建成本高适合共享。一个简单的整数对象创建成本几乎为零根本没共享的必要。对象数量是否巨大成千上万个对象内存压力明显值得用享元。如果全场只有 10 个对象省不了多少内存却增加了外部状态管理的复杂度。这三条同时满足就果断用享元。否则你可以继续往下读但建议不要让这种模式成为项目里无处不在的“架构炫技”。3. 代码实战Java 和 C 两种写法的完整演示理论讲太多容易飘直接上代码。我先用 Java 写一个游戏里的树渲染场景这是享元模式最贴合的形态再用 C 写一个不同场景的简单版本方便对比内存布局差异。3.1 Java 实现把游戏资源划分成共享单元先定义享元类也就是TreeType里面只放内部状态// TreeType 是享元对象内部只保存公共的纹理/颜色等数据 public class TreeType { private final String name; // 品种名 private final String trunkTexture; // 树干纹理路径 private final String leafTexture; // 树叶纹理路径 private final Color leafColor; // 树叶颜色 private final ListString animationFrames; // 动画帧路径 public TreeType(String name, String trunkTexture, String leafTexture, Color leafColor, ListString animationFrames) { this.name name; this.trunkTexture trunkTexture; this.leafTexture leafTexture; this.leafColor leafColor; this.animationFrames animationFrames; } public void draw(double x, double y, double height) { // 简化渲染逻辑实际会依赖纹理句柄 System.out.println(绘制[ name ]于( x , y ) 高度 height); } }再写享元工厂import java.util.HashMap; import java.util.List; import java.util.Map; public class TreeFactory { private static final MapString, TreeType pool new HashMap(); public static TreeType getTreeType(String name, String trunkTexture, String leafTexture, Color leafColor, ListString animationFrames) { TreeType result pool.get(name); if (result null) { result new TreeType(name, trunkTexture, leafTexture, leafColor, animationFrames); pool.put(name, result); System.out.println(创建新的树类型: name); } return result; } }此时森林里每棵树只是一个轻量的坐标信息加共享类型的引用。我们不需要再创建一个完整的Tree类可以直接在渲染循环里这样用// 场景中生成一万棵树 Random random new Random(); for (int i 0; i 10000; i) { TreeType type TreeFactory.getTreeType( 落叶松, trunk_01.png, leaf_03.png, Color.GREEN, List.of(anim_01.png, anim_02.png) ); type.draw(random.nextDouble() * 1000, random.nextDouble() * 1000, random.nextDouble() * 10); }无论循环多少万次池子里只有一个TreeType实例。你创建了十万棵树共享类型对象仍是同一个内存占用里的“树资源”部分基本恒定。3.2 Java 内置享元案例String 与 Integer理解上面的代码后你会发现Java 语言本身到处是享元的影子。String 常量池编译期确定的字符串被缓存相同字面量的String变量指向同一内存地址。Integer 缓存Integer.valueOf(-128~127)返回的是缓存对象不会重新创建。ThreadPool线程池复用线程。数据库连接池连接对象被复用而不是每次新建连接。这些不用刻意记它们的存在恰恰说明享元思想确实解决了真实问题。3.3 C 实现共享与内存布局的直观对比C 没有 JVM 帮我们管理对象生命周期享元模式更适合用shared_ptr配合工厂管理同时更接近底层内存视角。#include iostream #include map #include memory #include string // 棋子类型享元内部状态 class ChessType { public: ChessType(std::string name, std::string texture) : name_(std::move(name)), texture_(std::move(texture)) {} void draw(int x, int y) const { std::cout 绘制 name_ at ( x , y ) std::endl; } private: std::string name_; std::string texture_; }; // 享元工厂 class ChessTypeFactory { public: std::shared_ptrconst ChessType getChessType(const std::string name, const std::string texture) { auto it pool_.find(name); if (it ! pool_.end()) { return it-second; } auto type std::make_sharedconst ChessType(name, texture); pool_[name] type; std::cout 创建棋子类型: name std::endl; return type; } private: std::mapstd::string, std::shared_ptrconst ChessType pool_; }; int main() { ChessTypeFactory factory; // 棋子位置是外部状态通过参数传入 auto black factory.getChessType(黑棋, black.png); auto white factory.getChessType(白棋, white.png); black-draw(1, 1); black-draw(2, 2); white-draw(3, 3); return 0; }这里的ChessType是“内部状态唯一的对象”而棋盘上每个棋子实例的坐标则作为外部参数传递。其内存意义是无论棋盘上有上千个黑子它们的类型对象只有一份。与 Java 版本相比C 需要注意的是生命周期管理和缓存失效问题。用shared_ptr持有时只要工厂存在类型对象就不会被释放。这种共享更容易失控一定要在工厂层明确清理策略。4. 实战应用场景游戏、文本渲染与业务系统4.1 游戏开发粒子、草丛、树木全都中招游戏是享元模式的重度用户因为游戏里对象数量上限远高于业务系统。粒子系统是最经典的案例。一场爆炸特效会产生几百上千个粒子每个粒子都有坐标、速度、生命周期、颜色、纹理。如果每个粒子都持有贴图路径或者材质对象瞬间就是几十MB的额外内存。正确做法是粒子数据数组单独管理外部状态。纹理材质作为共享资源内部状态。更新粒子时传入共享资源引用。对比一下两种方式的差异运用模式对象数量每个对象持有数据内存估算1000个粒子不使用享元1000个粒子对象纹理路径材质引用坐标速度可能 80-120MB使用享元1个材质1000个粒子数据坐标速度共享材质指针约 10-15MB数据会根据引擎不同有差异但数量级差距是真实存在的。建筑、地形块、武器装备、NPC 外观也是同样的逻辑。很多游戏引擎把“静态网格资源”和“实例化绘制”分开本质就是享元模式在底层发挥作用的实例。4.2 文本渲染与文档系统字符级别的享元另一个经典场景是文本编辑器语种、字体、字号、颜色完全相同的大量字符可以共享同一个字形对象。一个文本编辑器可能有几十万个字符每个字符如果都保存字体、字号、颜色、字形数据内存开销是可怕的。但实际做法是字形数据Glyph共享对象内部状态字符位置信息char x y外部状态这让我想起当年做一个简单的富文本预览器时图省事把所有样式都存到每个字符对象里。一个文档几万个字直接就卡到爆炸。后来改成字体样式共享、位置信息单独存的方案内存占用直接降了一个数量级。4.3 业务系统中的“字典数据”与“用户画像标签”不仅在游戏和文档里业务系统同样大量用享元的思路。比如权限系统中的角色权限表如果几千个用户都拥有“经理”角色你不会给每个用户都复制一份经理的全部权限列表而是让所有用户引用同一个角色权限对象。这就是典型的共享对象实战运用。数据库表里的字典项也一样性别、订单状态、货币类型这些枚举值统一加载一份到内存其它地方全部引用同一份实例。5. 常见问题与排查技巧实录5.1 外部状态被错误写进共享对象这是最常见的写法误区。新手特别容易写出的代码如下// 错误示例外部状态被塞进共享对象 public class TreeType { private double x; private double y; private double height; }如果共享对象包含了外部状态那么张三改了坐标李四的树也被改了。排查方法很简单检查共享对象是否有非静态的可变字段它是否真的“跨对象共享”。观察是否出现 A 操作影响 B 结果的现象。检查调用方是否把当前对象特有的值传入了共享对象的 setter 里。正确做法是外部状态要么作为方法参数传入要么保存在一个轻量的外部包装对象中。5.2 线程安全全局池子的并发访问享元工厂的Map在多线程环境下必须考虑并发问题。不用HashMap而用ConcurrentHashMap或者在 get 方法里加锁否则高并发下可能出现同一个类型被创建两次虽然结果不一定会崩但逻辑上就没达到“共享唯一实例”的目的。代码层面要留意初始化时的竞态条件。最稳妥的写法是private static final ConcurrentMapString, TreeType pool new ConcurrentHashMap(); public static TreeType getTreeType(String key, SupplierTreeType creator) { return pool.computeIfAbsent(key, k - creator.get()); }ConcurrentHashMap.computeIfAbsent能保证同一个 key 只会执行一次创建逻辑是 Java 侧最简洁的方案。5.3 什么时候不能盲目追求共享可变对象的陷阱享元的前提是内部状态不变。如果你的“共享对象”是可变对象同时又会被多个调用方复用就会引发数据污染。排查这类问题时我是这样做的检查共享对象是否内部有setter比如setColor、updateText。检查共享对象是否暴露了可修改的List或Map调用方可能会直接 add 数据。凡是创建后需要改状态的类不要轻易放进享元池。规避方式创建后保持绝对不可变字段加上final集合返回时做Collections.unmodifiableList或者直接返回副本。5.4 一个更隐蔽的坑缓存永不释放导致“伪内存泄漏”工厂池里对象不断增加如果类型种类特别多池子本身就成了另一个内存消耗点。我这边的处理方式是为享元池设定最大容量。使用 LRU 或定时清理无引用缓存。代码里增加状态监控比如周期性打印池的大小发现异常增长时定位到创建方。这种问题一般只在 long-running 的服务端或特种场景中出现但一旦出现排查的成本远高于预防成本。6. 和对象池的区别以及乐与忧的边界很多人从形式上把享元模式和对象池混淆。两者在某些层面像但动机和结构有本质差异维度享元模式对象池核心目的减少对象数量减少对象创建/销毁的开销数据维度共享不可变内部状态复用可变对象实例典型场景大树、字符、贴图线程池、数据库连接池请求流程多次调用返回同一实例获取时可能重置状态后复用状态特点内部状态不可变可重置可重用最典型的例子是线程池同一个线程会被复用每次执行不同任务这是对象池而非享元。而字符串常量池里同一个“Hello”字符串被多处引用才是享元的典型形态。实际业务里两者经常结合使用。比如游戏引擎里的粒子系统一般先用一个粒子对象池减少创建销毁开销粒子们再用共享纹理减少重复数据这样效果最优也是一种合理的混合策略。7. 总结经验的最后几句话在我接触过的项目中享元模式最省心也最容易见效的使用场景永远是那些“资源型数据”的共享。贴图、模型、动画、文字字形、权限模板、配置条目这些都是天然共享的好苗子。只要内部对象做到不可变外部状态做好隔离再多对象也稳得住。有一类项目并不适合硬套享元比如对象状态本身就是千变万化的几乎没有重复数据或者对象数量本来就少节省的存储器远远填不平开发复杂度这个坑。模式是解决问题的工具不是必须完成的作业。想清楚这个问题往往比记住某段代码重要得多。如果你手边正好有一个内存爆炸的对象集合先试着观察一下它内部有什么字段是“反复出现”的然后把那部分抽出来放入工厂池。就这么做往往比直接重构整个对象体系要快得多。我自己的经验是分享数据不只是一项内存优化它还会逼着你把“什么才是一个对象最本质的属性”想明白。想清楚了代码结构自然就清爽了。
返回列表