Unreal是如何驾驭内存的 第5章 UObject的内存模型——创建、布局与生命周期
欢迎新朋友点赞、关注、收藏三连。
第5章 UObject的内存模型——创建、布局与生命周期
几乎所有引擎对象——Actor、Component、Asset、Widget——都继承自 UObject。从内存管理角度看,它有两条根本约定。第一,生命周期由垃圾回收器接管,开发者不应手动 delete。第二,每个实例会在全局对象数组 GUObjectArray 中注册一个索引,GC 和各类子系统借此快速定位任意 UObject。这两条约定贯穿后续所有讨论:40 字节的基础开销从何而来,NewObject 到底做了哪些事,CDO 如何影响实例化,以及销毁为何不是一次性完成的。
5.1 UObject的内存布局
5.1.1 继承链
UObject 的继承链分为清晰的几层。最底层的 UObjectBase 存放内存管理所必需的数据成员;UObjectBaseUtility 在其上叠加 IsA、GetClass 等工具方法,但不添加数据成员;UObject 本身同样不新增数据成员,却通过虚函数引入了序列化、GC 参与等完整能力。再往上是 AActor(组件、Tick、复制)、APawn / ACharacter(Gameplay 层)等具体类。
5.1.2 UObjectBase的内存成员
UObjectBase 是最底层的基类,五个数据成员加上一个隐式的虚表指针,构成了每个 UObject 实例的最低开销:
// UObjectBase.h (简化)
class UObjectBase
{
private:
UClass* ClassPrivate; // 此对象的 UClass 信息 —— 8B
FName NamePrivate; // 对象名称 —— 8B (ComparisonIndex + Number)
UObject* OuterPrivate; // 外部对象 (Owner) —— 8B
EObjectFlags ObjectFlags; // 对象标志位 —— 4B (int32)
int32 InternalIndex; // GUObjectArray 中的索引 —— 4B
// 隐式 vtable 指针 —— 8B
// 合计 40 字节
};
在 64 位平台上,三个指针各占 8 字节(ClassPrivate、OuterPrivate、隐式 vtable),FName 内部由 ComparisonIndex 与 Number 两个 int32 组合为 8 字节,ObjectFlags 和 InternalIndex 各 4 字节。加在一起刚好 40 字节。即便创建一个空的 UObject 子类,实例也至少消耗这么多。不过这里的 8 字节 FName 仅限 Shipping 构建。Editor 构建默认开启 WITH_CASE_PRESERVING_NAME,FName 会多出一个 4 字节的 DisplayIndex,导致 UObjectBase 增大到约 48 字节(含对齐填充)。后续讨论均以 Shipping 构建的 40 字节为基准。
5.1.3 UObject层的额外成员
UObject 继承自 UObjectBaseUtility,而后者继承自 UObjectBase,但这两层都不新增数据成员。UObject 的贡献完全体现在虚函数表的扩展上——Serialize、PostInitProperties、BeginDestroy、FinishDestroy、GetLifetimeReplicatedProps 等都在这一层声明。因此 sizeof(UObject) 在数据成员层面与 sizeof(UObjectBase) 相同,仍是 40 字节,差异仅在于 vtable 中的函数指针更多。
5.1.4 一个典型Actor的完整内存布局
// 以一个常见的 Actor 为例:
UCLASS()
class AMyActor : public AActor
{
GENERATED_BODY()
UPROPERTY()
float Health;
UPROPERTY()
FString Name;
UPROPERTY()
UStaticMeshComponent* MeshComp;
};
内存布局(64位,概念性):
| 偏移 | 成员 | 大小 | 说明 |
|---|---|---|---|
| 0x00 | vtable指针 | 8B | 虚函数表 |
| 0x08 | EObjectFlags | 4B | 对象标志位 |
| 0x0C | InternalIndex | 4B | GUObjectArray中的索引 |
| 0x10 | ClassPrivate | 8B | 指向UClass的指针 |
| 0x18 | NamePrivate | 8B | FName(ComparisonIndex + Number) |
| 0x20 | OuterPrivate | 8B | Outer对象指针 |
| 0x28 | — | — | UObjectBase结束(40字节) |
| 0x28+ | AActor的成员 | — | RootComponent、bReplicates等 |
| … | AActor结束 | — | — |
| …+ | AMyActor::Health等 | — | 自定义成员变量 |
| … | AMyActor结束 | — | — |
可以看到,0x00 到 0x28 是所有 UObject 共享的 40 字节基础区域。之后是 AActor 基类带来的大量成员——RootComponent 指针、Children 数组、OwnedComponents 集合、Tick 与 Replication 相关字段等等。一个空 AActor 在 UE5 中大约消耗 800 到 1000 字节(含对齐填充),自定义的三个 UPROPERTY 再追加约 28 字节。FString 的 16 字节仅是 TArray 头部,实际字符数据还要额外堆分配。
5.2 NewObject——对象创建的核心入口
5.2.1 API
NewObject 是最常用的 UObject 创建入口,它是一个模板函数,接受 Outer、Class、Name、Flags、Template 等参数。大多数情况下只需传入 Outer 即可,其余参数都有合理的默认值:
// 最常用的创建方式
template<class T>
T* NewObject(
UObject* Outer = (UObject*)GetTransientPackage(),
UClass* Class = T::StaticClass(),
FName Name = NAME_None,
EObjectFlags Flags = RF_NoFlags,
UObject* Template = nullptr
);
// 带SubobjectOverrides的变体
template<class T>
T* NewObject(
UObject* Outer,
FName Name,
EObjectFlags Flags = RF_NoFlags,
UObject* Template = nullptr
);
5.2.2 完整创建流程
5.2.3 内存分配大小的计算
// StaticAllocateObject 中确定分配大小
SIZE_T TotalSize = Class->GetPropertiesSize();
// GetPropertiesSize() 返回 sizeof(YourClass),包含对齐
// 对齐要求
SIZE_T Alignment = FMath::Max(
Class->GetMinAlignment(),
(SIZE_T)DEFAULT_ALIGNMENT // 16
);
5.2.4 内存清零
创建流程中有一步容易被忽略:在 placement new 之前,UE 会对整块分配区域执行 Memzero。这意味着所有指针、数值、布尔在构造函数开始之前就已经是零值(nullptr / 0 / false),与标准 C++ new 的未初始化行为截然不同。代价是每次 NewObject 多一次 memset,但换来的是更安全的初始状态。
5.3 GUObjectArray——全局对象注册表
5.3.1 为什么需要全局对象数组
标记-清除式 GC 需要能够遍历所有 UObject,而直接扫描堆内存的代价完全不可接受。因此 UE 维护了一个全局数组 GUObjectArray,将每个活跃 UObject 的指针和少量元数据集中在一起,GC 只需线性扫描这个数组即可。
5.3.2 数据结构
核心结构是一个分块数组,每个条目是一个 FUObjectItem,包含对象指针、GC 标志、集群索引和序列号。下面是简化后的声明:
// GUObjectArray的核心结构(简化)
class FUObjectArray
{
public:
// 对象条目
struct FUObjectItem
{
UObjectBase* Object; // 指向UObject实例
int32 Flags; // 标志位(GC相关)
int32 ClusterRootIndex; // 所属GC集群的根索引
int32 SerialNumber; // 序列号(用于弱引用验证)
};
// 核心存储:Chunked Array
// 不是连续的大数组,而是分块存储
enum { NumElementsPerChunk = 64 * 1024 }; // 每块64K个条目
FUObjectItem** Objects; // 指向Chunk指针数组
int32 MaxChunks;
int32 NumChunks;
// 空闲索引列表
TLockFreePointerListLIFO<int32> ObjAvailableList;
// 对象总数
TAtomic<int32> ObjObjects;
// 分配索引
int32 AllocateUObjectIndex(UObjectBase* Object);
// 释放索引
void FreeUObjectIndex(UObjectBase* Object);
};
// 全局实例
extern FUObjectArray GUObjectArray;
5.3.3 内存影响
每个 FUObjectItem 含一个指针和三个 int32,原始大小 20 字节,加上对齐填充通常按 24 字节计。假设游戏运行时有 100,000 个 UObject,索引本身消耗约 2.4 MB;再加上被索引的对象实例——按平均 500 字节估算——大约 50 MB。两者合计约 52 MB,其中索引开销不到 5%。
5.3.4 分块设计的原因
GUObjectArray 没有使用普通 TArray,而是采用了分块数组(Chunked Array),每块容纳 64K 个条目,背后有三层考虑。第一是避免大块连续分配:100 万个对象若用单一连续数组需要约 24 MB 的连续虚拟地址空间,分块后每块只需约 1.5 MB,对虚拟地址布局的压力显著降低。第二是扩容成本:TArray 扩容时必须整体拷贝,而分块数组只需追加新块,是 O(1) 而非 O(N)。第三也是最重要的一点,是索引稳定性——分块数组不会因扩容而搬移已有元素,InternalIndex 一旦分配就是稳定的标识符,弱引用机制(后续第 9 章讨论)正是依赖这一点。
5.4 CDO——Class Default Object
5.4.1 什么是CDO
每个 UClass 都持有一个“原型对象”——Class Default Object,简称 CDO。它存储了该类所有 UPROPERTY 的默认值。当 NewObject 创建新实例时,属性值是从 CDO 逐字段复制过来的,而非依赖 C++ 构造函数中的成员初始化列表。
5.4.2 CDO的内存影响
每个 UClass 都有且仅有一个 CDO 实例,即使该类在运行时从未被实例化也不例外。CDO 在引擎启动阶段创建,直到引擎关闭才销毁。对于 C++ 类,CDO 的大小等于类本身的 sizeof——通常可以接受;但蓝图类的 CDO 可能显著膨胀,因为蓝图中设置的默认值、引用的大资产都会被 CDO 持有,这些硬引用还会阻止被引用的资产被 GC 回收。
// 获取CDO
UMyClass* DefaultObj = GetDefault<UMyClass>();
// 等价于
UMyClass* DefaultObj = UMyClass::StaticClass()->GetDefaultObject<UMyClass>();
5.4.3 属性初始化:CDO复制
属性的复制发生在构造函数执行之后。引擎遍历 UClass 的 FProperty 链表,将 CDO 中每个属性的值拷贝到新实例:
// 简化后的属性初始化流程
void UClass::InitPropertiesFromCustomList(uint8* InData, const uint8* CDOData)
{
for (FProperty* Prop : PropertyLink)
{
Prop->CopyCompleteValue_InContainer(InData, CDOData);
}
}
换言之,构造函数里对 UPROPERTY 成员的赋值,会被之后的 CDO 复制覆盖。在没有蓝图继承的纯 C++ 类中,两者通常一致(因为 CDO 本身就是由构造函数建立的);但一旦存在蓝图子类,蓝图中修改的默认值通过 CDO 复制传递给每个新实例,优先级高于 C++ 构造函数中的硬编码值。
5.5 CreateDefaultSubobject——组件的创建方式
CreateDefaultSubobject 是构造函数中创建默认子对象的专用方法:
AMyActor::AMyActor()
{
MeshComp = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
RootComponent = MeshComp;
}
5.5.1 与NewObject的区别
两者最关键的差异在于 CDO 语义。NewObject 可以在任何时机调用,每次都生产一个独立对象;CreateDefaultSubobject 只能在构造函数中使用,创建的子对象会成为 CDO 的一部分。后续实例化时,实例的子对象并非再次走 CreateDefaultSubobject 的路径,而是从 CDO 中已有的子对象模板复制而来。此外,CreateDefaultSubobject 自动将当前对象设为 Outer,并使子对象随 Owner 一起参与序列化。
5.5.2 内存影响
既然每次 CreateDefaultSubobject 调用都在 CDO 阶段创建一个子对象实例,CDO 的内存与调用次数直接相关。一个拥有五个默认组件的 Actor 类,其 CDO 就包含五个组件实例及它们各自的数据。后续每个运行时实例又从这些子对象模板复制产生,形成“一CDO五子对象 → N实例各五子对象”的结构。过度使用 CreateDefaultSubobject——比如为每种可能的功能都预建一个 Component——会导致 CDO 显著膨胀,并通过复制放大到所有实例。
5.6 对象销毁流程
5.6.1 UObject销毁不是delete
直接 delete 一个 UObject 会绕过索引释放和 GC 记账,几乎必然导致崩溃。正确的做法是通过引擎 API 触发销毁:
// ✗ 错误——永远不要直接 delete
delete MyActor;
// ✓ 正确
MyActor->Destroy(); // 仅对 Actor 有效
MyActor->ConditionalBeginDestroy(); // 通用方式
MyActor->MarkAsGarbage(); // 标记为垃圾,等待 GC 清理
5.6.2 销毁的完整流程
从标记到内存真正释放,要经过多个阶段:
5.6.3 异步销毁
销毁是分步、异步完成的。BeginDestroy 在标记阶段调用,用于启动异步资源释放(比如 GPU 纹理的卸载)。随后引擎会反复询问 IsReadyForFinishDestroy,直到所有异步操作收尾,才调用 FinishDestroy 执行最终清理。最后,GC 在清扫阶段释放全局索引、调用析构函数、归还内存。这种分步设计把销毁成本摊开到多帧,避免了一次性销毁大量对象引起的卡顿。
5.7 对象标志 (EObjectFlags)
UObjectBase 中的 ObjectFlags 成员是一个 32 位位域,编码了对象的生命周期状态、序列化属性和 GC 信息。下面列出主要的标志位(具体数值以引擎源码 ObjectMacros.h 为准,各版本可能微调):
enum EObjectFlags : int32
{
// 生命周期标志
RF_Public = 0x00000001, // 在包外可见
RF_Standalone = 0x00000002, // 不被引用也不GC
RF_Transactional = 0x00000008, // 支持Undo/Redo
RF_ClassDefaultObject= 0x00000010, // 是CDO
// 垃圾回收相关
RF_ArchetypeObject = 0x00000020, // 是模板对象
RF_Transient = 0x00000040, // 不序列化
RF_NeedLoad = 0x00000200, // 需要加载
RF_NeedPostLoad = 0x00000400, // 需要PostLoad
// 销毁相关
RF_BeginDestroyed = 0x00010000, // BeginDestroy已调用
RF_FinishDestroyed = 0x00020000, // FinishDestroy已调用
RF_WasLoaded = 0x00080000, // 从磁盘加载
RF_NewerVersionExists= 0x00100000, // 有更新版本
// GC内部使用
RF_Garbage = 0x00200000, // 已标记为垃圾
// 具体数值可能随引擎版本微调,以源码中 ObjectMacros.h 为准
};
所有标志位压缩在 UObjectBase 的 4 字节 ObjectFlags 成员中,不引入额外内存开销。GC 在标记阶段会检查 RF_Garbage、RF_BeginDestroyed 等标志决定对象的可达状态,序列化系统则依据 RF_Transient、RF_NeedLoad 等标志决定行为。
5.8 UObject的内存开销统计
5.8.1 运行时查看
最直接的手段是控制台命令 obj list,它会按类别统计所有活跃 UObject 的数量与内存:
// 控制台命令
obj list // 列出所有类型
obj list class=StaticMeshComponent // 按类型筛选
// 输出示例:
// Class Count NumKBytes MaxKBytes
// StaticMeshComponent 847 1234 1567
// SkeletalMeshComponent 156 789 1023
// Actor 1247 3456 4567
5.8.2 单个对象大小
若想在代码中拿到精确数字,可以通过 UClass 获取结构体大小,或借助 FArchiveCountMem 模拟序列化来估算含堆分配在内的“真实”大小:
SIZE_T ExclusiveSize = MyObject->GetClass()->GetStructureSize();
FArchiveCountMem MemAr;
MyObject->Serialize(MemAr);
SIZE_T SerializedSize = MemAr.GetMax();
5.8.3 常见UObject子类的内存大小
以下是 UE5(64 位)下几个典型类的近似 sizeof,仅代表实例自身的 C++ 数据成员开销,不含引用资产的内存:
| 类型 | 近似 sizeof | 包含的主要增量 |
|---|---|---|
| UObject | 40 B | 基础开销 |
| UActorComponent | 200–300 B | 注册、Tick、Owner 引用 |
| USceneComponent | 400–500 B | FTransform、AttachChildren |
| UStaticMeshComponent | 600–800 B | Mesh 引用、材质覆盖数组 |
| AActor | 800–1000 B | 组件数组、Replication、Tick |
| ACharacter | 2000–3000 B | MovementComponent、CapsuleComponent |
| UUserWidget | 500–800 B | Slot、WidgetTree |
一个 UStaticMeshComponent 实例本身只占几百字节,但它引用的 UStaticMesh 资产(含顶点、索引、LOD 数据)可能是几十 MB——这是“sizeof 尺寸”与“内存足迹”之间的巨大鸿沟。
5.9 永久对象与GC跳过
UClass、CDO、引擎底层单例等对象的生命周期贯穿整个进程,每次 GC 都对它们做可达性分析纯属浪费。UE 通过配置项 gc.MaxObjectsNotConsideredByGC 来优化这一点:GUObjectArray 中索引小于此阈值的对象在 GC 标记阶段会被直接跳过。由于引擎启动时最先创建的正是 UClass 和 CDO 这类永久对象,它们自然占据了最靠前的索引位,恰好落在跳过范围内。
// gc.MaxObjectsNotConsideredByGC 的作用
// GUObjectArray 中 InternalIndex < 此值的对象,GC 标记阶段直接跳过
// 减少每次 GC 的遍历量
此外,历史版本中存在过 SizeOfPermanentObjectPool 配置项,用于将永久对象集中分配到一块专用内存池中,但在 UE5 中这一功能默认禁用,实际项目里很少需要手动启用。
5.10 FObjectInitializer——构造时的上下文
UObject 的构造函数有两种风格。使用 GENERATED_BODY() 时,声明的是无参构造函数;使用旧式 GENERATED_UCLASS_BODY() 时,声明的构造函数显式接收一个 const FObjectInitializer& 参数。无论哪种风格,引擎内部都会在调用构造函数之前将一个 FObjectInitializer 压入线程局部栈,构造函数内部通过 FObjectInitializer::Get() 获取它——这正是 CreateDefaultSubobject 能够在无参构造函数中工作的原因。
// GENERATED_BODY() 风格(UE5 推荐)—— 无参构造函数
AMyActor::AMyActor()
{
// 内部通过 FObjectInitializer::Get() 从线程局部栈取得上下文
MeshComp = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
}
// GENERATED_UCLASS_BODY() 风格(旧式)—— 显式接收参数
AMyActor::AMyActor(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
MeshComp = ObjectInitializer.CreateDefaultSubobject<UStaticMeshComponent>(
this, TEXT("Mesh"));
}
FObjectInitializer 在栈上创建,仅在构造过程中有效,随后由其析构函数触发 CDO 属性复制和 PostInitProperties 调用。它携带三项关键信息:正在创建的对象指针、用作属性模板的 CDO(或 Archetype),以及子对象覆盖列表。简化后的结构:
struct FObjectInitializer
{
UObject* Obj; // 正在创建的对象
UObject* ObjectArchetype; // CDO 或模板
bool bCopyTransientsFromClassDefaults;
UObject* CreateDefaultSubobject(UObject* Outer, FName Name, UClass* Class, ...);
TArray<FOverriddenSubobject> SubobjectOverrides;
};
FObjectInitializer 自身不占堆内存——它是栈上的临时对象,构造完成即析构。唯一需要关注的是 SubobjectOverrides 数组:在深层继承链中覆盖条目可能达到几十条,但每条只是两个指针大小,总量通常可忽略。
5.11 小结
回顾本章的核心脉络。UObjectBase 的 40 字节(Shipping 构建;Editor 约 48 字节)由 vtable、Class 指针、FName、Outer、Flags、InternalIndex 组成,是每个 UObject 实例无法逃脱的固定税。NewObject 在此基础上走过 Malloc → 清零 → 注册全局索引 → 构造函数 → CDO 属性复制 → PostInitProperties 的完整链路,每一步都对内存留下可追溯的痕迹。GUObjectArray 用分块数组管理所有实例的索引,每条目约 24 字节,换来 O(1) 查找和稳定的索引值。CDO 作为每个 UClass 的原型,在引擎启动时创建、直到关闭才释放,蓝图类 CDO 的膨胀和硬引用滞留值得警惕。销毁走 BeginDestroy → IsReadyForFinishDestroy → FinishDestroy 三阶段分步完成,成本摊到多帧。永久对象通过 gc.MaxObjectsNotConsideredByGC 跳过 GC 检查,减少每次标记的遍历量。
理解了 UObject 的内存模型,才能真正理解 GC 的标记-清除在操作什么——下一章将深入那片领域。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)