2850 字
14 分钟
游戏引擎内存管理

一、游戏为什么不能直接用 malloc#

1 三个致命问题#

malloc / new 是通用内存分配器,它不知道你的使用模式。在游戏场景中,这带来了三个致命问题:

碎片化(Fragmentation)

运行 30 分钟后,堆上布满了大小不一的空洞。虽然有 2GB 可用内存,但没有一块连续的空间能放 64MB 的纹理。

[已用32B][空洞16B][已用1MB][空洞8B][已用256B][空洞4KB]...
↑ 够吗?不够!64MB 的连续空间在哪?

不可预测的延迟

malloc 内部可能触发系统调用(brk / mmap)、可能要走复杂的空闲链表查找合适大小的块。对游戏来说,一帧只有 16ms——不能在分配内存上花掉 2ms 还不知道为什么。

频繁分配/释放的开销

每帧创建 100 个小对象(粒子、事件、临时数组),每个都 new / delete。累计的分配器内部簿记开销可能超过实际业务逻辑。

2 游戏引擎的答案#

知道使用者模式。游戏引擎知道:

  • 哪些数据活一帧就扔 —— 帧分配器
  • 哪些数据大小固定、频繁分配释放 —— 池分配器
  • 哪些数据需要连续增长 —— 线性分配器
  • 哪些数据来自文件、需要持久存在 —— 更粗粒度的操作系统级分配

引擎通过为不同生命周期的数据提供不同策略的分配器来规避上述所有问题。


二、核心分配器模式#

1 线性分配器(Linear / Arena Allocator)#

最简单、最快、零碎片

class LinearAllocator {
char* m_base; // 内存块起始
size_t m_capacity; // 总大小
size_t m_offset; // 当前写入位置(单向增长)
public:
void* Allocate(size_t size) {
// 对齐到 16 字节
size_t aligned = (size + 15) & ~15;
if (m_offset + aligned > m_capacity)
return nullptr; // 内存耗尽
void* ptr = m_base + m_offset;
m_offset += aligned;
return ptr;
}
void Reset() {
m_offset = 0; // "释放"所有内存,O(1)
}
};

特点

  • Allocate 就是指针加法 + 边界检查,极快
  • Reset 是 O(1) 操作——不是逐个 free,而是直接把偏移量归零
  • 不能单独 free 某一个对象——要么全保留,要么全释放

适用场景:一帧内的所有临时分配。这帧完了,Reset 一下,所有临时数据全清了。整个帧的数据共享一块大内存,没有碎片。

UE 里 FLinearBlockAllocator 和 RDG 内部的 Transient Resource Allocator 都是这个思路。

2 栈分配器(Stack Allocator)#

线性分配器的变体,支持”后进先出”的部分释放。

class StackAllocator {
char* m_base;
size_t m_capacity;
size_t m_top;
public:
struct Marker {
size_t offset; // 记录当前位置
};
Marker GetMarker() {
return Marker{ m_top };
}
void* Allocate(size_t size) {
// 同线性分配器,移动 m_top
}
void FreeToMarker(Marker marker) {
m_top = marker.offset; // 回滚到标记点
}
};

适用场景:加载一个 Level 时,Level 内的所有资源从栈上分配。Level 卸载时,回滚到 Level 加载前的 Marker,一次性释放全部。

UE 的 FMemStack 就是栈分配器,广泛用于 UObject 的加载流程。

3 池分配器(Pool / Object Pool Allocator)#

所有分配的大小都相同。

template<size_t ObjectSize>
class PoolAllocator {
// 预分配一大块内存,切分成固定大小的槽位
// 空闲槽位用链表串联(freelist)
struct Slot {
Slot* next; // 空闲时用作链表指针
};
std::vector<Slot*> m_chunks;
Slot* m_freelist; // 空闲链表头
public:
void* Allocate() {
if (!m_freelist) {
// 没有空闲槽位,分配新的大块
AllocateNewChunk();
}
Slot* result = m_freelist;
m_freelist = m_freelist->next;
return result;
}
void Free(void* ptr) {
Slot* slot = static_cast<Slot*>(ptr);
slot->next = m_freelist; // 归还到空闲链表头
m_freelist = slot;
}
};

特点

  • AllocateFree 都是 O(1)
  • 零碎片——每个分配大小完全一致
  • 支持独立释放任意对象
  • 浪费空间——如果对象实际大小小于 ObjectSize,剩余空间被浪费

适用场景:子弹、粒子、网络消息包。这些对象有相同的尺寸、极高的分配/释放频率。用池分配器后,“创建一颗子弹”不再涉及 malloc,只是从 freelist 弹出一个槽位。

UE Core 模块里的 TObjectPool 就是此模式的模板实现。

4 伙伴分配器 / 多级分配(Buddy Allocator / Slab Allocator)#

处理更多样的分配需求。伙伴系统维护多个不同大小的空闲链表——比如 64B、128B、256B、512B……申请 100B 时,从 128B 的链表分配。如果一个链表为空,向上级请求并把多余部分”拆成伙伴”放入下级链表。

UE 的 FMallocBinned 系列就是 Slab 分配器的变体。它把分配按大小分桶(bin),每个桶独立管理,减少大分配和小分配之间的碎片影响。


三、帧分配器(Frame Allocator)#

1 工作方式#

帧分配器不是一个独立的分配器类型,而是线性分配器的一个特定用法。引擎每帧创建一个线性分配器,所有”活到帧末就扔”的数据都从它分配。帧结束时一次性 Reset。

// 每一帧:
frameAllocator.Reset();
UWorld::Tick(frameAllocator); // 所有临时数据从 frameAllocator 分配
// Tick 结束,frameAllocator 内的所有数据自动失效

适用数据

  • 渲染命令的临时参数
  • 字符串拼接
  • TArray 的临时扩容
  • 调试和 Profiling 的输出

2 注意事项#

帧分配器上的数据不能跨帧引用。你必须在下一帧开始前把所有指针拷贝到持久内存中。C++ 的智能指针无法帮你检查这个问题——它不知道”这块内存上一帧已经回收了”。

这就是 C++ 游戏开发里内存管够但生命周期管不好的经典 bug 来源。UE 里某些神秘的 crash,根因就是:某个对象持有了上一帧帧分配器上的一个指针,帧结束后那块内存被复用了。


四、UE 的多级内存体系#

1 从下到上#

┌──────────────────────────────────────┐
│ UObject 层(GC 管理) │ ← UObject::operator new → GUObjectAllocator
├──────────────────────────────────────┤
│ FMalloc 层(可替换的分配器后端) │ ← FMallocAnsi / FMallocBinned / FMallocStomp
├──────────────────────────────────────┤
│ FMemory(静态分发门面) │ ← FMemory::Malloc / FMemory::Free
├──────────────────────────────────────┤
│ Container Allocator(容器层) │ ← TArray<T> / TMap<K,V> 默认使用 FMemory
├──────────────────────────────────────┤
│ OS 层(VirtualAlloc / mmap) │ ← 底层的虚拟内存申请
└──────────────────────────────────────┘

2 UObject 的特殊待遇#

UObject 不直接使用 FMemory。它有自己的分配器 GUObjectAllocator,原因是:

  • GC 需要能够遍历所有 UObject——分配器内部维护了对象索引,方便 GC 扫描
  • UObject 频繁加载卸载——使用专门的分配器避免碎片影响
  • 跨 Package 引用计数——UObject 的加载/卸载单元是 Package,分配器需要感知这个粒度

当你写 NewObject<UMyClass>() 时,最终调用的是 StaticAllocateObjectGUObjectAllocator.AllocateUObject,而不是普通的 new

3 FMalloc 的实现选择#

UE 提供了多种 FMalloc 实现:

实现特点适用
FMallocBinned大小分桶,默认选项,平衡速度和碎片大部分平台
FMallocBinned2Binned 的改进版,更好的多线程性能主机平台
FMallocBinned3进一步优化的 BinnedUE5 默认
FMallocStomp每次分配前后加 guard page调试内存越界
FMallocAnsi直接封装 malloc/free兜底
FMallocProfiler记录每次分配调用栈排查内存泄漏

通过编译宏和平台定义,UE 可以选择不同的 FMalloc 实现而不修改一行引擎代码——这是策略模式在引擎层的经典应用。


五、GPU 显存管理#

1 CPU 内存 vs GPU 显存#

CPU 内存管理你已经理解了。GPU 显存管理有两个额外维度:

不同的存储类型

  • VRAM(显存):GPU 直接访问,快但容量有限(通常 8-16GB)
  • System RAM(系统内存):CPU 访问,GPU 通过 PCIe 访问,慢但容量大
  • Upload Heap(上传堆):CPU 写、GPU 读的中间缓冲——每帧的常量缓冲区数据从这里走

不同的访问模式

  • GPU 以帧为单位访问数据——一帧读几十次常量缓冲区、几百次纹理
  • CPU 偶尔写、GPU 频繁读——纹理、网格体
  • CPU 每帧写、GPU 每帧读——常量缓冲区

2 DX12 的显式内存管理#

在 DX12 里,你需要手动创建三种资源:

D3D12_HEAP (物理内存块)
├── Upload Heap → CPU 可写、GPU 可读(小容量,常量缓冲、动态数据)
├── Default Heap → GPU 最优(大容量,纹理、静态网格)
└── Readback Heap → GPU 可写、CPU 可读(截帧数据、计算结果回读)

DX11 的驱动帮你做的事情(决定资源放哪、什么时候搬迁),DX12 里你得自己操心。好消息是你比驱动更了解自己的资源——你知道这张纹理只在加载时更新,之后就只读,所以可以一次放入 Default Heap 永远不过问。

3 UE 的 GPU 内存体系#

UE 通过一系列类封装了 GPU 内存管理:

FRHIResource (基类)
├── FRHIBuffer (顶点/索引/常量缓冲)
├── FRHITexture (2D/3D/Cube/RenderTarget)
└── FRHIView (SRV/UAV/RTV/DSV)

FRHICommandList 在执行 CreateBuffer 等命令时,内部调用平台的 RHI 实现(FD3D12Device::CreateBufferCreateCommittedResource),处理 Heap 选择和内存提交。

RDG 层之上还有 Transient Resource System——一个帧分配器思想的 GPU 版本。一个 Pass 用完的 300MB 临时纹理不会立刻释放,而是标记为空闲、下一个 Pass 需要时直接复用。这就是为什么 RDG 可以显著降低显存占用和 Barrier 开销。

4 移动端 GPU 的特殊性#

移动端 GPU(Tile-based Renderer)的显存模型与桌面端根本不同:

桌面端 (Immediate Mode)移动端 (Tile-Based)
整个帧在 VRAM 里按 Tile 分块渲染,中间数据在片上 SRAM 里
Render Target 全程在显存中每个 Tile 结束时才写回显存
Memory Bandwidth 是瓶颈片上 SRAM 快但容量极小(通常 128-256KB/Tile)

如果在自研引擎组且项目有手游需求,这个差异就是你必须理解的基础知识。概念关键词:Store/Load ActionsMemoryless Render Targets(Metal)、Subpass(Vulkan)。


六、避坑指南#

  1. 帧分配器上的数据不要跨帧持有引用——沉默的 bug,难以复现
  2. 池分配器不要做得太大——池里 90% 空闲槽位就是浪费的碎片
  3. 线性分配器的 Reset 不等于析构——如果你的对象有析构函数(比如 TSharedPtr),在线性分配器上使用之前要确认其生命周期不需要析构
  4. GPU 上传堆不能无限增长——DX12 的 Upload Heap 总量有限(通常 256MB-1GB),用完会崩溃而不是报错。UE 用 FRingAllocation 在 Upload Heap 上做环形复用
  5. 多线程下的分配器要选对——有些分配器(如简单的线性分配器)本质不是线程安全的。UE 的 FMallocBinned 使用了 per-thread free-lists 来减少锁竞争

七、总结#

分配器释放方式碎片速度适用场景
线性分配器整体 Reset最快一帧内的临时数据
栈分配器回滚到 Marker极快Level 加载,阶段型处理
池分配器逐个 Free固定大小的频繁分配(粒子、子弹)
malloc逐个 Free严重不可预测只在必要时用
UE FMallocBinned逐个 Free通用引擎层分配

内存管理是引擎最底层的模块,它不”做”任何游戏功能,但所有子系统都构建在它之上。理解分配器的设计哲学——把不同生命周期的数据交给不同策略的分配器——是理解引擎架构的第一步。

游戏引擎内存管理
https://www.m4doka.xyz/posts/engine/engine-3-memory-management/
作者
m4doka
发布于
2026-07-01
许可协议
CC BY-NC-SA 4.0