欢迎新朋友点赞、关注、收藏三连。

第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 层)等具体类。

UObjectBase
全局索引、Class 指针、Name、Outer、Flags

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 完整创建流程

NewObject<AMyActor>(Outer)

StaticConstructObject_Internal

StaticAllocateObject

同名对象已存在?

Rename 或 Destroy 旧对象

AllocateUObject

FMemory::Malloc → Binned2

FMemory::Memzero 清零

GUObjectArray.AllocateUObjectIndex
分配全局索引

placement new 调用构造函数

从 CDO 复制属性默认值

PostInitProperties

返回 AMyActor*

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++ 构造函数中的成员初始化列表。

属性复制

属性复制

UMyClass (UClass 元信息)

CDO
Health = 100.0
Name = "Default"
MeshComp = nullptr

Instance 1
Health = 100.0 ← CDO
Name = "Default" ← CDO

Instance 2
Health = 75.0 ← 运行时修改
Name = "Player" ← 运行时修改

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 销毁的完整流程

从标记到内存真正释放,要经过多个阶段:

MarkAsGarbage / Destroy

ConditionalBeginDestroy

BeginDestroy(虚函数)
释放渲染资源、取消异步任务

标记 RF_BeginDestroyed

GC 运行,确认对象不可达

ConditionalFinishDestroy

FinishDestroy(虚函数)
最终清理

FreeUObjectIndex — 释放全局索引

调用析构函数 ~UObject

FMemory::Free — 归还内存给 Binned2

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 的标记-清除在操作什么——下一章将深入那片领域。

下一章:第6章 垃圾回收核心——标记-清除与可达性分析

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐