前言
本文章面向已经熟悉 Unity、正在转向Unreal Engine的开发者,目标是帮助读者建立UE对象生命周期和内存管理的基本心智模型。
Unity开发者日常工作中通常使用C#以及额外的解释型脚本语言(例如:Lua),这些语言大多都内置成熟的垃圾回收系统,大大降低了开发者管理内存的心智负担。
但转到Unreal Engine领域,尤其还要以C++作为主要开发语言时,首要任务就要搞清楚两者在内存管理上的差异。这也是面试官对Unity转UE的开发者最大的“担忧”之处。
本文不讲解C++语法、C++对象模型,也不深入探讨GC内部算法与底层实现细节。
Unity与UE对象体系、GC系统差异
UE中的双轨制对象体系(UObject对象 vs 普通C++对象)
Unity所使用的C#中,除了接口、指针等少数类型,几乎所有类型都派生自System.Object;其中引用类型的实例分配在托管堆上,生命周期由GC追踪并管理。值类型(如Vector3)则分配在线程栈上或内联宿主对象内部,随宿主或栈帧一起消失,不受GC影响。
而 Unreal Engine 中将数据结构严格分为两大阵营:
- 派生自
UObject的UObject体系类。 - 普通C++类或结构体。
两者内存管理机制完全不同:
| 对比维度 | UObject体系类(UCLASS) | 普通C++类/结构体(Non-UObject) |
|---|---|---|
| 典型代表 | AActor、UActorComponent |
纯C++类、数据结构、网络解包数据、SWidget(Slate UI) |
| 内存管理机制 | UE引擎垃圾回收(Garbage Collection, GC) | 引用计数/智能指针 |
| 指针定义方式 | 使用UPROPERTY宏标记 例如:UPROPERTY() TObjectPtr<UMyObject> MyObj; |
使用虚幻智能指针库 例如:TSharedPtr<FMyClass> MyClassPtr; |
| 生命周期控制 | 由引擎后台自动定期回收无引用的对象 | 由最后一个持有的TSharedPtr离开作用域时自动delete释放 |
严格来说,Unity其实也是“双轨”制。
UnityEngine.Object体系由托管包装器(C#)与原生对象(C++)两层构成——包装器受GC管理,原生对象却依赖引擎引用计数和显式地调用Destroy()。只是这种差异被引擎封装得很好,多数时候开发者感知不到。
UE 与 Unity GC系统差异
UE与Unity GC系统特征差异:
| 分类 | Unreal Engine | Unity(Mono/IL2CPP + Boehm) |
|---|---|---|
| 引用计数/追踪式 | 追踪式 | 追踪式 |
| 精确扫描/保守扫描 | 精确式扫描,且必须提供反射信息(例如:UPROPERTY()),才能识别引用。 |
扫描堆上的对象是精确式扫描;扫描线程栈、寄存器时为保守扫描(逐字扫描内存,每个“看起来”像指针的字都当指针/引用处理)。 |
| GC过程中是否移动对象内存 | 否 | 否 |
| GC过程中是否会主动暂停 | 可达性分析阶段默认情况下(不开启增量GC)需要暂停游戏线程和所有走FGCScopeGuard的UObject操作;清理销毁阶段按每帧2ms分摊到多帧中,不需要暂停。 |
标记阶段切成约3ms的步进分摊到每帧,每步扫描线程栈会短暂地冻住所有托管线程(包括主线程)。 清扫阶段(Unity没有UE那种“销毁”阶段)不需要暂停。 |
UE GC的系统中,UObject对象之间的引用关系必须明确声明:通过反射信息(
UPROPERTY()宏标记)、手动添加到根集(AddToRoots())。普通C++对象若持有UObject对象,也需要手动声明(通过继承FGCObject)。
Unity的GC过程大致分为:标记(Mark)和清扫(Sweep)两个阶段。同样的,UE的GC系统也大体上分为两个阶段—— 可达性分析(Reachability analysis) 阶段和清理销毁阶段。
之所以说“可达性”,是因为UE构建了一个引用图表以确定哪些UObject仍在使用,哪些是孤立的。该图表的根部是一组指定为“根集”的UObject对象。引擎从根集开始,搜索已知UObject引用树来追踪所有被引用的UObject对象。任何未被引用的UObject对象(未在引用树中搜索到这些对象)将被认为是不再需要的,因此被删除。
UE GC系统的清理销毁步骤主要有以下步骤(并且这些步骤都按照每帧2ms的预算逐步执行,并非一次性执行完):
- 将对象从全局哈希表中摘除,
FindObject()从此找不到该对象,名字也将被复用。 - 调用
Object->ConditionalBeginDestroy()-> 虚函数BeginDestroy(),打上RF_BeginDestroyed标志位。主要是为了清理外部登记、释放外部资源、解开引用。 - 等到
Object->IsReadyForFinishDestroy()结果为true时,才继续调用Object->ConditionalFinishDestroy()->FinishDestroy()。主要是为了应对带渲染资源(Graphic Resource)的对象异步释放资源时,需要等待渲染线程执行到“release fence”的情况。 - 释放对象索引。对象从全局对象数组中消失,索引槽位进入复用池。
- 执行析构函数以及释放内存空间:
UObject* Object = UnreachableObject.Object;
Object->~UObject();
GUObjectAllocator.FreeUObject(Object);
UnreachableObject.Object = nullptr;
UE 增量GC
UE的GC系统默认情况下,可达性分析阶段是在单帧内全量进行(有做并行优化),需要暂停所有UObject处理(尤其Gameplay)。要扫描的对象越多,暂停时间越长,可能会产生明显的卡顿。程序员可以采用多种方法避免卡顿:
- 保持紧张的UObject预算。
- 使用UObject池。
- 在正常Gameplay期间禁用GC。
但以上方法往往会增加代码复杂度和维护成本,因此UE提供了增量可行性分析的改进,具体可参考官方文档。
需要建立的核心认识
- UObject虽然是C++对象,但通常不由开发者直接释放内存。
- 普通C++对象不会自动进入GC。
- “对象会不会被GC回收”与“它占用的资源是否还驻留内存”是两个独立的问题:
- “对象会不会被GC回收”问的是资格——对象还有没有指向它的强引用。如果没有引用,它就获得了“被回收的资格”,将来某次GC会回收它,但不是当下、立刻将它回收并释放其占用的资源。
- “资源是否驻留内存”问的是现状——此刻对象占用的资源,在物理上还在不在。例如:纹理流送。
UTexture2D对象被引用、当然不会被回收,但它的GPU mip内存可以被流送系统踢出驻留内存——对象还在,资源不在。
- 转向UE后,内存管理的重点不是“记得调用释放函数”,而是学会表达对象之间所有权和引用关系。
- UE GC系统中,对象之间的引用关系需要明确声明!对象之间的引用关系需要明确声明!对象之间的引用关系需要明确声明!
UE GC如何判断对象是否存活
可达性
UE GC的基本思路是从必然可达对象(通常是那些设定为根集的对象,例如GEngine、UGameplayTagsManager)开始,沿着强引用链条扫描整个对象关系图。
Root Set
└─ PlayerController
└─ HUD
└─ InteractionWidget
UnreferencedObject
└─ 不再可达,可以被 GC 回收
如果一个UObject从根对象或其他可达对象沿着强引用访问到,那么它被认为是可达的。如果UObject对象不可达,则GC系统将考虑回收它。
GC必须能够看见引用
并不是保存一个UObject对象的内存地址,GC系统就“感知”到这个对象被引用了。只有GC能识别的引用关系或者明确地“告诉”GC对象的引用关系(UPROPERTY()宏),才具有保活的作用。
因此需要区分:
- 程序是否还能找到这个地址。
- GC是否知道某个存活对象的正在持有它。
- 这个引用是否应该阻止对象被回收。比如:弱引用不会阻止对象被回收。
回收通常不是立即发生
UObject对象失去最后一个强引用时,通常不会在同一时刻立即回收。对象会在后续GC周期中被发现并回收。
因此:
- 指针非空并不代表对象有效。
- 对象进入销毁流程后,内存可能尚未立即释放。
- 不应依赖“看起来有地址”来判断UObject对象是否有效。
UObject对象的创建、持有与销毁
使用与类型匹配的创建方式
UObject不能被当作普通C++数据随意创建,因为创建过程中还需要完成对象注册、类型初始化、Outer设置和GC登记。
不同对象因使用与其类型匹配的创建流程:
- 普通UObject使用UObject创建机制,例如:
NewObject()。 AActor应通过UWorld->SpawnActor()生成。UActorComponent需要正确建立所属关系并注册。- 默认组件需要在宿主对象的构造阶段创建。
- 资源对象通常由资源加载系统取得。
UUserWidget结合所属玩家和UI生命周期创建。
创建对象后,应立即明确谁负责保证其存活。
持有UObject成员变量,必须用UPROPERTY宏保证其存活
Unity中,当一个对象的类类型成员变量创建完成后,成员变量就处于“被引用”的状态:
class MyClassA
{
public MyClassB MemberB = new MyClassB();//GC认为MemberB被MyClassA对象引用,这对Unity程序员来说稀松平常。
}
但UE中,只是“New”一个UObject成员变量,并不代表与其宿主对象建立了“能被GC认可的引用关系”。例如:
UCLASS()
class UMyClassA : public UObject
{
GENERATE_BODY()
public:
UMyClassA();
UMyClassB* MemberB;
}
void UMyClassA::Process()
{
//MemberB能被GC发现,但对GC来说MemberB不可达,未来会被GC回收。
MemberB = NewObject<UMyClassB>(this);
}
因为UE需要通过反射信息建立引用关系,所以你需要使用UPROPERTY()宏标记成员对象:
UCLASS()
class UMyClassA : public UObject
{
GENERATE_BODY()
public:
UMyClassA();
UPROPERTY() //使用宏标记,建立反射信息,使成员对象进入引用图。
TObjectPtr<UMyClassB> MemberB;//TObjectPtr是UE5推荐写法,UMyClassB*也能生成反射信息。
}
局部UObject变量需手动保证存活
Unity的GC(Boehm)是保守式的,会扫描线程栈(保守扫描)和寄存器——局部变量在出作用域前,该变量能一直存活(至少Debug构建是这样)。例如:
void Func()
{
var data = new MyData();
DoSomething(data);//使用期间,data绝对存活
//之后不再使用data。在Release构建下,JIT认为后续不再使用data,在出函数作用域前可能会将其回收。
//Debug构建下,data将一直存活,直至函数作用域结束。
LongRunning();
}
UE的GC是精确式的,只认反射信息(通过宏标记显式声明)和显式注册(添加到根集、派生自FGCObject)——完全不扫描C++调用栈。栈上的裸UObject*对GC来说不可达,未来会择机回收掉。例如:
void AMyActor::Func()
{
UMyData* Data = NewObject<UMyData>();// 无UPROPERTY引用。
BigAllocation();//这次分配可能触发GC——Data随时可能变成悬挂指针。
Data->Use();//如果Data已被回收,对于Data的操作将触发崩溃。但在C#中,这里不可能出问题。
}
解决方案有三种:
- 用
UPROPERTY标记的宿主对象的成员变量持有。前提是宿主对象也必须能被GC“发现”,如果宿主是普通C++对象,则该方案无效,应使用第3个方案。 - 显式地添加到根集(
AddToRoot()),但也必须显式地从根集移除(RemoveFromRoot())。 - 使用
TStrongObjectPtr。
例如:
void AMyActor::Func()
{
//方案1:为AMyActor声明一个用UPROPERTY标记的成员变量,用来持有局部变量
//假如AMyActor声明了:UPROPERTY() TObjectPtr<UMyData> MemberData;
MemberData = NewObject<UMyData>();// 无UPROPERTY引用。
BigAllocation();
MemberData->Use();
//但如果宿主对象是普通C++对象,UPROPERTY的方法无效。
//方案2:显式地添加到根集
UMyData* Data = NewObject<UMyData>();
Data->AddToRoot();
BigAllocation();
Data->RemoveFromRoot();//用完还要显式地从根集移除,否则无法被回收。
//方案3:使用TStrongObjectPtr
UMyData* Data = NewObject<UMyData>();
TStrongObjectPtr<UMyData> KeepAlive(Data);//从这里开始,GC将能“发现”它。
BigAllocation();
}//函数作用域结束后,KeepAlive析构——保护自动解除;Data无引用,未来将被GC回收。
C#是“持有即保护”(保护到不再需要为止),UE是“不看指针,只看是否显式地声明引用”。
不同对象有不同的销毁方式
- 普通UObject应断开强引用,等待GC回收。
Actor应通过UWorld进入销毁流程。ActorComponent应使用对应的注销和销毁流程。- 资源能否卸载取决于是否仍然存在硬引用、加载句柄或其他资源依赖。
UUserWidget从父节点删除只表示不再显示,不代表已经没有其他强引用,
Outer不等于通用的强所有权
Outer的作用主要在于表达对象的命名层级、上下问文和所属关系,不能简单理解为C#中的成员引用。
指定Outer不代表在所有情况下Outer都会自动保证目标对象存活。如果需要对象长期存活,应建立GC能识别的强引用。
强引用、弱引用与软引用
引用类型表达的不只是访问方式,更重要的是对象之间的生命周期关系。
强引用
强引用表示:只要持有者仍然存活,被引用的UObject就应该继续存活。
适合使用强引用的场景包括:
PlayerController持有HUD。HUD持有需要常驻的界面。- 角色持有自己的运行时组件。
- 管理器持有仍在使用的运行时数据对象。
需要注意:
- UObject成员引用必须以GC能识别的方式等级。
- 数组、集合和映射中的UObject引用同样必须被GC识别。
- 普通地址或未登记的对象成员不一定能够阻止GC。
弱引用
弱引用表示:可以观察和访问对象,但不负责保证对象存活。
适合弱引用的场景包括:
- 当前交互目标。
- 当前锁定的敌人。
- 临时缓存的
Actor或Widget。 - 可能在其他系统中被销毁的对象。
使用弱引用时,应当在每次访问前检查目标是否有效。
弱引用不是一种单纯的性能优化,而是明确地表达“不拥有这个对象”。
软引用
软引用主要用于表示一个暂时不需要加载的资源或者类。
软引用的应用场景包括:
- 角色和宠物资源
- UI图标。
- 可选皮肤、音频和动画。
DataAsset。Ability类。- 大型关卡资源。
软引用通常不要求目标资源立即加载。真正使用资源前,应通过同步或异步的方式加载;加载完成后,还需要明确由谁保证资源继续驻留。
TSubclassOf与TSoftClassPtr
TSubclassOf表达的是类引用和类型的约束,是硬引用(需要通过UPROPERTY宏登记)。TSubclassOf引用的是UClass对象,保活的也是UClass对象,不保活任何类实例。
TSoftClassPtr是TSubclassOf的软引用版本,在使用前需要先通过同步或异步方式加载UClass。
指针引用类型对照表
| 声明(UPROPERTY内) | 引用类型 | 存什么 | GC保活 | 序列化/打包行为 |
|---|---|---|---|---|
UStaticMesh*/TObjectPtr<T>/TSubclassOf<T> |
硬引用 | 直接指针 | 是 | 直接对象引用(进入导入表)-> 打包依赖、保持加载 |
TWeakObjectPtr<T> |
弱引用 | 弱句柄 | 否 | 不参与序列化(运行时专用) |
TSoftObjectPtr<T>/TSoftClassPtr<T> |
软引用 | FSoftObjectPath(路径字符串) |
否 | 只存路径;可解析、可异步加载 |
普通C++对象内存管理
在UE中,通常使用虚幻智能指针库提供的智能指针追踪和管理普通C++对象的内存。
虚幻智能指针库
虚幻智能指针库为C++11智能指针的自定义实现,旨在减轻普通C++对象内存分配和追踪的负担。
虚幻智能指针类型分类:
| 智能指针类型 | 适用情形 |
|---|---|
共享指针(TSharedPtr) |
共享所有权的强引用智能指针。共享指针拥有其引用的对象,防止该对象被删除,并在无共享指针引用时释放对象。共享指针允许为空,意味其不引用任何对象。 |
共享引用(TSharedRef) |
与共享指针类似,其拥有自身引用的对象,且属于强引用。不同是,共享引用不允许为空,即必须固定引用非空对象。 如果一个函数参数要求传入绝对有效的普通C++对象,使用共享引用可以从编译期杜绝空指针检查。 |
弱指针(TWeakPtr) |
弱指针与共享指针行为类似,但不拥有其引用的对象,因此不影响其生命周期。弱指针主要用于中断引用循环,但也意味着弱指针可在无预警的情况下随时变为空。弱指针可生成指向其引用的对象的共享指针,确保程序员能对该对象进行安全的临时访问。 |
唯一指针(TUniquePtr) |
独占所有权的智能指针,无法共享所有权,仅有一个唯一指向给定的对象。唯一指针可以转移所有权(Move),但禁止拷贝,任何拷贝操作将导致编译错误。唯一指针超出其作用域时,将自动释放所引用的对象。 |
与C++11标准库智能指针的对应关系:
标准 C++ 库 (std::) UE 智能指针
┌──────────────────┐ ┌──────────────────────────┐
│ std::shared_ptr │ ──►──► │ TSharedPtr │
│ std::weak_ptr │ ──►──► │ TWeakPtr │
│ std::unique_ptr │ ──►──► │ TUniquePtr │
│ (无) │ ──►──► │ TSharedRef (UE 独创强引用)│
└──────────────────┘ └──────────────────────────┘
绝对禁止给UObject使用智能指针
在UE开发中,千万不要尝试用智能指针(例如:TSharedPtr<UObject>)包裹UObject及其子类。
- 原因:UObject的创建和销毁受UE引擎底层的 UObject哈希表 和GC强行接管。
- 后果:如果强行用
TSharedPtr包裹UObject,相当于让它同时被两套所有权系统管理,并且二者无法感知到对方。两套系统谁先销毁对象,对另一方都是背刺:- GC先回收,对象被引擎销毁,
TSharedPtr内部的裸指针变成 悬挂指针 。当TSharedPtr的引用计数归零,再delete一次,导致 双重释放(Double Free) 的错误。 TSharedPtr计数先归零,直接delete对象,绕过引擎的UObject哈希表记账。GC之后扫描到已释放内存里的残留条目,是为—— 内存破坏(Memory Corruption) 。
- GC先回收,对象被引擎销毁,
FGCObject
普通C++对象无法使用UPROPERTY宏,如果在普通C++对象中定义UObject成员变量,需要让普通C++对象继承自FGCObject,并重载AddReferencedObjects()函数,然后在该函数中显式地将UObject成员变量加入GC的引用图表中。例如:
class FMyHelper : public FGCObject
{
public:
TArray<TObjectPtr<UObject>> Objects;
TMap<FName, TObjectPtr<UObject>> Lookup;
virtual void AddReferencedObjects(FReferenceCollector& Collector) override // 纯虚,必须实现
{
Collector.AddReferencedObjects(Objects);
Collector.AddReferencedObjects(Lookup);
}
virtual FString GetReferencerName() const override // 纯虚,必须实现;显示在引用链诊断里
{
return TEXT("FMyHelper");
}
};
从“释放内存”转向“表达生命周期”
从Unity C# 转向 Unreal C++后,最重要的是学会表达对象之间的生命周期关系。
可以将全文归纳为六个原则:
- 先判断对象是否属于UObject系统。
- 为每个对象确定创建者、持有者和生命周期结束方式。
- 需要保活时,使用GC能够识别的强引用。
- 不拥有对象时,使用弱引用并在访问前检查有效性。
- 不希望立即加载时,使用软引用而不是硬引用。
- “最终能够被GC回收”只是底线,对象数量、资源依赖和回收峰值才决定内存是否真正可控。
开发能够常见的内存问题
UObject被提前回收
现象: 对象刚创建时可以使用,经过一段时间或一次GC后失效。
常见原因: 虽然保存了对象的地址,但这条引用没有被GC识别。
处理原则: 确认负责对象生命周期的持有者建立了正确的强引用。
UObject永远不被回收
现象: 离开界面或关卡后,对象仍然长期存在。
常见原因: 对象仍被GameInstance、Subsystem、全局容器、静态变量或Root Set持有。
处理原则: 从长生命周期对象开始检查引用,确认容器和缓存是否及时清理。
Actor从数组移除后仍在场景中
原因: World仍然管理该Actor,从自己的数组移除引用,不等于销毁Actor。
处理原则: 确认负责对象生命周期的持有者建立了正确的强引用。
Widget从屏幕移除后仍然占用内存
原因: 从界面层级移除只是改变显式状态,HUD、委托、Timer或其他对象可能仍然持有Widget。
处理原则: 区分“停止显示”和“允许回收”,并检查所有长期引用和回调关系。
异步回调访问已经失效的对象
原因: 异步加载、Timer回调获取延迟回调执行时,最初发起操作的对象可能已经被销毁。
处理原则: 回调不应盲目相信目标仍然存活;非用有对象应该使用弱引用,并在使用前检查有效性。
引用资源时应该考虑使用软引用
原因: 硬引用会形成资源依赖链。一个常驻管理对象可能通过角色类继续引用骨骼、动画、材质和纹理。
GameInstance
└─ Manager
└─ CharacterClass
├─ SkeletalMesh
├─ AnimBlueprint
├─ Materials
└─ Textures
处理原则: 大型、可选或按需使用的资源应评估是否改用软引用或异步加载。

Discussion
留言讨论
发表留言时,评论服务会记录必要的 IP 与 User-Agent 信息,用于安全审核和防止滥用。