UE5.8 UObject 生命周期与垃圾回收:一次把 NewObject、GC 泄漏和弱引用讲透
引子:周三下午的两百兆泄漏
去年秋天,我们项目组在压测一个开放世界 Demo 时,客户端内存曲线在四十分钟后陡然上扬。美术场景没有换,玩法逻辑也没加新分支,但就是每过一轮战斗,内存固定多涨两到三兆。QA 小林盯着 Profiler 看了半天,把截图甩到群里:「有人在不停生孩子。」
他说的孩子,是 UObject。
我和主程阿杰花了两个下午定位,最终问题出在两个地方:一个是自定义的 Buff 管理器用 NewObject 创建了 UObject,却没给这些对象找一个能被 GC 看见的引用;另一个是技能系统为了缓存目标,在 A 和 B 之间互相挂了强引用,形成孤岛式的循环引用,GC 一直扫不到它们。修复后内存曲线平了,帧率波动也少了一截。
这次排查让我意识到,很多 UE 开发者对 UObject 的生命周期和 GC 机制只停留在「加 UPROPERTY 就不会死」这个层面。真到项目后期,这点模糊认识会转化成线上事故。这篇文章把 UE5.8 里相关机制拆开讲,尽量不堆术语,只谈代码实际运行时发生了什么。
一、UObject 生命周期的几个关键阶段
UObject 不是普通 C++ 对象。它被创建之后,会进入引擎的统一对象池,接受反射、序列化、GC 管理。一个典型 UObject 从生到死,会经历下面这条主线:
flowchart TD
A[StaticConstructObject 分配内存与初始化 VMT] --> B[PostLoad / PostInitProperties]
B --> C[BeginPlay 进入游戏逻辑]
C --> D[Tick 每帧更新]
D --> E[EndPlay 被请求销毁]
E --> F[GC 标记为不可达]
F --> G[BeginDestroy 释放多线程资源]
G --> H[FinishDestroy 回收内存]这八步里最常被误解的是开头和结尾。很多人觉得 UObject 的构造函数就是一切,其实 StaticConstructObject 已经帮你做完了反射注册、属性零值填充、Outer 绑定等工作;而你写的构造函数体,通常只是在补一些 C++ 层面的默认值。同样,调用 ConditionalBeginDestroy 不代表对象立刻消失,它只是在告诉 GC:下一轮可以把这家伙扫掉了。
AActor 作为 UObject 的子类,生命周期更复杂一点,因为它还要处理组件初始化。这张图描绘了 AActor 生成之后、被 GC 回收之前的完整调用链:
sequenceDiagram
autonumber
participant G as GameThread
participant W as UWorld
participant A as AActor
participant C as UActorComponent
G->>W: SpawnActor
W->>A: StaticConstructObject
A->>A: PostSpawnInitialize
A->>C: CreateDefaultSubobject
A->>A: PreInitializeComponents
loop 每个组件
A->>C: InitializeComponent
end
A->>A: PostInitializeComponents
G->>A: BeginPlay
loop 游戏运行中
G->>A: Tick
end
G->>A: Destroy / MarkAsGarbage
A->>A: EndPlay
Note over A: GC 周期到来
A->>A: BeginDestroy
A->>A: IsReadyForFinishDestroy
A->>A: FinishDestroy第 4 步的 CreateDefaultSubobject 只在 C++ 默认构造阶段出现,负责拉起默认组件,比如 RootComponent、StaticMeshComponent。Blueprint 里拖进去的组件走的是另一条路径,但底层仍然是 UObject 创建与 Outer 绑定。
二、NewObject 与 CreateDefaultSubobject,到底用哪个
这两个函数名字有点像,但使用场景完全不同。
NewObject<T>() 是一个通用工厂,几乎可以在任何时刻调用。它的典型形式是:
UBuffInstance* Buff = NewObject<UBuffInstance>(
this, // Outer
UBuffInstance::StaticClass(),
TEXT("BuffInstance") // 可选实例名
);调用之后,Buff 会进入全局 UObject 数组,GC 认识它。但它会不会被 GC 掉,取决于 Outer 以及有没有被 UPROPERTY 引用。如果 Outer 是当前 Actor,而 Actor 本身还活着,Buff 通常也会被 Outer 的引用链带活;如果 Outer 传的是 GetTransientPackage(),那这个对象基本就是流浪儿,下一轮 GC 就可能被收走。
CreateDefaultSubobject 只能在 Actor 的构造函数里调用。它的职责是声明默认子对象,并且把这些子对象注册到 Class Default Object 中。典型写法:
AMyCharacter::AMyCharacter()
{
PrimaryActorTick.bCanEverTick = true;
CapsuleComponent = CreateDefaultSubobject<UCapsuleComponent>(TEXT("CollisionCylinder"));
RootComponent = CapsuleComponent;
Mesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("CharacterMesh"));
Mesh->SetupAttachment(RootComponent);
}在构造函数外调用 CreateDefaultSubobject 会直接触发运行时错误。反过来,在构造函数里用 NewObject 创建默认组件也不会得到编辑器支持,属性面板不会显示,Replication 也可能出问题。
我有个简单的判断标准:默认组件用 CreateDefaultSubobject,运行时动态创建的对象用 NewObject 或 SpawnActor。别把两个混着用。
三、GC 的标记清扫与 UObject 标志
UE 的 GC 不是引用计数,而是基于可达性分析的标记清扫。它维护一个全局 UObject 数组,每隔一段时间或者显式触发时,从 Root Set 出发,把所有能走到的地方标记为 Reachable,剩下的全部清掉。
Root Set 包括:
- 被
AddToRoot显式保护的对象; - 被
FGCObject通过AddReferencedObjects报告出来的对象; - World、Level、GameInstance 等引擎核心结构里的对象;
- 栈上的 UObject 引用(在 GC 执行时刻)。
GC 执行一次完整周期,大概可以拆成下面两个阶段:
其中 是 Root 引用的平均数量, 是引用链的平均深度, 是本轮被判定为不可达的对象数。Mark 阶段的开销和对象图的大小、引用复杂度直接相关;Sweep 阶段则和需要销毁的对象数量相关。如果一个 World 里 UObject 数量巨大,Mark 阶段就可能造成明显卡顿。UE5.8 在这方面继续做增量 GC 的优化,但核心模型没变。
UObject 有很多内部标志位控制 GC 行为,最常见的几个:
RF_MarkAsRootSet:对象在 Root Set 里,GC 不会回收;RF_Standalone:对象即使没有引用也不会被 GC;RF_Public:对象允许跨包引用;RF_Transient:对象不会被序列化到磁盘;RF_MirroredGarbage/RF_BeginDestroyed:对象已被请求销毁,等待 GC 处理。
我们平时写业务代码,基本不需要手动操作这些标志,但读引擎源码时会频繁遇到它们。
四、GC Threshold 与显式触发
UE 的 GC 默认不是每帧都跑,而是根据时间、内存压力和对象增量来触发。项目设置里可以调整的关键参数包括 gc.TimeBetweenPurgingPendingKillObjects、gc.MaxObjectsInGame 等。TimeBetweenPurgingPendingKillObjects 控制 GC 周期之间的最小间隔,默认值通常是 60 秒左右;MaxObjectsInGame 则限制了游戏中能同时存在的 UObject 数量上限,超过后会出现分配失败。
你也可以在代码里显式触发一次 GC:
GEngine->ForceGarbageCollection(true);或者在控制台输入 gc.Collect。
强制 GC 在编辑器里很有用,比如你想验证修复后某个类是否真的不再泄漏。但在运行时频繁调用会制造卡顿,特别是 Mark 阶段对象图庞大的时候。我一般在两种情况下用:一种是自动化测试里确认对象是否被回收,另一种是关卡切换后手动清一波残留。
UE5.8 还继续改进了增量 GC,目标是把一次性的大停顿拆成多帧完成。对于开放世界或长关卡流送的项目,这个选项通常值得打开。具体开关在 DefaultEngine.ini:
[/Script/Engine.GarbageCollectionSettings]
bUseIncrementalGarbageCollection=True开了之后不代表 GC 不耗性能,只是把开销摊到多帧。如果你的 UObject 引用链极深,Mark 阶段依然会占用可观的主线程时间。
五、Outer、Package 与加载边界
每个 UObject 都有一个 Outer,这个 Outer 决定了对象在序列化和 GC 时的归属关系。创建对象时传入的 Outer 通常是当前 Actor、Component 或者 Package。
Outer 有两个实际影响:
- 对象会随着 Outer 一起被序列化或反序列化。比如一个 Actor 的子组件会跟着 Actor 一起存进 Level 包;
- GC 可达性分析会沿着 Outer 链向上走。Outer 活着,对象通常也活着。
但这不意味着 Outer 自动持有对象。如果 Outer 没有通过 UPROPERTY 或其他方式引用这个对象,对象仍然可能被回收。很多人误以为 NewObject<UMyClass>(this) 就一定安全,其实 this 只是 Outer,不是强引用保证。
跨包引用时更要小心。A 包里的 UObject 引用 B 包里的 UObject,如果 B 包被卸载而 A 包还在,引用就会变成悬空。处理这类场景通常用 TSoftObjectPtr 做懒加载,或者用弱引用并在对象失效时重新加载。
我们项目里曾出现过一次典型的包边界问题:主 UI 里缓存了一个关卡内生成的 Texture,关卡卸载后 Texture 理论上该走,但 UI 用 UPROPERTY 强引用了它,导致整个关卡包的部分资源无法释放。改成 TWeakObjectPtr 并在 IsValid() 失败时重新加载,问题才解决。
五、引用链:为什么加了 UPROPERTY 还会泄漏
最让新手困惑的问题之一就是:明明所有指针都加了 UPROPERTY,为什么内存还在涨?
答案通常藏在引用链里。GC 只关心对象是否从 Root Set 可达。A 引用 B、B 引用 A,这种循环引用本身不是问题;真正的问题在于 A 和 B 作为一个整体,被某个不该长期存活的东西持有着。换句话说,泄漏不是因为循环,而是因为循环没有断干净。
下面这张图展示了一个典型的错误设计:
graph TD
Root[Root Set
GameInstance] --> M[UBuffManager]
M --> A[UBuffInstance A]
A --> B[UBuffInstance B]
B --> A
M -.-> C[UBuffInstance C
理应是临时对象]
C --> B在这个例子里,BuffManager 持有了 A,A 和 B 互相引用,C 又引用了 B。即使业务逻辑上 C 只是一次性对象,但因为 C 还在 BuffManager 的某个数组里,整个环都活着。GC 会老老实实把它们全部标为可达。
修复方式通常是两条路:
- 在对象失效时主动清空引用,比如
BuffManager->ActiveBuffs.Remove(...); - 对于缓存性质或者观察者性质的引用,改用
TWeakObjectPtr,让它不形成强引用。
六、弱指针:FWeakObjectPtr 与 TWeakObjectPtr
UE 提供了两套弱引用机制:FWeakObjectPtr 是底层实现,TWeakObjectPtr<T> 是带类型的包装。我们平时写业务代码,99% 的情况下用 TWeakObjectPtr<T>。
弱引用的核心作用:你能知道对象还在不在,但不会因为你的引用而阻止 GC 回收它。
UCLASS()
class MYGAME_API UBuffWatcher : public UObject
{
GENERATED_BODY()
public:
UFUNCTION()
void WatchTarget(ABaseCharacter* InTarget)
{
// 只观察,不拥有
CachedTarget = InTarget;
}
UFUNCTION()
void OnTick()
{
if (CachedTarget.IsValid())
{
CachedTarget->ApplyBuffEffect();
}
else
{
// 目标已经被 GC 或销毁,清理自己
ConditionalBeginDestroy();
}
}
private:
TWeakObjectPtr<ABaseCharacter> CachedTarget;
};这段代码只有 28 行,但解决了大问题。CachedTarget 是弱引用,即使 BuffWatcher 还活着,也不会把目标角色强行留在内存里。目标被销毁后,IsValid() 返回 false,BuffWatcher 可以自行决定是清理还是重新找目标。
FWeakObjectPtr 则多见于引擎底层或者需要与 UObject 无关的结构打交道的地方。它本质上只保存一个对象索引和序列号,不持有 UObject 指针,所以可以在非 UObject 类里安全使用。不过它没有类型安全,手动取对象时需要自己 Cast。
七、AddToRoot 与 ConditionalBeginDestroy 的正确用法
AddToRoot 是把一个 UObject 直接钉进 Root Set,GC 永远不会回收它。听起来很爽,但用错地方就是泄漏温床。
我见过一种典型错误:有人写单例管理器,里头用 NewObject 创建了一个 UObject,然后调用 AddToRoot,以为这样就能得到一个全局对象。结果关卡切换时忘了 RemoveFromRoot,这个对象跟着到下一个关卡,旧数据污染新场景。更隐蔽的是,如果这个单例还引用了其他 UObject,那些人也会被一起带活。
我的建议:能用 UPROPERTY 引用解决的就别用 AddToRoot。只在真正需要跨越 GC 周期、又找不到合适 Outer 的对象上使用,比如插件里的全局配置对象,而且必须在模块卸载或关卡切换时显式移除。
ConditionalBeginDestroy 用于主动请求销毁一个 UObject。它会在对象上设置 RF_MirroredGarbage 标志,下一波 GC 把它标为不可达后,依次调用 BeginDestroy、IsReadyForFinishDestroy、FinishDestroy。
调用时要注意两件事:
- 别在 Tick 或渲染线程里直接调用,尽量通过主线程的销毁流程触发;
- 调用之后对象不一定立刻释放,如果你下一行代码就访问它,可能会拿到悬空指针。
八、常见的 GC 泄漏与崩溃场景
做了这么多年 UE 开发,下面这几类 GC 相关的问题出现频率最高。
8.1 动态创建 UObject 没有挂到引用链上
void UMySystem::CreateTempData()
{
UMyData* Data = NewObject<UMyData>(GetTransientPackage());
Data->Value = 42;
// Data 没有任何 UPROPERTY 引用,出了作用域就可能在下次 GC 被回收
}如果 Data 需要长期存在,应该把它存到类的 UPROPERTY 数组里,或者指定一个稳定的 Outer。临时对象没问题,但别把业务状态放上面。
8.2 非 UObject 类持有 UObject 裸指针
普通 C++ 类里的裸指针不会触发 GC 引用。如果这个类既不是 UObject 也不继承 FGCObject,那它持有的 UObject 指针在 GC 眼里就是空气。
修复方式有两种:要么把类改成 UObject 子类并用 UPROPERTY,要么继承 FGCObject 并实现 AddReferencedObjects。
class FMyGCObject : public FGCObject
{
public:
UObject* HeldObject = nullptr;
virtual void AddReferencedObjects(FReferenceCollector& Collector) override
{
Collector.AddReferencedObject(HeldObject);
}
virtual FString GetReferencerName() const override
{
return TEXT("FMyGCObject");
}
};8.3 循环引用拖住本应该释放的对象
两个 UObject 互相 UPROPERTY 引用,业务上又觉得它们应该同生共死,结果一方应该释放时另一方还在,导致谁也走不掉。这种情况用 TWeakObjectPtr 拆环通常是最干净的方案。
8.4 在 GC 执行期间创建 UObject
UE 在 GC 的 Mark 阶段会锁定对象创建,如果你在异步回调、子线程或者某些引擎回调里触发了 UObject 创建,就可能 crash 在 Creating UObjects while Collecting Garbage is not allowed。解决办法是把创建逻辑推到下一帧,或者用 FFunctionGraphTask::CreateAndDispatchWhenReady 丢回 GameThread。
8.5 错误使用 AddToRoot 导致对象永存
前面已经提过。再补充一点:编辑器模式下频繁 Play in Editor 更容易暴露这类问题,因为同一个进程里会反复加载卸载关卡,Root Set 里的钉子户会一次次累积。
九、调试 GC 问题的一些手段
真遇到问题时,下面几个工具能帮上忙。
9.1 obj list 和 obj refs
在编辑器控制台输入 obj list class=UBuffInstance,可以列出当前所有该类的实例以及它们的引用数量。配合 obj refs name=BuffInstance_42 可以看到一个对象被谁引用。
9.2 Reference Viewer
右键点击一个 Asset 或 UObject,打开 Reference Viewer,能直观看到引用链。排查泄漏时特别有用。
9.3 Memory Profiler
UE 自带的 Memory Profiler 可以抓取两个时间点的快照,对比之后找出新增对象。很多泄漏都是靠对比抓出来的。
9.4 GC 日志
在 DefaultEngine.ini 里打开 GC 详细日志:
[Core.Log]
LogGarbage=Verbose你会看到每次 GC 扫了多少对象、标记了多少、销毁了多少。 pause 大的那几次,重点看 Mark 阶段扫描的对象数和引用数。
十、一个更完整的架构视角
把上面这些内容放在一起,可以用下面这张图理解 UObject 在引擎里的位置:
architecture-beta
group runtime(server)[Runtime Layer]
service gameplay(server)[UObject Gameplay] in runtime
service actor(server)[AActor and Component] in runtime
service asset(disk)[UAsset and UClass] in runtime
group gc(cloud)[Garbage Collection]
service collector(server)[GC Collector] in gc
service roots(server)[Root Set] in gc
service reachability(server)[Reachability Analyzer] in gc
group memory(database)[Memory Management]
service pool(database)[UObject Pool] in memory
service destroy(database)[Object Destroyer] in memory
gameplay:T -- B:actor
actor:T -- B:asset
collector:L -- R:roots
reachability:L -- R:collector
roots:T -- B:gameplay
reachability:T -- B:pool
pool:T -- B:destroyRuntime Layer 里的 UObject、AActor、UAsset 都被 GC 管理。GC Collector 从 Root Set 出发,通过 Reachability Analyzer 标记可达对象;不可达的对象从 UObject Pool 里移除,最终交给 Object Destroyer 完成 BeginDestroy 到 FinishDestroy 的收尾。
如果想更直观地感受一个 UObject 整个生命周期的流转,也可以看这张简化的 SVG 示意。它并不精确对应引擎内部调用顺序,只是把「创建—运行—标记—销毁」四个阶段画成了一条线。
Runtime Layer 里的 UObject、AActor、UAsset 都被 GC 管理。GC Collector 从 Root Set 出发,通过 Reachability Analyzer 标记可达对象;不可达的对象从 UObject Pool 里移除,最终交给 Object Destroyer 完成 BeginDestroy 到 FinishDestroy 的收尾。
十一、给客户端程序员的 UObject 引用自查清单
写完代码准备提交前,我会快速过一遍下面这几个问题。不需要每条都答是,但如果好几条都模棱两可,那就该停下来再想想。
- 这个 UObject 是用
NewObject创建的吗?它的 Outer 是谁?Outer 能否保证它活到需要的时候? - 这个指针是拥有关系、观察关系,还是临时关系?拥有用
UPROPERTY或TStrongObjectPtr,观察用TWeakObjectPtr,临时用完最好主动清理。 - 这个类是不是非 UObject?非 UObject 持有 UObject 时,有没有继承
FGCObject或者把对象交给 UObject 管理? - 这里有没有 A 引用 B、B 引用 A 的环?环本身没问题,但环里的对象是不是都被某个长期存在的 Root 间接抓着?
- 这个对象有没有调用
AddToRoot?如果有,有没有对应的RemoveFromRoot时机? - 动态创建的对象数量大不大?是不是可以考虑对象池,而不是每次都
NewObject? - 关卡切换或模块卸载时,这个引用会不会指向已经消失的 Package?
这些问题看起来很基础,但大部分 GC 泄漏都是基础问题反复出现。审查代码时多花两分钟,后面定位内存问题就能少花两小时。
结语:把 GC 当作合作者,而不是黑箱
UObject 的 GC 机制确实有学习成本,但它并不是为了刁难开发者。它提供了一套明确的规则:你是谁引用的、你有没有被 Root 保护、你是强引用还是弱引用。只要你把这些规则写清楚,GC 就会按约定帮你清理内存。
我们那次两百兆泄漏修复之后,阿杰在复盘会上说了一句话:「GC 不会撒谎,它只是照着你写的引用关系办事。」后来我把这句话贴在工位显示器旁边。每次写 UObject 引用时,都会多想一层:这个引用到底想表达拥有、观察,还是临时借用?答案不同,指针类型就该不同。
希望这篇文章能帮你在项目里少踩几个坑。内存问题不会消失,但理解 UObject 生命周期之后,至少你知道该往哪边看。下次再看到内存曲线往上翘,先别急着加对象池,先看看引用关系里是不是藏着一根没断干净的线。