综合页
游戏中的内存管理
Memory Management in Games
本页把 wiki 中关于内存的内容串成一条线:内存从两方面影响游戏,一是访问模式(缓存未命中远比计算昂贵),二是分配本身(通用堆分配慢且产生碎片,而主机上没有自动换页的虚拟内存兜底)。对策按生命周期组织:常驻与关卡数据用栈、每帧临时数据用单帧/双缓冲分配器、同尺寸小对象用池或小内存分配器、大小不一的动态对象用堆加碎片整理与句柄;资源按引用计数和分块加载管理,流送系统把世界切成等大的块;UE 则在 UObject 层用垃圾回收管理对象生命周期。最后,没有准确的追踪工具,以上一切都无从验证。各结论的细节与出处见所链接的概念页。
1. 为什么游戏要自己管内存
- 内存鸿沟:CPU 与内存的速度差距催生了寄存器、多级缓存和 NUMA;对游戏程序员最重要的结论是"数据要连续、紧凑、顺序访问;热循环的代码要小"。见 计算机硬件与内存层次。GEA 举例 PS3 上一次缓存未命中相当于执行数千条指令(运行时对象模型架构)。
- 堆分配慢:通用分配器要处理任意大小的请求,GEA 还称
malloc/free在多数系统上要切换到内核态(原书问题:现代分配器多数请求在用户态完成,只在扩展堆或大块分配时才做系统调用,详见该页)。GEA 的经验法则是"尽量少做堆分配,绝不在紧密循环里做堆分配"。见 自定义内存分配器。 - 碎片与主机:PS5 与 Xbox Series X/S 支持虚拟地址重映射,但不像 PC 那样自动换页到磁盘,开发者面对固定数量的 RAM,必须自己实现流送。见 自定义内存分配器。
- 布局本身:对齐与填充、虚表指针都会影响对象大小和缓存利用,见 C++ 内存布局与对齐。
2. 按生命周期选分配器
| 数据的生命周期 | 典型做法 | 页面 |
|---|---|---|
| 整个游戏(LSR 全局资源) | 栈底常驻,记下标记 | 资源管理器 |
| 一个关卡 | 栈分配器回滚到标记;或双端栈(Hydro Thunder 从未遇到碎片) | 自定义内存分配器 |
| 流式资源 | 等大块 + 池式分配,按关卡/区域的块列表加载卸载 | 游戏世界的加载与流送 |
| 一帧 | 单帧分配器,每帧清空,指针不得跨帧 | 自定义内存分配器 |
| 两帧(异步任务结果) | 双缓冲分配器,在 i+2 帧前用完 | 自定义内存分配器 |
| 同尺寸、频繁增删的小对象 | 池分配器 / 对象池 | 对象池 |
| 大小不一的游戏对象 | 小内存分配器(8、16、32……512 字节的池,过大的交给堆)、每尺寸一个池、或堆 + 摊销碎片整理 | 游戏世界的加载与流送 |
| 排队事件的深拷贝 | 池(Variant 参数等大时)、小内存分配器,Naughty Dog 用可重定位块 | 事件与消息传递 |
另一种极端是根本不在游戏中动态分配:载入世界块时生成全部对象,由编辑器离线分配,内存完全可预测,但策划必须预估每种对象的数量("游戏对象守恒定律")。见 游戏世界的加载与流送。
3. 碎片:避免、容忍或整理
- 避免:栈天然免疫碎片;池虽有碎片,但因所有块等大,不会因找不到连续块而提前耗尽。
- 整理:把已分配块下移填洞,每帧只移动少量块以摊销成本;难点是修正指向被移动块的指针。
- 引用方式决定能否整理:裸指针无法配合重定位;智能指针(如全局链表)或句柄(不可重定位的指针表的索引,带唯一 id 防止槽位复用)可以。Naughty Dog 的所有引擎都支持碎片整理,尽可能用句柄,并因此在并发更新时把"解引用句柄"当作检测对象交互的时机。见 自定义内存分配器、游戏对象引用与查询 与 游戏对象并发更新。
4. 资源与世界:引用计数、分块与流送
- 引擎保证每个资源在内存中只有一份;卸载时机比加载难,跨关卡共享的资源用引用计数避免"卸了又载"。资源可按段(主内存、显存、临时、调试)组织,按等大块分配以免碎片。见 资源管理器。
- 世界流送的目标是"边玩边载"并消除碎片:从一关一载、气闸,到 Naughty Dog 的细粒度块 + 加载区域;PS5 的 AMPR 把资产映射到预留的连续虚拟地址范围,流出时只需取消映射。见 游戏世界的加载与流送 与 引擎文件系统与异步文件读写。
- 共享不变数据:享元把内在状态(网格、纹理)共享给大量实例,是另一种省内存手段,见 享元模式。
5. 访问模式:让数据为 CPU 排队
- 把同类组件放进连续数组、活跃对象排在前面、冷热数据分离;以属性为中心的对象模型和 MassEntity 都采用 SoA 式布局。见 数据局部性 与 运行时对象模型架构。
- 容器的选择要同时看大 O 和内存布局,引擎常为避免动态分配而自研容器。见 容器与哈希表。
- 批量更新之所以快,部分原因正是子系统能把每对象数据放在一段连续内存里,见 游戏对象的实时更新。
6. UE5 的做法:GC 与智能指针
- UObject 由标记-清除式的可达性分析管理,"谁持有
UPROPERTY引用"决定对象活多久;GC 可能造成卡顿,规避手段包括簇、调整清理间隔、对象池和增量可达性分析。见 垃圾回收与对象生命周期。 - 非 UObject 用
TSharedPtr/TUniquePtr等,UObject 指针属性推荐TObjectPtr;这与 GEA "远离智能指针,非用不可就用成熟实现"的建议形成对照,两种观点并列在 智能指针与 TObjectPtr。 - 在有 GC 的环境里,对象池仍可用来避免分配和回收卡顿,但也可能让本该回收的对象一直存活,见 对象池。
7. 没有度量就没有管理
- 只包装
malloc/new不够:第三方库的分配、显存、以及每个自定义分配器内部的分配都要追踪;Naughty Dog 引擎有通用堆、对象堆、关卡加载堆、单帧栈、显存分配器、调试堆等。工具应给出各分配器/系统的高水位线、运行时内存 HUD,并在开发机上超出零售限制时醒目提示。见 游戏内性能与内存剖析。 - 这些内存策略在整个引擎运行时结构中的位置见 引擎运行时架构总览。
出处
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 3、6、7、10 章(经由下列 wiki 页面综合)
- Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章(经由下列 wiki 页面综合)
- GPP 与 UE5 官方文档(经由下列 wiki 页面综合)
相关页面
游戏开发自定义内存分配器Custom Memory Allocators内存从两方面影响性能:动态分配(malloc / 全局 new)很慢,以及访问模式主导现代 CPU 的性能。GEA 的经验法则是「尽量少做堆分配,绝不在紧密循环里做堆分配」,并介绍游戏引擎常用的自定义分配器:栈分配器(含双端栈)、池分配器、对齐分配、单帧与双缓冲分配器;以及如何用栈/池避免碎片,或用碎片整理加指针重定位(智能指针、句柄)解决碎片。游戏开发计算机硬件与内存层次Computer Hardware & Memory Hierarchy从冯·诺依曼结构讲起,依次是 CPU 的组成(ALU、VPU、寄存器、控制单元、时钟)、总线与字长、机器语言、汇编和寻址方式,再到内存体系:内存映射、虚拟内存和 TLB,以及为了缩小“内存鸿沟”而设计的寄存器文件、多级缓存(缓存行、标签、组相联、替换和写策略、一致性协议)和 NUMA(PS3 的 SPU 本地存储、PS2 的 scratchpad)。对游戏程序员最重要的结论是:数据要连续、紧凑、顺序访问;热循环的代码要小。游戏开发C++ 内存布局与对齐C++ Memory Layout & Alignment理解 C/C++ 程序在内存中的样子,是写高性能引擎代码的前提。主要内容包括:声明与定义、内部与外部链接;可执行映像的几个段(text、data、BSS、rodata)以及栈和堆;结构体成员的自然对齐和填充;继承和虚函数如何改变类的布局(vpointer 和 vtable)。GEA 也简要回顾了面向对象:多重继承容易产生致命菱形,所以通常只允许 mix-in;组合往往比继承更好。游戏开发数据局部性Data Locality数据局部性 (Data Locality) 是一种优化思路:CPU 读取内存时会把相邻的一整条缓存行一起载入,所以把数据按处理顺序连续存放,就能大幅减少缓存未命中。具体手段包括:组件按类型存进连续数组,不再到处跟随指针;把活跃对象排在前面;把冷热数据拆开。代价是要放弃一部分继承、接口与指针带来的抽象。游戏开发资源管理器Resource Manager游戏由网格、材质、纹理、着色器、动画、音频、关卡布局、碰撞与物理参数等大量资源(也叫资产、媒体)构成。内存有限,所以引擎要保证每个资源在内存中只有一份(五个网格共用一张纹理时只载入一份)。每个引擎都有某种资源管理器:有的是统一集中的子系统,有的散布在多年间由不同人写的各个子系统中,但职责和要解决的问题是一样的。本页讲运行时部分:职责、文件组织与归档、文件格式、资源 GUID、资源注册表、生命周期(引用计数)和资源的内存管理;交叉引用与加载后初始化见 资源交叉引用与加载后初始化,离线工具链见 资产管线与工具链。游戏开发游戏世界的加载与流送Loading & Streaming Game Worlds世界加载系统负责两件事:把世界块和所需资源从磁盘读进内存的文件 I/O,以及这些资源的内存分配与释放;另外还要管理游戏对象的生成与销毁。GEA 按演进顺序介绍:一次只载一个关卡的栈式加载(LSR 资源压栈底),用小块气闸 (air lock) 区域实现基本无缝的过渡,再到把所有资产切成等大块、用块池分配器按关卡加载区域流送的细粒度方案(Naughty Dog);对象生成的内存问题有"不许运行时分配"、按尺寸分池、小内存分配器和内存重定位四种对策;存档则分检查点和随处存档(GEA4 卷 II 17.4)。游戏开发游戏对象引用与查询Game Object References & Queries游戏对象需要唯一 id 来区分、查找和充当通信目标;找到对象后还要能引用它,引用的寿命可以从一次函数调用到许多分钟。GEA 比较三种实现:裸指针最快最简单,但有孤儿对象、悬垂指针、非法指针三类问题,且不能配合内存重定位;智能指针可以检测对象已删除、做引用计数,但很难写对,GEA 个人建议远离它,非用不可就用 C++11 标准库或 Boost 的成熟实现;句柄是全局句柄表的整数索引,加上唯一 id 防止槽位复用后指向错误对象,简单而安全。对象查询则不追求通用数据库,而是为最常用的查询建专门的数据结构(GEA4 卷 II 17.5)。游戏开发对象池Object Pool对象池 (Object Pool) 预先一次性分配一组可复用对象,需要时从池里取一个未使用的对象并初始化,用完标记为未使用,而不是逐个 new/delete。它能避免频繁分配的开销和堆碎片,常用于粒子、子弹、正在播放的声音等。代价是池大小要调校、同时活跃的数量有上限、复用的对象不会被自动清零,在有 GC 的环境里还可能让本该回收的对象一直活着。游戏开发享元模式Flyweight Pattern享元模式 (Flyweight Pattern) 把对象的数据拆成可共享的“内在状态”和每个实例独有的“外在状态”,让大量对象共用一份内在状态。森林里成千上万棵树共用一份网格和纹理,以及瓦片地图中每格只存一个指向共享地形对象的指针,都是它的典型应用。游戏开发容器与哈希表Containers & Hash Tables容器的职责都是容纳和管理零个或多个元素,差别在于实现方式与取舍。GEA 概览了常见容器与操作、迭代器、大 O 复杂度与内存布局的综合考量,讨论引擎为何常自研容器、C++ 标准库 / Boost / Folly / Loki 的利弊,并重点讲了动态数组的增长策略与哈希表(开放哈希与封闭哈希、哈希函数、线性/二次探测、Robin Hood 哈希)。游戏开发游戏内性能与内存剖析In-Game Profiling & Memory Tracking游戏是实时系统,要保持 30 或 60 FPS,同时受目标硬件内存限制。第三方剖析工具有局限、在主机上未必可用,所以许多引擎自带游戏内剖析器(手动插桩的代码块计时、层级关系、调用次数、导出 CSV)和内存统计与泄漏检测工具。GEA 讲解它们的实现要点和常见限制。(卷 I 10.8–10.9 节)游戏开发 · UE5垃圾回收与对象生命周期Garbage Collection & Object LifecycleUE 用标记-清除式的可达性分析管理 UObject 内存:从根集合 (Root Set) 出发沿反射可见的强引用遍历对象图,找不到的对象在下次垃圾回收 (Garbage Collection, GC) 时被删除。因此“谁持有 UPROPERTY 引用”决定了对象活多久;Actor 调用 Destroy() 只是请求销毁,真正释放仍取决于引用是否全部放开。游戏开发 · UE5智能指针与 TObjectPtrSmart Pointers & TObjectPtrUE 有两套对象寿命管理:普通 C++ 对象用引用计数智能指针(TSharedPtr/TSharedRef/TWeakPtr/TUniquePtr),UObject 由垃圾回收管理,不能放进这些智能指针。UE5 新增的 TObjectPtr 用来替代 UPROPERTY 里的 UObject 裸指针:编辑器构建中提供动态解析和访问跟踪,非编辑器构建中与裸指针表现相同。游戏开发事件与消息传递Events & Message Passing游戏天然是事件驱动的:爆炸、玩家被敌人发现、血包被捡起都是事件,系统要通知感兴趣的对象并让它们以各自的方式处理。用虚函数 OnExplosion() 通知属于静态类型的后期绑定,迫使基类为所有可能的事件声明虚函数;GEA 主张把事件封装成对象(类型 + 参数),实现动态类型的后期绑定,从而获得单一处理函数、持久性和盲转发。GEA 依次讨论事件类型(枚举 vs 字符串/字符串 id)、参数(派生类、Variant 数组、键值对)、处理器、职责链、兴趣注册、排队与否的利弊,最后是数据驱动的事件系统和以数据通路连接端口的图形化系统(如 UE 蓝图)(GEA4 卷 II 17.8)。游戏开发运行时对象模型架构Runtime Object Model Architectures玩法基础系统 (gameplay foundation system) 是位于"引擎与游戏分界线"附近、供构建游戏规则和动态元素的一组运行时组件,其中最复杂的是运行时对象模型:它在运行时实现世界编辑器展示给策划的抽象对象模型。GEA 把架构分为两大类:以对象为中心(每个工具侧对象对应一个或一小组类实例)和以属性为中心(对象只是一个 id,属性分散在按属性类型划分、以 id 为键的表里)。前者从单体类层次走向组合与组件,后者更省内存、更缓存友好、更易数据驱动,但难以表达属性间关系、也更难调试(GEA4 卷 II 17.1–17.2)。游戏开发引擎文件系统与异步文件读写Engine File System & Asynchronous File IO资源管理器大量依赖文件系统。引擎通常把操作系统的文件 API 包装成自己的 API:一是跨平台、屏蔽硬件差异;二是操作系统缺少引擎需要的功能(如流式加载),主机还要统一访问记忆棒、硬盘、蓝光盘、网络文件系统等介质。引擎文件系统 API 通常涵盖:文件名与路径操作、打开/关闭/读写文件、扫描目录、处理异步 I/O 请求(流式加载)。综合引擎运行时架构总览Engine Runtime Architecture Overview本页把 wiki 中分散在 GEA 第 1、6、8、17 章及 GPP、UE5 相关页面里的内容串成一条线,回答"一个游戏引擎在运行时是怎样组织起来的":静态结构上是自下而上的分层,上层依赖下层;启动关闭按依赖顺序显式进行;每一帧由主循环驱动各子系统批量更新,游戏对象的逻辑被安排在子系统阶段之间,并按依赖分桶或设先后关系;多核上一帧被拆成成百上千个作业;玩法基础系统(对象模型、事件、脚本、世界加载流送、高层流程)位于引擎与游戏的分界线附近。各结论的细节与出处见所链接的概念页。游戏开发多线程渲染与任务系统Multithreaded Rendering & Task SystemsCPU 时钟频率约在 2003 年停在 3.4 GHz 附近后,性能来自多核。应用阶段的优化一是让代码与内存访问更缓存友好,二是利用多核:多处理器流水线(时间并行,提高吞吐但增加延迟)、并行处理(空间并行,延迟更低)以及两者结合的任务系统;图形 API 用命令缓冲让多个线程并行录制绘制命令,新一代 API(DirectX 12、Vulkan、Metal)把更多责任交给应用以降低驱动开销。游戏开发游戏对象并发更新Concurrent Game Object Updates游戏对象模型要和几乎必然是多线程的底层子系统打交道,所以对象代码必须异步思考:耗时操作用非阻塞请求尽早发起、尽晚取结果,必要时容忍一帧延迟。作业之间的依赖决定了并行度 (DOP),每个依赖都引入同步点,"优化并发设计的秘诀是延迟"。对象模型本身也能并行:Naughty Dog 在把引擎从 PS3 移植到 PS4(《最后生还者 重制版》)时,让每个桶内的对象更新成为作业;桶间访问无需加锁,桶内读取用对象快照,桶内修改尽量避免,或加锁、或放进请求队列、或交给下一桶中的同步作业(GEA4 卷 II 17.7)。游戏开发游戏对象的实时更新Updating Game Objects in Real Time游戏对象更新就是根据上一时刻状态 Si(t − Δt) 求出当前状态 Si(t)。"每帧对每个对象调一次虚函数 Update(),并在里面顺带更新它的动画、物理、渲染组件"的简单做法在商业引擎里行不通:底层子系统需要批量更新才能高效,而对象之间、子系统之间又有依赖顺序。GEA 介绍的对策依次是:子系统由游戏循环直接批量更新、对象只操纵子系统中的"代理状态";在帧内多个阶段挂回调;按依赖森林把对象分桶,逐桶运行完整的分阶段更新(Naughty Dog 的做法);并用状态缓存和时间戳对付更新过程中的状态不一致和一帧延迟(GEA4 卷 II 17.6)。