从 std::vector到TArray :Unreal Engine 如何为游戏重新发明动态数组
看详细的TArray请看这一篇:
在 Unreal Engine 世界里,提到动态大小的连续内存容器,绝大多数情况下我们都会想到 TArray。乍一看,TArray似乎是一个更安全、更现代的 std::vector 替代品,但实际上两者在设计理念、底层实现细节以及性能权衡方面有着显著不同。特别是在内存分配策略、元素搬移方式,以及对游戏运行负载的适应能力上,TArray体现出对游戏开发场景的深度定制和优化。
内存分配:
标准库的 std::vector 依赖于 std::allocator,其默认实现通常调用 ::operator new 和 ::operator delete,最终走向操作系统的 malloc 和 free。这套方案跨平台统一且符合C++标准的要求,但本质上是一个通用工具,未针对游戏的特殊需求做深度优化。
而在UE中,所有TArray的内存申请都会通过 FMemory::Malloc、FMemory::Realloc、FMemory::Free 转发给一个全局指针 GMalloc,而这个指针会根据平台和构建配置指向不同的内存分配器。譬如,常见的Windows和Linux发布版本使用性能卓越的 FMallocBinned2 或其后续版本 FMallocBinned3,而编辑器或者开发版则可能采用基于Intel Threading Building Blocks的 FMallocTBB 或微软的 Mimalloc,部分主机平台还使用定制的专属内存池。
其中,FMallocBinned2 是一个典型的分桶分配器。它将所有可能的分配大小划分成一系列固定大小的“桶”,例如16字节、32字节,直到几KB的大小。每个桶内部维护固定大小空闲内存块的链表。当申请内存时,系统先定位目标桶,若有可用空闲块直接分配;否则它会一次从操作系统申请一整页大内存(通常64KB对齐),将其拆分为多个同尺寸小块,批量加入空闲链表。
这种设计极大优化了游戏中大量频繁的小对象内存分配问题,例如组件、粒子数据、UI元素以及TArray自身的缓冲区,都属于这个范畴。相较于传统的malloc、glibc ptmalloc或Windows堆分配器,binned allocator带来了低外部碎片、极快的分配释放速度(O(1)的空闲块出栈操作)、更小的元数据开销,以及在Realloc时几乎零成本的内存原地扩展(当新大小仍属于同一桶时)。
反观 std::vector 每次扩容时都会分配一块新内存区域,搬移旧数据,释放旧块,不仅频繁改变数据的物理地址,导致缓存失效,而且更易造成碎片,尤其在移动端及长期运行的游戏中,这些开销放大尤为明显。
元素迁移:
TArray 与 std::vector 在扩容时元素搬移的不同是它们性能差距最大的体现。C++标准明确要求 std::vector 对于非平凡(non-trivial)类型,必须使用元素的 move-constructor(在 noexcept 情况)或 copy-constructor 逐个构造新对象,同时逐个调用旧元素析构函数,然后释放旧内存。即便结构体本身仅含指针,只要类型未被标记为trivially copyable,vector也不会直接使用 memcpy。为保证安全和正确性,这种保守策略必然带来额外的构造析构开销。
与此相反,TArray大胆假设它包含的元素类型均可安全地通过 memcpy 进行内存搬移,即它们是“trivially relocatable”的。扩容、插入、移除导致内存搬移时,TArray绝大部分代码路径都会直接使用高效的 FMemory::Memcpy,跳过构造析构。Epic选择这条路,是基于游戏中绝大多数类型(如 FVector、FTransform、FIntPoint、FGameplayTag,甚至包含内嵌TArray/FString或指向UObject指针的结构体)都满足这一假设,获得巨大性能提升,尤其是在数组规模大、扩容频繁的情况下,TArray相较std::vector能快上2到10倍以上。
当然,这种做法有风险:如果元素内部包含自引用指针或非trivial的成员(如std::mutex、std::atomic,或特殊move逻辑的自定义类型),直接 memcpy 会导致未定义行为甚至崩溃。但游戏代码一般避免此类设计,完全信赖这一优化。
删除操作的优化
游戏中动态列表通常删除操作更频繁,且大多数情形不要求维持元素顺序。针对这一特点,TArray提供了 RemoveSwap 和 RemoveAtSwap 函数:它们通过用待删除元素交换尾元素,再移除尾部,完成 O(1) 的快速删除,而 std::vector 默认为顺序保持的 erase()操作,代价为O(n)的内存搬移。虽然可以自行实现 swap-and-pop,但并非标准接口。这组API极大简化了游戏中对性能敏感的删除场景。
针对小数组的额外优化与生态完整性
游戏里最常见的并非成千上万元素的巨型数组,而是大量大小在4到32之间的小数组。TArray通过 TInlineAllocator<N> 模板参数,允许前N个元素存放在栈上或者对象内部内嵌缓冲区,避免堆分配开销与碎片。这个特性在粒子系统、组件列表、UI数据等频繁创建销毁的小规模场景中尤为有效。
而 std::vector 则缺乏内置的小数组优化,除非引入第三方small vector实现,这使得TArray得以在小规模数组处理上拥有天然优势。
总的来说,Unreal Engine的TArray并非简单复制std::vector,而是在考虑游戏真实场景负载和性能瓶颈后,针对内存分配、元素搬移、删除策略、小数组优化等核心细节做了深度定制。它是Epic从底层重新设计动态数组的典范,更适合复杂游戏开发中的极端性能需求和多平台适应性。理解这些差异,有助于更好地利用UE的容器优势,写出更高效、稳定的游戏代码。
下面仔细看看TArray 中如何通过类型特征判断是否可用 memcpy:
TArray 等容器通过类型特征判断是否可用 memcpy 进行批量操作,以提升性能。该机制位于 MemoryOps.h,并依赖多个类型特征类。
核心类型特征:TIsTriviallyCopyConstructible
判断是否可平凡复制构造的基础是 TIsTriviallyCopyConstructible,定义在 IsTriviallyCopyConstructible.h:
1 | template <typename T> |
它封装了 C++ 标准库的 std::is_trivially_copy_constructible_v,用于判断类型是否可平凡复制构造。对于 POD 类型(如 int32、float、FVector),该值为 true;对于包含非平凡构造函数的类型(如 FString、TArray),该值为 false。
C++的std::is_trivially_copy_constructible_v怎么实现的还是自己去问AI吧,涉及到了编译器的行为,能够直接访问编译器前端的类型分析结果。
TIsBitwiseConstructible:位操作构造判断
TIsBitwiseConstructible 在 UnrealTypeTraits.h 中定义,用于判断是否可用位操作(如 memcpy)构造对象:
1 | template <typename T, typename Arg> |
默认情况下,TIsBitwiseConstructible<T, Arg> 为 false。当源类型和目标类型相同时,它会检查 TIsTriviallyCopyConstructible<T>::Value。如果类型可平凡复制构造,则可以用 memcpy 进行位操作构造。
TCanBitwiseRelocate_V:位重定位判断
TCanBitwiseRelocate_V 在 MemoryOps.h 的 UE::Core::Private::MemoryOps 命名空间中定义,用于判断是否可用 memmove 进行位重定位:
1 | template <typename DestinationElementType, typename SourceElementType> |
当目标类型与源类型相同,或满足位构造且源类型可平凡析构时,可以使用 memmove 进行重定位。
ConstructItems:根据类型特征选择实现
ConstructItems 函数根据 TIsBitwiseConstructible 选择实现。如果类型支持位构造,使用 memcpy:
1 | template < |
如果不支持位构造,则逐个调用拷贝构造函数:
1 | template < |
RelocateConstructItems:重定位操作的优化
RelocateConstructItems 用于将对象从一个内存位置重定位到另一个位置(破坏性移动)。如果 TCanBitwiseRelocate_V 为 true,使用 memmove:
1 | template < |
否则,逐个进行移动构造并析构源对象:
1 | template < |
MoveConstructItems:移动构造的优化
MoveConstructItems 根据 TIsTriviallyCopyConstructible 选择实现。如果类型可平凡复制构造,使用 memmove:
1 | template < |
否则,逐个进行移动构造:
1 | template < |
DestructItems:析构函数的优化
DestructItems 根据 std::is_trivially_destructible_v 选择实现。如果类型可平凡析构,函数为空:
1 | template < |
否则,逐个调用析构函数:
1 | template < |
类型特征判断的完整流程
当 TArray 需要复制或移动元素时,流程如下:
- 检查
TIsTriviallyCopyConstructible<T>::Value:判断是否可平凡复制构造 - 检查
TIsBitwiseConstructible<Dest, Source>::Value:判断是否可用位操作构造 - 检查
std::is_trivially_destructible_v<Source>:判断源类型是否可平凡析构 - 综合判断
TCanBitwiseRelocate_V:决定是否可用 memmove 进行重定位
如果所有条件满足,使用 memcpy/memmove;否则使用逐个构造/析构的方式。
性能影响
对于 POD 类型(如 TArray<int32>、TArray<FVector>),使用 memcpy 可以显著提升性能。例如,复制 1000 个 FVector 时,memcpy 只需一次内存拷贝,而逐个构造需要 1000 次构造函数调用。
对于非 POD 类型(如 TArray<FString>),必须使用逐个构造的方式,因为 FString 包含动态分配的内存,不能简单地通过 memcpy 复制。