
搞游戏引擎搞到一定阶段内存管理这关是绕不过去的。前几篇我们把引擎的启动流程、数学库和渲染基础过了一遍这一篇重点落在两个地方引擎基础系统如何组织以及内存管理这块硬骨头怎么啃。你知道的写业务逻辑时内存分配是随手的事但放到引擎里一次不经意的堆分配在每帧反复触发轻则帧率波动重则游戏跑到一半进入漫长的假死状态。这篇文章就是来拆解这些东西的为什么默认的分配器撑不起一个游戏循环、我们需要哪几种分配器、对象的生命周期到底该怎么管以及内存出问题时怎么高效排查。如果你是刚开始搭引擎或者正在优化现有框架的开发者这篇的内容应该能帮你少走不少弯路。先说一句大实话内存管理没有银弹引擎里往往同时存在好几种分配策略各自管各自适合的那摊事。真正的工作是在架构阶段就理解每种方案的适用边界然后在正确的场景里用正确的工具。1. 拆解之前先理清引擎基础系统到底管什么1.1 生命周期骨架从入口函数到主循环引擎的基础系统不是渲染器、不是物理模块而是那些从进程启动到退出都在幕后运行的东西。最底层的是一条生命周期骨架入口函数拿到操作系统给的控制权之后先做平台相关的初始化然后创建引擎核心对象接着按依赖顺序启动各个子系统全部就绪之后进入主循环最后在退出时倒序关停。这个骨架听起来简单但实际工程里很容易翻车。我见过不少引擎项目把子系统的初始化顺序写成一个任意排列的函数列表结果某个模块启动时去查询另一个还没就绪的子系统拿到一个空指针崩溃点在几百行之外排查半天。正确的做法是把子系统之间的依赖做成显式的拓扑关系启动时按依赖顺序遍历关停时反向执行。好处很直接只要依赖声明没有环你就不可能遇到“查询一个未初始化模块”的竞态初始化流程也能并行化——没有依赖的子系统可以各自在自己的线程里干活。主循环本身也是一个基础系统设计问题。经典结构是 Update 和 Render 交替进行但现代引擎更倾向于固定步长逻辑更新加插值渲染把逻辑帧率和渲染帧率解耦。这样做的原因并不玄乎物理模拟稳定需要固定步长渲染则需要平滑插值来适配显示刷新率。这一层的设计是否清晰直接决定你后面叠加网络同步、回放系统或者工具链的时候是如鱼得水还是寸步难行。1.2 模块化组织子系统之间怎么解耦基础系统的另一个大头是模块化组织。引擎里的子系统通常指这些日志系统、配置系统、文件系统、平台抽象层、输入系统、线程任务系统、资源管理器、内存分配器。它们之间不应该互相直接 new 对方。一个常见且实用的组织方式是建立一个核心上下文对象把各个子系统挂上去子系统之间通过上下文来访问彼此而不是在全局到处放单例。有人会问单例不是更省事吗单例确实写起来快但等你的工具链、热重载、多开编辑器场景出现时单例就是灾难。同一时刻存在多个引擎实例时单例会让所有实例互相踩踏。用上下文对象的方式每个引擎实例拥有自己独立的子系统集合编辑器里同时开两个预览场景就不再是噩梦。这一点在项目规模小的时候感觉不到等工具链复杂起来你就知道当初没偷懒是值得的。模块之间还要定好通信规则。我喜欢的原则很简单子系统可以调用底层设施的接口但上层业务不能反向调用实现细节。拿日志系统来说它应该是一个独立的最底层模块谁都能用但它自己最好不要依赖于文件系统之外的任何业务模块。配置文件系统则要处理好“物理路径”和“逻辑路径”的映射打包之后资源都在 PAK 文件里路径查找就得走虚拟文件系统的代理层。这些设计看似琐碎但都是引擎能否稳定扩展的地基工程。2. 默认内存分配器为什么撑不起一个游戏循环2.1 一次 malloc 到底有多贵很多人最初写引擎原型时内存分配就是随手 new/delete跑起来好像也没问题。但你把性能剖析器打开盯着单帧耗时分布看就会发现堆分配的开销远比你想象的大。一次系统级分配比如 malloc背后可能涉及用户态堆管理器的加锁、空闲链表的遍历、甚至向操作系统申请新的内存页。在 Windows 的默认堆上一次分配的开销有时候是几十到上百纳秒这还是在没有锁竞争的情况下。问题在于游戏一帧里的分配次数远不是个位数的量级。算一笔账假设一帧里你要为移动组件创建临时变换数组为 UI 字符串拼接分配缓冲区为粒子系统重新组织数据为寻路结果复制路径点这些加起来轻松超过几百次。如果按每次 100 纳秒算一帧的纯分配开销可能就是几十微秒听起来还行但这里有个前提——堆管理器在并发场景下会退化成更慢的路径而且频繁的分配释放还会引来更大的问题也就是碎片化。我实际帮人排查过一个案例某个系统的 AI 寻路在每帧执行时都在循环里 push_back 路径点vector 容量不够就触发扩容扩容意味着重新分配、拷贝、释放旧内存。寻路 AI 数量一多单帧在内存分配上耗费的时间突然变成原来的五倍。剖析结果里没有哪个单独函数特别慢但整个帧时间就是从这些地方一点点漏掉的。这正是默认分配器的典型瓶颈它太通用不够快也不够可预测。2.2 碎片化最隐蔽的性能杀手碎片化这个事用个生活类比最好懂。想象你有一个停车场车辆频繁进出虽然空车位总数不少但每一个空位都是分散的小格子。这时候如果有一辆大车要停进来整个停车场都找不到连续的区域。内存碎片化就是同一回事系统堆上确实有足够的总空闲空间但找不到一块连续的大块给某个分配请求于是分配器被迫向操作系统申请更多内存甚至触发昂贵的内存整理游戏就卡顿一下。碎片化的危害不只在单次分配变慢。长期运行的引擎会产生大量分散的小空闲块导致新对象的数据跨了不连续的内存区域缓存命中率显著下降。现代 CPU 对数据的获取是按缓存线通常 64 字节来加载的如果对象被撕裂在两条缓存线上读取它就需要两次内存访问。一个游戏跑半小时后感觉操作开始变得“飘”和“钝”很多时候不是逻辑变慢了而是数据的物理分布变差了。为了对抗碎片化引擎里的常规思路是不再让每个对象都从系统堆上单独分配而是在某些生命周期明确、频率高的场景里用自定义分配器接管内存。这些分配器的核心特点就是可控它们知道内存何时重置、何时复用因此能保持内存区域的相对连续性和热点集中性。下一节我们就来逐个拆解这些分配器的设计思路和实现要点。3. 内存分配器到底怎么选四种方案与关键实现3.1 线性分配器最快也最糙的分配器线性分配器Linear Allocator是最简单的自定义分配器。你预先申请一大块连续内存维护一个当前偏移指针每次分配就把指针向后挪动需要的字节数返回移动前的位置。释放操作基本不存在你要么不释放要么在大块内存的末尾一次性重置指针到起始位置。这段代码长这样class LinearAllocator { public: explicit LinearAllocator(size_t size) { m_memory ::malloc(size); m_size size; m_offset 0; } ~LinearAllocator() { ::free(m_memory); } void* allocate(size_t size, size_t alignment 16) { // 地址对齐到指定字节 uintptr_t base reinterpret_castuintptr_t(m_memory) m_offset; uintptr_t aligned (base alignment - 1) ~(alignment - 1); m_offset static_castsize_t(aligned - reinterpret_castuintptr_t(m_memory)) size; assert(m_offset m_size); return reinterpret_castvoid*(aligned); } void reset() { m_offset 0; } private: void* m_memory nullptr; size_t m_size 0; size_t m_offset 0; };为什么它最快因为分配就是几次整数运算连锁都不用加。缺点是它只能整体重置不能单独释放某个对象所以它最适合的场景是“每帧或每个作用域内产生、然后整体丢弃”的临时数据。比如一帧的变换计算中间结果、物理碰撞查询的输出缓冲、渲染命令的参数批次。你只需要保证单个分配的生命周期不超过一次 reset 边界。我在项目里最常见的用法是每帧创建一个临时 arena帧开始时 reset帧内所有短生命周期对象都在这里分配帧结束直接一锅端扔掉。这里的核心理念是既然这些数据的生命周期本来就以帧为单位内存管理就不需要更细的释放粒度。这也是一种“用设计消灭问题”的思路而不是试图实现一个万能的分配器去应对所有情况。3.2 栈式分配器临时对象的最优解栈式分配器Stack Allocator像是线性分配器的扩展。它同样是预申请一块内存但除了分配偏移之外还支持一次标记位置之后可以释放到标记位置也就是“回滚”。它非常适合那些作用域嵌套明显的场景进入一个函数时标记栈顶函数内创建一堆临时对象函数返回时释放回标记位置完全符合 C 栈对象的作用域逻辑。实现上栈分配器需要额外维护一个栈帧表保存每一次整型释放的标记点。分配时判断是否有可复用的栈帧标记如果没有就按当前顶部继续分配释放时把顶部指针降回标记点。整个过程依然只有指针运算非常快。你可以把它理解为在堆上手工模拟一个受控的“作用域栈”多了一层嵌套能力但依然不能任意释放中间对象。实际工程里栈分配器很适合做实体遍历时的临时缓冲区。举个典型场景场景图要收集某个节点半径内的所有灯光这套查询逻辑需要动态数组保存结果但结果只用于本次渲染阶段之后就被消费掉了。用栈分配器配合一个自定义容器就能做到查询过程零堆分配外层作用域结束后自动回滚内存。这个模式对长时间运行的引擎特别友好因为它从源头上就不产生碎片。3.3 池分配器高频小对象的正解池分配器Pool Allocator针对的是另一类问题大量同类型、固定大小的对象频繁创建和销毁。典型的例子是游戏实体、组件、粒子、碰撞形状。它们的数量可能成百上千生命周期又很零散如果每个对象都从系统堆分配性能开销和碎片问题都非常突出。池分配器的逻辑很直白预先申请一大块内存切成等大小的槽块用空闲链表把所有槽块串起来。分配时从头链表取一块释放时把这块还回首部。分配和释放都是 O(1) 操作不涉及系统堆而且因为所有槽块来自同一块连续内存同类对象的地址分布非常紧凑缓存友好度也高。基础实现梳理一下struct PoolAllocator { void init(size_t blockSize, size_t blockCount) { m_blockSize blockSize; m_memory ::malloc(blockSize * blockCount); m_freeList nullptr; // 把每一块内存的起始位置当作 next 指针连接起来 char* p static_castchar*(m_memory); for (size_t i 0; i blockCount; i) { void** link reinterpret_castvoid**(p); *link m_freeList; m_freeList link; p blockSize; } } void* allocate() { void* head m_freeList; m_freeList *static_castvoid**(head); return head; } void deallocate(void* ptr) { void** link static_castvoid**(ptr); *link m_freeList; m_freeList link; } };这里有个非常关键的细节也是新手最容易踩的坑池分配器只负责内存块的分配和回收它不负责对象的构造和析构。把内存从池子里拿出来之后你需要用 placement new 在内存上构造对象把内存还回池子之前必须先手动调用析构函数。直接 delete 一个从池子里出来的对象指针是非法操作因为 delete 会尝试把内存还给操作系统堆而这根本不属于它。这个习惯一定要尽早建立否则后面做对象复用时会出很多诡异问题。池分配器在设计上通常会让每个槽块大小对齐到缓存线64 字节或者至少 16 字节避免同一缓存线被不同对象共享造成伪共享问题。如果各个对象类型大小不同就用多个池每个池负责一种固定大小。这是很多引擎里所谓“通用小对象分配器”的雏形。3.4 大小桶分配器碎片与效率的折中严格来说大小桶分配器是池分配器的一种推广它维护多个固定大小池比如 8、16、32、64、128、256 字节等档位。分配时根据请求大小选择最小的能容纳的桶位。这样既保留池分配的 O(1) 性能又支持不同大小的对象不至于为每个类型建一个池。这里最核心的取舍在于桶位的粒度。桶太细比如按 1 字节递增会导致桶的数量爆炸每个桶的管理开销也大桶太粗比如只有 16 和 256 两档那么 200 字节的请求会占用 256 字节的桶浪费内存。实际做的时候要统计项目里各种分配请求的尺寸分布常见引擎里 128 字节以下的小请求占了绝大多数所以桶位设计通常会向小尺寸集中。大小桶分配器在中型引擎里是一个很实用的“通用分配器”替代方案但不要指望它解决所有问题。大块内存几 KB 以上仍然应该走系统堆或者专门的块分配器因为大块分配的频率往往很低碎片风险也主要是大块连续区域小桶管不了这个。成熟引擎往往会组合使用全局大小桶分配器处理中小区间请求大块分配走专门的大块内存池临时高频数据走线性或栈分配器对象密集型实体走类型专属池。4. 对象生命周期管理引擎里真正该说的“所有权”4.1 为什么裸指针不够好Handle 方案分配器解决了“内存从哪里来”的问题生命周期管理解决的是“这块内存什么时候还回去”的问题。在引擎里裸指针有一个先天缺陷你根本不知道它指向的对象还活着没有。如果某个对象被释放掉了其他模块里仍然持有它的裸指针下次访问就是踩在未定义行为上。运气好是崩溃运气不好是读到被覆盖的数据产生难以复现的逻辑错误。一个常见的工程方案是引入句柄Handle而不是直接传裸指针。句柄通常是一个 32 位整型拆成两部分对象的索引和代数generation。索引定位对象在对象表里的槽位代数用来判定这个槽位里的对象是否还是原来那个。释放对象时把当前槽位的代数递增这样旧句柄再访问时因为代数不匹配就会被判定为失效。这套方案相当于给对象加了一层“门卫”虽然偶尔要查表但换来的是安全性大幅提升。实际使用句柄需要注意几个细节。一个是代数的回绕问题如果一个槽位被反复复用代数涨到溢出后会回到旧值导致旧句柄重新“复活”。工程上一般把代数位段设计得足够宽比如 32 位句柄里用 24 位索引、8 位代数并且在高频复用场景下主动检查回绕必要时直接禁用该槽位而不是让它继续复用。另一个是要区分“句柄有效性”和“对象处于加载中/销毁中”状态句柄只是生命周期安全的入口状态机还要单独建。4.2 引用计数与侵入式设计句柄能判断对象是否存活但还不能回答“这个对象该什么时候释放”。对资源这类共享对象业界最常见的机制是引用计数。每个对象维护一个引用计数器获取对象时计数器加一释放引用时减一计数器归零时销毁对象。听起来简单但实现上有个争论了几十年的问题计数器放哪里。非侵入式的做法是在一个全局映射里记录外部引用计数对对象本身没有要求但每次增减计数都要做一次哈希查找性能差。侵入式的做法是让对象继承一个基类基类自带计数字段增减计数就是直接修改对象头上一个整数性能极高但对象必须持有这个头部。游戏引擎里性能敏感绝大多数会选择侵入式设计。这也是为什么很多引擎的基础对象都继承自一个带计数和标志位的公共基类。引用计数还有个坑循环引用。场景树里父子节点互相持有引用或者资源 A 引用了 BB 又回引 A计数器永远降不到零对象就泄漏了。解决的手段通常是区分强引用和弱引用弱引用不增加计数只能用来获取一个可用的强引用获取失败说明对象已销毁。资源管理器里常见的是资源之间多对多引用必须有一套完整的强弱引用规则否则资源泄漏和重复加载会轮番折磨你。4.3 引擎里智能指针怎么选C 11 之后标准库提供了 unique_ptr、shared_ptr 和 weak_ptr很多刚接触引擎开发的朋友会问直接全用 shared_ptr 不行吗我的回答通常是在引擎内部shared_ptr 最好少用。原因有几个它的控制块本身要额外分配它的原子计数操作在单线程场景下是纯浪费它还容易掩盖所有权设计缺失的问题——如果你不知道一个对象属于谁shared_ptr 只是把这个混乱外包给了引用计数。引擎内部我比较推荐的思路是排序优先级。能被独占的对象比如某个子系统内部的临时实例用 unique_ptr 或直接裸指针加明确所有权归属跨系统共享的、生命周期不确定的资源用侵入式引用计数或 Handle跨模块传递的引用尽量用 Handle 而不是裸指针方便在调试模式下做越界检查。标准库的 shared_ptr 更多是引擎对外脚本接口或者工具代码里才频繁出现因为那些场景确实需要便利且相对安全。智能指针的选择本质上是在问一个问题谁拥有这个对象想清楚了内存管理的大半问题就解决了。你可以在脑中建立一把尺子如果对象生命周期和某个作用域绑定用栈对象或者 unique_ptr如果对象生命周期由资源系统统一管理用引用计数资源如果对象可以被外部模块长时间持有且可能失效用 Handle。这个分类法几乎覆盖了引擎里的绝大部分场景。5. 对齐、构造与缓存内存管理的三大隐藏细节5.1 对齐踩坑实录内存对齐是被忽视最多、但出事最严重的一个点。现代 CPU 在访问内存在地址的自然对齐边界时是最快的某些架构甚至强制要求对齐比如在 ARM 平台上访问特定的数据类型地址没对齐直接触发硬件异常。C 的 new 一般保证对齐到最严格的基本类型通常是 16 字节但当你用自定义分配器时这个保证就没了。自定义分配器里所有返回地址都必须做对齐处理。我之前在某 ARM 设备上遇到一个诡异崩溃排查到最后发现是粒子系统从线性分配器拿内存时分配器只做了 4 字节对齐而粒子数据里有一个 16 字节的 SIMD 类型这种不对齐的访问偶尔会触发异常导致程序随缘崩溃。从那以后我给自己定了一条铁律正常分配器默认至少对齐到 16 字节。如果某个子系统用了更宽的向量类型就在分配接口传入对齐参数统一走 align 逻辑。C 层面做对齐需要注意几个坑。一个是 alignas 只能作用于变量或者类型声明不能直接修饰一个 void* 返回值另一个是 C17 之前的 aligned_malloc 和 free 必须配对使用不能和标准 delete 混着来。C17 之后有了 std::pmr但使用前要确认你的标准库实现是否完整。老实说不管用哪套 API最稳妥的做法还是像我们前面线性分配器那段代码一样自己写一个 alignUp 函数对任何分配器的返回地址统一处理这样不会受平台和标准库差异影响。5.2 分配与构造分离placement new 的意义自定义分配器出现后一个顺带而来的概念就是“分配内存”和“构造对象”是两回事。默认的new把这两件事绑在一起先调用 operator new 获得内存再调用构造函数。但在池分配器里内存是预先切好的或者是从复用池里拿出来的你只需要在已有内存上构造对象这个操作就是 placement new。placement new 的语法很直接在某个地址上显式调用构造函数。对应的销毁时不能 delete而只能显式调用析构函数内存本身由分配器回收。这个模式在引擎里到处都是游戏实体从实体池取槽位、资源系统加载资源到预分配缓存、渲染命令写入帧内的临时缓冲。掌握这个模式你才能在设计池化对象时游刃有余。使用 placement new 有两条关键纪律。第一不要对同一个地址重复构造内存中的对象除非你能保证已先调用析构函数否则会导致对象内部资源重复初始化而泄漏。第二确保析构函数不能抛出异常一旦析构中抛异常调用栈就崩了对象生命周期永远无法完成内存也会泄漏。还有一点经验当你把对象归还池子时不要急着清零内存可以先写入一个标记模式让调试器在后续访问已释放对象时更容易发现问题。5.3 SoA/AoS数据布局对性能的深远影响内存管理不只是分配算法的事数据的物理布局同样深刻影响性能。两种常见布局方式AoSArray of Structures结构体数组和 SoAStructure of Arrays数组结构体。直观理解AoS 是把对象的所有字段放在一起像一个班级里每个人的所有档案都订在一起SoA 是把所有人的同一字段集中放像一个科目成绩单里面只记这个班的某一科成绩。对渲染和物理这类需要遍历大量对象特定字段的系统SoA 通常有碾压级的优势。以粒子系统为例如果你用 AoS 存储一大串包含位置、速度、颜色等字段的粒子数组然后遍历所有粒子的坐标做更新CPU 加载一条缓存线时很大一部分数据其实是速度、颜色这类当前步骤用不到的字段相当于浪费了大量内存带宽。改成 SoA 后你把所有位置放在一个连续数组里遍历时就只用读位置相关的内存同样工作量的内存读取量可能压缩到原来的三分之一甚至更低性能提升几乎是立竿见影的。那 AoS 就一无是处吗也不是。当你需要频繁访问单个对象的所有字段或者对象之间按引用关系彼此关联时AoS 的局部性好能减少跳跃。真实工程里很少一刀切而是根据热路径选择布局。我见过的一些引擎在数据层面做得狠直接把实体的组件按类型拆分到不同数组把渲染实例按批次使用 SoA 布局。这件事想在哪个阶段做架构阶段就要想清楚因为后期数据布局改成 SoA 往往涉及大量代码重写。6. 内存出问题怎么查调试工具与实战避坑6.1 内存越界调试三板斧即便分配器设计得再完善代码写多了内存问题还是会来。越界是头号问题你可能写越一个字节数据被悄悄破坏具体表现却延迟到几百次操作之后才出现。应对策略一般是三板斧填充字节、页保护、调试分配器。填充字节是最便宜的一招。分配器在分配区域的尾部和头部设置固定的 Magic Number比如未初始化的内存填 0xCD已释放的内存填 0xDD。每次内存回收时检查填充区域是否被改动改动说明发生了越界写编译器 Debug 模式下还可以在分配和释放上下断点定位到具体操作。页保护更暴力也更精准把每块分配区域都对齐到内存页边界后一页设为不可访问一旦越界写入CPU 立即抛异常你能直接拿到当前的调用栈但内存开销大通常只在调试模式启用。我的实践经验是长期运行的引擎里维护一个 Debug 分配器它封住所有分配入口记录每次分配的调用栈和大小在释放时检查 Magic Number 和调用栈匹配。这套东西看着繁琐但往往能在出问题的当天定位到越界代码省去几天的痛苦排查。性能开销确实存在我一般在本地开发环境和 QA 版本开启正式发布则全部关掉。内存调试的价值就在“可复现的、精确的失败”越早暴露越好。6.2 泄漏追踪与统计泄漏比越界隐蔽因为它不会立刻崩溃而是一点一点吃掉内存。游戏跑一个小时后开始变慢多半是某些对象没释放。引擎里常规做法是做一个简单的泄漏追踪器全局统一登记每次分配记录文件行号、大小、调用栈哈希在分配释放配对时移除记录最终在引擎退出时打印所有未释放记录的汇总。为了把泄漏追踪做到能查我强烈建议每个分配调用都带一个调试标签。比如alloc(128, TextureData)这样最后的泄漏报告能直接告诉我“有 300 个 128 字节的 TextureData 没释放”比看一串地址管用得多。生产环境的统计还可以更细按系统聚合一段时间拉一份快照对比不同内存池的使用率能看出某些池是否随着关卡推进无限增长。还有一个容易漏掉的点资源加载和卸载的配对。很多泄漏不是对象没删而是资源引用关系没断干净导致资源系统认为某个资源还在使用就永远不卸载。排查这种问题要额外做引用图遍历检查哪些资源在关卡卸载后仍被持有着通常就是某段代码里存了一个资源的智能指针没有清空。这种问题靠简单计数不够我一般会把资源引用关系也做成调试输出导出成可视化的引用树来核对。6.3 一帧分配过多导致卡顿的真实排查案例前面提到过帧率波动排查这里把完整过程展开一下作为内存问题处理的一种范式。有一次朋友接手某引擎项目症状是战斗场景中敌人数量一多帧节奏开始不规则跳动性能剖析器看不出单个热点帧时间像心电图一样忽高忽低。排查第一步看他自己的性能计数器里有没有把内存分配单独统计。结果没有于是先临时在分配器入口加了一个计数器按帧统计分配次数和字节数。数据很快浮出水面敌人 AI 系统在一帧当中执行了上千次小分配而且分布极不均匀。又深挖一层发现寻路系统在每帧路径更新时都新建了一个std::vector来保存路径点路径点数量又随路径长度波动一旦vector扩容就会引起一大波重新分配和拷贝。修复方式没有走玄学直接在寻路子系统里引入一个临时的栈式分配器路径更新逻辑的临时数据全部从这上面拿作用域结束后整体回滚。改动不过几十行但帧时间立刻变得平稳分配次数从上千降到几十几乎都和长生命周期对象相关。这个案例最有价值的地方在于问题看表象是卡顿实质是默认分配器被用在不该用的高频场景里而系统工程需要建立“分配是可观测的”这一基础设施否则这种问题只能靠运气发现。顺带说一句内存统计信息本身也值得像帧率一样画在调试界面上。你可以把各类分配器的当前占用、峰值占用、分配次数放进调试菜单像看 FPS 一样盯它们。很多内存问题出现时你甚至不需要等性能剖析器跑完看着统计数字的异常增长就能猜到大概哪个子系统出事了。我个人在这些年踩坑之后最深的一个体会是内存管理不是后期优化才做的事而是引擎架构阶段就要定好的地基。分配器选型、生命周期规则、数据布局策略这三点越早统一后面填功能的时候越省心。我见过太多项目前期用裸指针和散乱的 new 堆出了原型后期想优化却发现每一个子系统都假设自己拥有的内存不会失效任何改动都牵一发动全身最后只能推倒重来。最后再分享一个小技巧给每个分配器起名字并且把它们注册到一个全局统计面板里。面板上能看到每个分配器的总分配量、峰值、最大单次分配、调用次数这些信息。这些数字不会骗人它们会直接告诉你哪些系统在以低效的方式使用内存。把分配器的状态当成游戏开发过程中一项普通的 HUD 指标来监控是保持引擎健康和高效的很重要的一环。