UE5.8 UObject 生命周期与垃圾回收:一次把 NewObject、GC 泄漏和弱引用讲透

📑 目录

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++ 默认构造阶段出现,负责拉起默认组件,比如 RootComponentStaticMeshComponent。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,运行时动态创建的对象用 NewObjectSpawnActor。别把两个混着用。

三、GC 的标记清扫与 UObject 标志

UE 的 GC 不是引用计数,而是基于可达性分析的标记清扫。它维护一个全局 UObject 数组,每隔一段时间或者显式触发时,从 Root Set 出发,把所有能走到的地方标记为 Reachable,剩下的全部清掉。

Root Set 包括:

  • AddToRoot 显式保护的对象;
  • FGCObject 通过 AddReferencedObjects 报告出来的对象;
  • World、Level、GameInstance 等引擎核心结构里的对象;
  • 栈上的 UObject 引用(在 GC 执行时刻)。

GC 执行一次完整周期,大概可以拆成下面两个阶段:

TGCTmark+Tsweep=O(RD)+O(Nunreach)T_{GC} \approx T_{mark} + T_{sweep} = O(R \cdot D) + O(N_{unreach})

其中 RR 是 Root 引用的平均数量,DD 是引用链的平均深度,NunreachN_{unreach} 是本轮被判定为不可达的对象数。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.TimeBetweenPurgingPendingKillObjectsgc.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 有两个实际影响:

  1. 对象会随着 Outer 一起被序列化或反序列化。比如一个 Actor 的子组件会跟着 Actor 一起存进 Level 包;
  2. 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 会老老实实把它们全部标为可达。

修复方式通常是两条路:

  1. 在对象失效时主动清空引用,比如 BuffManager->ActiveBuffs.Remove(...)
  2. 对于缓存性质或者观察者性质的引用,改用 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 把它标为不可达后,依次调用 BeginDestroyIsReadyForFinishDestroyFinishDestroy

调用时要注意两件事:

  • 别在 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 listobj 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:destroy

Runtime Layer 里的 UObject、AActor、UAsset 都被 GC 管理。GC Collector 从 Root Set 出发,通过 Reachability Analyzer 标记可达对象;不可达的对象从 UObject Pool 里移除,最终交给 Object Destroyer 完成 BeginDestroyFinishDestroy 的收尾。

如果想更直观地感受一个 UObject 整个生命周期的流转,也可以看这张简化的 SVG 示意。它并不精确对应引擎内部调用顺序,只是把「创建—运行—标记—销毁」四个阶段画成了一条线。

NewObjectBeginPlay / TickMarkAsGarbageGC

UObject 生命周期主线

Runtime Layer 里的 UObject、AActor、UAsset 都被 GC 管理。GC Collector 从 Root Set 出发,通过 Reachability Analyzer 标记可达对象;不可达的对象从 UObject Pool 里移除,最终交给 Object Destroyer 完成 BeginDestroyFinishDestroy 的收尾。

十一、给客户端程序员的 UObject 引用自查清单

写完代码准备提交前,我会快速过一遍下面这几个问题。不需要每条都答是,但如果好几条都模棱两可,那就该停下来再想想。

  • 这个 UObject 是用 NewObject 创建的吗?它的 Outer 是谁?Outer 能否保证它活到需要的时候?
  • 这个指针是拥有关系、观察关系,还是临时关系?拥有用 UPROPERTYTStrongObjectPtr,观察用 TWeakObjectPtr,临时用完最好主动清理。
  • 这个类是不是非 UObject?非 UObject 持有 UObject 时,有没有继承 FGCObject 或者把对象交给 UObject 管理?
  • 这里有没有 A 引用 B、B 引用 A 的环?环本身没问题,但环里的对象是不是都被某个长期存在的 Root 间接抓着?
  • 这个对象有没有调用 AddToRoot?如果有,有没有对应的 RemoveFromRoot 时机?
  • 动态创建的对象数量大不大?是不是可以考虑对象池,而不是每次都 NewObject
  • 关卡切换或模块卸载时,这个引用会不会指向已经消失的 Package?

这些问题看起来很基础,但大部分 GC 泄漏都是基础问题反复出现。审查代码时多花两分钟,后面定位内存问题就能少花两小时。

结语:把 GC 当作合作者,而不是黑箱

UObject 的 GC 机制确实有学习成本,但它并不是为了刁难开发者。它提供了一套明确的规则:你是谁引用的、你有没有被 Root 保护、你是强引用还是弱引用。只要你把这些规则写清楚,GC 就会按约定帮你清理内存。

我们那次两百兆泄漏修复之后,阿杰在复盘会上说了一句话:「GC 不会撒谎,它只是照着你写的引用关系办事。」后来我把这句话贴在工位显示器旁边。每次写 UObject 引用时,都会多想一层:这个引用到底想表达拥有、观察,还是临时借用?答案不同,指针类型就该不同。

希望这篇文章能帮你在项目里少踩几个坑。内存问题不会消失,但理解 UObject 生命周期之后,至少你知道该往哪边看。下次再看到内存曲线往上翘,先别急着加对象池,先看看引用关系里是不是藏着一根没断干净的线。