UE 里有些对象从创建到进程退出都不该被 GC 回收:引擎单例、全局管理器、CDO、根级 Package。这些对象怎么告诉 GC"别碰我"?答案在不同时代不一样:UE3 有专门的 DisregardForGC 池,UE4 改成了 AddToRoot/RemoveFromRoot 的 flag 机制,UE5 又引入了 EInternalObjectFlags::RootSet 的优化。下面基于 UE5.3 把这条演变线和当前机制讲清楚。
RootSet:GC 可达性分析的起点
GC 的可达性分析从 RootSet 出发——RootSet 里的对象视为天然可达,从它们开始遍历引用图。RootSet 的构成:
- 显式 AddToRoot 的对象:调用
UObject::AddToRoot()后,对象被打上EInternalObjectFlags::RootSet标记。 - RF_MarkAsRootSet 标记的类:
UClass可以带这个 flag,表示该类所有实例默认进 RootSet。 - GC Cluster 的根:簇根对象在标记时被视为 root。
- Permanent Object Pool:
FPermanentObjectPool(GarbageCollection.cpp)里的对象,用于异步加载期间临时 root。
RootSet 的遍历在 TFastReferenceCollector::ProcessObjectArray 的开头:先把所有 root 对象染灰(加入待处理队列),然后开始三色标记的主循环。
AddToRoot 的实现
UObject::AddToRoot() 的实现极其简单(UObject/UObject.cpp):
void UObject::AddToRoot()
{
SetInternalFlags(EInternalObjectFlags::RootSet);
}
SetInternalFlags 是原子操作(std::atomic fetch_or),多线程安全。RemoveFromRoot() 对应清除这个 flag。GC 标记时检查 HasAnyInternalFlags(EInternalObjectFlags::RootSet) 判断是否进 root 集合。
这个设计的优势是零额外内存:RootSet 不是单独的数据结构,就是对象上的一个 flag 位。查询是 O(1),添加/移除是 O(1)。代价是 GC 标记时必须扫描所有对象来收集 root——因为 RootSet 没有集中存储,只能遍历 FUObjectArray 找带 flag 的。这就是 gc.NumObjectsInRootSet 统计项的来源。
DisregardForGC 的历史
UE3 时代有个专门的 FDisregardForGC 类,维护一个 TSet<UObject*> 存常驻对象。GC 标记前先把这个集合里的对象全部加入可达集合。问题有两个:
- 内存开销:每个常驻对象占一个 TSet 节点,几万个常驻对象就是几百 KB。
- 遍历开销:标记前要遍历整个集合,集合大时这步本身就不便宜。
UE4.6 左右 Epic 把这套改成了 flag 机制(提交信息里明确写了"remove DisregardForGC pool"),用 AddToRoot 替代。迁移期 API 保留了 AddToRoot/RemoveFromRoot 的名字,但内部实现从"加入集合"变成了"设置 flag"。
UE5 进一步优化:引入了 EInternalObjectFlags::RootSet 作为独立 flag(之前复用 RF_MarkAsRootSet 的语义,但两者有细微差别),并且 GC 收集 root 时用了分块遍历的优化——FUObjectArray 的分块结构让"扫描所有对象找 root flag"可以并行。
Permanent Object Pool:异步加载的临时 root
异步加载资产时,加载中的对象需要临时 root——否则 GC 可能在加载进行到一半时把部分加载的对象回收了。FPermanentObjectPool(GarbageCollection.cpp 的 GPermanentObjectPool)就是干这个的:
// 异步加载包时
GPermanentObjectPool.AddToRoot(Package);
// ... 加载完成 ...
GPermanentObjectPool.RemoveFromRoot(Package);
这个 pool 和全局 RootSet 的区别在于它是临时的:加载完成后对象应该从 pool 移除,转由正常的引用关系维持可达性。如果代码 bug 导致加载完成后没移除,对象会永久泄漏——这是异步加载内存泄漏的常见根因。
常驻对象的正确使用
什么该加 root?经验法则:
- 引擎级单例:
UGameEngine、UGameInstance这类,进程存活期间必须存在。 - CDO:类默认对象在类注册时加 root(
UClass的SetUpInternalFlags里做),保证类的默认值永远可用。 - 全局配置对象:从 ini 加载的运行时配置,加载一次后不再变。
什么不该加 root?
- 关卡内 Actor:生命周期由 Level 管理,加 root 会导致 Level 卸载后 Actor 不回收。
- 临时计算对象:用完就该被 GC。
- 大量对象:RootSet 的收集是遍历式的,root 对象越多,GC 标记前的收集阶段越慢。
一个反模式:为了"防止对象被 GC"就把所有缓存对象加 root。正确的做法是用 UPROPERTY 让一个 root 对象持有它们——这样 GC 的引用图会自然保护这些对象,而且引用关系可见、可调试。
RootSet flag 与 RF_MarkAsRootSet 的区别
这两个标记经常被混为一谈,但语义不同:
EInternalObjectFlags::RootSet:对象级标记,AddToRoot()设的就是它。只影响当前这一个对象。RF_MarkAsRootSet:类级标记(EObjectFlags),设在 UClass 上。GC 收集 root 时,凡GetClass()->HasAnyClassFlags(CLASS_MarkAsRootSet)为 true 的实例都算 root。引擎里UField、UClass这类元数据类带这个标记,所以所有类对象天然常驻。
判断一个对象是否会被 GC 当 root,完整的条件是(TFastReferenceCollector 里的逻辑,简化):
bool bIsRoot = Obj->HasAnyInternalFlags(EInternalObjectFlags::RootSet)
|| Obj->GetClass()->HasAnyClassFlags(CLASS_MarkAsRootSet)
|| Obj->IsRooted(); // 含外部引用计数(AsyncLoading 期间)
第三条 IsRooted() 对应的是 FPermanentObjectPool 和外部 AddRef 机制——异步加载的包在加载期间由这里保活。
排查 root 滥用
RootSet 膨胀的症状是 GC 的 root 收集阶段变慢,以及"对象删不掉"。定位手段:
obj list里看哪些类的实例数只增不减,结合obj refs name=X shortest看引用链是否终止于 root。- 代码侧全局搜索
AddToRoot,逐个审查调用点是否有配对的RemoveFromRoot。模块卸载(ShutdownModule)是常见的遗漏点——编辑器热重载模块时,旧模块 root 的对象不释放,新模块又建一份,内存双倍。
实战结论
- 排查"对象不回收"时先查
HasAnyInternalFlags(EInternalObjectFlags::RootSet),再看obj refs的引用链是否终止于某个 root 对象。 - 异步加载内存泄漏优先查
GPermanentObjectPool——加载完成没移除是常见 bug。 - 自定义全局管理器用
AddToRoot时,配套在模块卸载时RemoveFromRoot,否则编辑器热重载会泄漏。 gc.NumObjectsInRootSet异常大(几万以上)说明 root 滥用,需要审查哪些对象被加了 root。