GC 与对象生命周期

自动关联目录:GC 与对象生命周期

UE 的 GC 只回答一个问题:还有没有人引用这个 UObject。有就留着,没有就回收。它不关心你"打算"什么时候用这个对象,只关心此刻能不能从根走到它。

一句话定位:GC 是可达性分析,不是引用计数。理解这一条,绝大多数"对象突然没了"的 bug 都能瞬间定位。

基本原理

根集(Root Set)
  ├── AddToRoot 的对象
  ├── 被引擎持有的对象(World、GameInstance、已加载的 Package…)
  └── TStrongObjectPtr 持有的对象
        │
        │  沿 UPROPERTY 指针边遍历
        ▼
   可达对象 → 标记存活
   不可达   → 析构并回收

遍历只走 UPROPERTY 标记的 UObject 指针。裸指针、TArray<UMyObj*>(未标 UPROPERTY)、std::vector、Lua 里的引用,全都不算边。

UPROPERTY()
UMyObj* Safe;          // 有引用边,安全

UMyObj* Dangerous;     // 无引用边,随时可能被回收

UObject vs 普通 C++ 对象

UObject普通 C++ 对象(TSharedPtr/new)
释放GC 自动手动 / 智能指针
反射有无
序列化有无
网络复制有无
开销有元数据,构造更贵极轻
适用需要被引擎接管的资源与对象纯算法、临时数据、高频小对象

判断标准:这个对象需不需要出现在编辑器里、要不要存档、要不要复制、会不会被蓝图拿到?四个都是"否"就用普通 C++ 对象,四个里有一个"是"就用 UObject。

大量高频小对象(粒子、寻路节点)做成 UObject 会显著拖慢 GC——GC 的代价与 UObject 数量正相关,不是与内存量。

保活手段

手段适用场景代价
UPROPERTY()默认首选无
AddToRoot() / RemoveFromRoot()全局单例、手动管理生命周期必须成对调用,漏了就是永久泄漏
TStrongObjectPtr需要保活但不想改头文件有额外一层,作用域结束自动释放
FGCObject + AddReferencedObjects非 UObject 类持有 UObject要自己实现接口
放进 UObject 容器UPROPERTY() TArray<UMyObj*>无
// 非 UObject 类持有 UObject 的正确写法
class FMyData : public FGCObject
{
    UPROPERTY()           // 这里 UPROPERTY 无效,靠 AddReferencedObjects
    UMyObj* Obj;
public:
    virtual void AddReferencedObjects(FReferenceCollector& Collector) override
    {
        Collector.AddReferencedObject(Obj);
    }
    virtual FString GetReferencerName() const override { return TEXT("FMyData"); }
};

FGCObject 里忘实现 AddReferencedObjects 是静默 bug——编译过、运行也"看起来正常",直到某次 GC 后崩。

弱引用与有效性检查

类型用途
TWeakObjectPtr<T>不保活,对象销毁后自动置空;GC 安全
TSoftObjectPtr<T>软引用,不强制加载资源(见 Asset&Pak&Patch)
IsValid(Ptr)非空 且 未被标记待销毁
IsPendingKillPending()本帧被标记,还没真正销毁
UPROPERTY()
TWeakObjectPtr<AActor> Target;   // 观察目标,但不阻止它被销毁

if (Target.IsValid()) { /* 安全使用 */ }

不要用裸指针缓存跨帧的 UObject,也不要用 != nullptr 判断有效性——对象已被标记待销毁时指针仍非空,但内容已经不该再碰。

销毁时序

标记(Mark)→ 收集不可达对象 → BeginDestroy → IsReadyForFinishDestroy(可等待异步)
            → FinishDestroy → 内存释放
回调时机该做什么
BeginDestroy确定要销毁释放自己持有的外部资源(连接、文件、线程)
IsReadyForFinishDestroy轮询有异步释放未完成就返回 false
FinishDestroy真正释放前最后一刻一般不必重写

BeginDestroy 里不能再引用别的 UObject——它们的销毁顺序没有保证,很可能已经销毁了。

Actor 有自己的一套(EndPlay → Destroyed),那是 GamePlay 层的时序,不要用 BeginDestroy 做游戏逻辑清理。

显式触发与配置

CollectGarbage(RF_NoFlags);            // 强制全量 GC
TryCollectGarbage();                   // 有锁/有对象在用就跳过
GEngine->ForceGarbageCollection();     // 下一帧强制
控制台变量作用
gc.TimeBetweenPurgingPendingKillObjects两次清理的间隔
gc.MaxObjectsNotConsideredByGC小于此数量的对象不参与 GC
gc.VerifyNoUnreachableObjects调试用,验证可达性分析
gc.VerifyUObjectsAreNotFGCObjects调试用

性能与卡顿

GC 是分帧增量执行的(UE 会在帧预算内做一部分),但仍有可感知的尖峰:

现象原因处理
每隔 N 秒一个尖峰全量可达性分析调大间隔,或减少 UObject 数量
GC 时间随关卡加载变长UObject 总量增长检查是否有泄漏(对象没被卸载)
切场景卡顿卸载时大量销毁用 stat gc 确认,考虑分批卸载

排查命令:stat gc、obj gc、obj list class=XXX(列出某类所有实例,查泄漏非常好用)。

查泄漏的实操路线:obj list class=你的类 看数量是不是只增不减 → 如果是,检查谁在持有它 → obj refs name=XXX 看引用链。

常见坑

坑说明
裸指针保存 UObject无引用边,随机被回收
非 UPROPERTY 的 TArray<UObject*>同上,数组不构成引用边
AddToRoot 没有对应的 RemoveFromRoot永久泄漏,切场景也不释放
用 nullptr 判断代替 IsValid待销毁对象指针非空但不可用
在 BeginDestroy 里访问其它 UObject销毁顺序无保证
高频创建 UObject(每帧、每粒子)GC 压力剧增,应改用结构体池
静态变量持有 UObject 指针静态区不在 GC 引用图里,等效于裸指针
Lua 侧持有 UObject见 UnLua,需要专门的保活处理
异步加载回调里用栈上对象的成员回调时对象可能已销毁,用 TWeakObjectPtr