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 |