UE 的容器不是 STL 的简单换皮——TArray 的增长策略、TMap 的存储结构、TSet 的迭代器失效规则都和 STL 有实质差异,而且这些差异直接影响性能调优时的判断。这篇基于 Runtime/Core/Public/Containers/(Array.h、Map.h、Set.h、ContainerAllocationPolicies.h)把核心容器的内存行为拆开。
为什么不用 STL:三个历史原因和一个现实原因
历史原因:UE 诞生于 STL 实现质量参差的年代(早期 MSVC 的 vector 慢、ABI 不稳定),跨平台一致性要求引擎自带容器。现实原因只有一个但足够硬:反射和序列化。UPROPERTY() TArray<int32> Values; 能被序列化、被 GC 遍历、被属性面板显示,是因为 TArray 有对应的 FArrayProperty 反射描述。std::vector 对 UHT 是不可见的。所以"为什么不用 STL"在 UObject 体系里根本不是选择题——用了就脱离对象模型。
非反射场景(模块内部的纯 C++ 计算)用 STL 没有禁令,但 UE 的风格指南和大部分引擎代码仍然用自家容器,保证风格统一和分配器一致性(见文末的分配器部分)。
TArray:Slack 是核心概念
TArray 的内存模型是"逻辑大小(Num)+ 分配容量(Max)",但和 std::vector 的关键区别在于 Slack(余量)是显式暴露的一等公民:
TArray<int32> Arr;
Arr.Add(1); // Num=1, Max=1(或按初始分配策略)
Arr.Reserve(100); // Num=1, Max=100 —— 显式预留
int32* Slack = Arr.GetData() + Arr.Num(); // 余量区的起始地址
Arr.AddUninitialized(10); // Num=11,直接占用 Slack,不构造元素
Reserve/AddUninitialized/SetNumUninitialized 这组 API 的存在是因为 UE 大量场景需要"先分配后填充":网络反序列化、批量加载、与 C API 交互。STL 要模拟得靠 resize(会构造元素)+ memcpy 的组合,TArray 直接给了不构造的余量操作。
增长策略在 ContainerAllocationPolicies.h 的 DefaultCalculateSlackGrow:新容量不是简单的翻倍,而是
$$\text{NewMax} = \text{Num} + \left\lfloor \frac{3 \cdot \text{Num}}{8} \right\rfloor + 16$$
再按分配器的块粒度向上对齐。这个公式的含义是:小数组(<几十元素)每次增长至少多 16 个元素的余量,大数组按约 1.375 倍惰性增长。和 std::vector 的 2 倍增长比,UE 的策略更省内存(1.375 < 2),代价是增长更频繁。引擎的赌注是:游戏场景的数组多数不大,省下的内存比多几次 realloc 值钱。
实际验证:往一个空 TArray<uint8> 里逐个 Add,前几次 Max 的变化是 4 → 8 → 16 → 32(分配器最小块约束主导),过了 64 之后开始进入 $1.375N + 16$ 的节奏。想避免增长期的反复 realloc,唯一的办法就是 Reserve。
迭代器失效规则比 STL 更严格:Add/Insert/Remove 都可能触发 realloc 或元素搬移,任何持有的指针/迭代器/引用全部失效。Remove 是搬移语义——删中间的元素,后面所有元素前移(memmove),不是 std::vector::erase 的"返回下一个有效迭代器"那么温和,UE 的 Remove 返回 INDEX_NONE。遍历时删除的安全姿势是倒序遍历或 RemoveAll 谓词。
TMap/TSet:稀疏数组 + 哈希桶
TMap 不是红黑树也不是开放寻址哈希表,它的结构是TSparseArray(稀疏数组)存元素 + 哈希桶数组存索引(Containers/Map.h 内部用 TSet 实现,TSet 用 TSparseArray):
元素存储:TSparseArray<TPair<Key, Value>>
├── 索引 0: (KeyA, ValA) ← 有效
├── 索引 1: 空闲(FreeList) ← 被删除后进入空闲链
└── 索引 2: (KeyB, ValB) ← 有效
哈希桶:int32 HashBuckets[BucketCount]
bucket[hash(KeyA) % N] → 存元素索引 0
bucket[hash(KeyB) % N] → 存元素索引 2
这个结构带来几个 STL 没有的行为:
- 删除不搬移元素:
Remove(Key)只是把槽位标记进空闲链表,后续Add优先复用空闲槽。迭代顺序不稳定(因为有空洞),但迭代器失效只发生在"复用了你正在迭代的槽位"这种边缘情况。 - 查找是两跳:算 Key 的哈希 → 查桶拿到元素索引 → 用索引访问稀疏数组。比
std::unordered_map的"桶内链表"多一次间接,但元素内存连续(稀疏数组主体是连续的),缓存命中率更好。 Compact()显式整理:长期增删后稀疏数组空洞变多,遍历变慢,调Compact()把空洞挤掉。这是 STL 完全没有的概念,因为 vector/map 没有"空洞"状态。
Key 的哈希函数来自 GetTypeHash(Templates/TypeHash.h),内置类型都有特化,自定义类型要自己重载。TMap 的 Key 要求 可复制 + 可哈希 + 可比较(==),自定义 struct 做 Key 忘了实现 GetTypeHash 会编译报错,报错信息指向 StaticAssert——看到 "type must be hashable" 就是缺这个。
分配器:FMalloc 的分层
所有 UE 容器的内存最终都走 FMemory::Malloc(HAL/FMemory.h),实际实现是平台相关的 FMalloc 派生类。桌面平台默认是 FMallocBinned2/3(HAL/MallocBinned2.h)——一个按大小分桶的池化分配器:
- 小对象(< 约 32KB)按大小分到固定尺寸的池页,分配/释放是 O(1) 的位图操作,几乎无锁竞争(每线程有本地缓存)。
- 大对象直接转发给 OS 分配器(
mmap/VirtualAlloc)。
这个分层对容器行为有直接解释力:TArray 增长公式的"按分配器块粒度对齐"就是在对齐 BinnedMalloc 的池尺寸——你一个 100 字节的分配实际可能占 128 字节的池块,TArray 的 Slack 计算会利用这个对齐避免"差一个字节就跨块"的浪费。查内存问题时 stat memory 看到的 BinnedMalloc 各项就是这个分配器的记账。
与 STL 的换算关系
| UE 容器 | STL 对应 | 关键差异 |
|---|---|---|
TArray |
std::vector |
Slack 显式暴露;增长 ~1.375× 而非 2×;Remove 是搬移语义 |
TMap |
std::unordered_map |
稀疏数组+桶,删除留空洞;迭代顺序不稳定 |
TSet |
std::unordered_set |
同上,TMap 的 Value 为空特化 |
TLinkedList |
std::list |
侵入式/非侵入式两种,用得极少 |
TQueue |
std::queue |
支持无锁模式(EQueueMode::Spsc) |
TUniquePtr |
std::unique_ptr |
基本等价 |
FString |
std::string |
引用计数字符串,深拷贝语义但写时复制 |
实战结论
TArray预分配永远用Reserve,别靠 Add 触发自然增长——1.375 倍增长在大数组上比 2 倍增长多很多次 realloc。- 遍历时删除用倒序循环或
RemoveAll/RemoveAllSwap,正序for+Remove(i)是最经典的越界来源(Remove 后 Num 减一,循环上界没更新)。 TMap长期运行后遍历变慢先调Compact(),不行再考虑换结构;Key 用自定义 struct 时把GetTypeHash和==一起写了,别等 StaticAssert 报错。- 内存排查先看
stat memory的 BinnedMalloc 项:如果"已分配"和"OS 实际占用"差很大,是池化分配器的碎片,不是泄漏;确认泄漏要用obj list或 LLM 标签,不能靠任务管理器。
容器解决的是单线程的内存布局,多线程的任务调度是另一套东西——TaskGraph。下一篇看 UE 的并发任务系统怎么把活分发到 NamedThread 和 AnyThread。