技术博客

UE 容器与 allocator:TArray/TMap 的内存行为

第 7 / 100 篇 · 2025-10-30 · ue5 · TArrayTMap内存分配容器

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 没有的行为:

  1. 删除不搬移元素:Remove(Key) 只是把槽位标记进空闲链表,后续 Add 优先复用空闲槽。迭代顺序不稳定(因为有空洞),但迭代器失效只发生在"复用了你正在迭代的槽位"这种边缘情况。
  2. 查找是两跳:算 Key 的哈希 → 查桶拿到元素索引 → 用索引访问稀疏数组。比 std::unordered_map 的"桶内链表"多一次间接,但元素内存连续(稀疏数组主体是连续的),缓存命中率更好。
  3. 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 的 Slack 增长与 TMap 的稀疏数组结构 TArray 内存布局 已用元素(Num 个) Slack 余量 增长:NewMax = Num + ⌊3·Num/8⌋ + 16,再按分配器块对齐(小数组保底 +16,大数组约 1.375×) TMap 的稀疏数组存储 槽0 (K,V) 槽1 空闲 槽2 (K,V) Remove → 槽位进空闲链,不搬移 Add → 优先复用空闲槽 Compact() → 挤掉空洞 哈希桶 → 稀疏数组索引 hash(K)%N → 槽索引 查找两跳:算哈希 → 查桶 → 按索引访问稀疏数组 Key 要求:可复制 + GetTypeHash + operator==(自定义 struct 做 Key 必须重载 GetTypeHash) 元素内存连续,缓存友好于链表桶

实战结论

  • 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。