技术博客

DisregardForGC:常驻对象集合的建立过程

第 18 / 100 篇 · 2026-04-06 · ue5 · GCRootSet内存管理源码分析

UE 里有些对象从创建到进程退出都不该被 GC 回收:引擎单例、全局管理器、CDO、根级 Package。这些对象怎么告诉 GC"别碰我"?答案在不同时代不一样:UE3 有专门的 DisregardForGC 池,UE4 改成了 AddToRoot/RemoveFromRoot 的 flag 机制,UE5 又引入了 EInternalObjectFlags::RootSet 的优化。下面基于 UE5.3 把这条演变线和当前机制讲清楚。

RootSet:GC 可达性分析的起点

GC 的可达性分析从 RootSet 出发——RootSet 里的对象视为天然可达,从它们开始遍历引用图。RootSet 的构成:

  1. 显式 AddToRoot 的对象:调用 UObject::AddToRoot() 后,对象被打上 EInternalObjectFlags::RootSet 标记。
  2. RF_MarkAsRootSet 标记的类:UClass 可以带这个 flag,表示该类所有实例默认进 RootSet。
  3. GC Cluster 的根:簇根对象在标记时被视为 root。
  4. 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。

RootSet 构成:GC 可达性分析的起点 AddToRoot 标记 EInternalObjectFlags::RootSet RF_MarkAsRootSet 类 该类所有实例默认 root Cluster 根 簇根视为 root PermanentObjectPool 异步加载临时 root CDO / 引擎单例 进程级常驻对象 GC 标记流程:遍历 FUObjectArray 收集所有 root 对象 → 染灰 → 三色标记主循环。 RootSet 无集中存储,就是对象上的 flag;收集是 O(总对象数) 的遍历。 历史:UE3 用 TSet 存 DisregardForGC,UE4.6+ 改为 flag 机制,省内存但收集需遍历。 PermanentObjectPool 是临时 root,加载完成必须移除,否则永久泄漏。