概述
在进入GC流程前,我觉得需要普及一些概念。。。
对象簇
FUObjectCluster
把一组紧密相关的 UObject 打包成一个整体,GC 时只检查"包"而不检查里面每个对象,大幅减少遍历量
假设加载一个材质包,里面有 UMaterial + 50 个 UMaterialExpression + 若干 Shader 相关对象。没有簇时,GC 需要逐个遍历这 50+ 个对象的引用。但这些对象的生死完全由材质决定——材质活着它们就活着,材质死了它们也该死。逐个遍历纯属浪费。
簇就是把它们捆成一个单元:簇根(Cluster Root)可达 → 整个簇可达,不需要逐个遍历成员。
struct FUObjectCluster
{
int32 RootIndex; // 簇根对象索引(如 UMaterial)
TArray<int32> Objects; // 簇内成员对象索引(同包、可簇化的对象)
TArray<int32> ReferencedClusters; // 本簇引用的其他簇的根索引
TArray<int32> MutableObjects; // 不能入簇但被引用的对象(需单独遍历)
TArray<int32> ReferencedByClusters; // 引用本簇的其他簇(用于级联溶解)
bool bNeedsDissolving; // 标记需要溶解
};
什么对象能当簇根 / 入簇
- 簇根。重写
CanBeClusterRoot()返回true的类:UMaterial、UBlueprintGeneratedClass、USoundCue等 - 入簇成员。
CanBeInCluster()返回true(默认委托给 Outer 链)、在同一个包内、已加载完成、非根对象 - 不能入簇。不同包、未加载完成、被
AddToRoot()锁定、CanBeInCluster()返回false→ 放入MutableObjects
注意:簇只在 Cooked 构建(打包后的游戏)中创建,编辑器中不会创建。
创建时机:
资源加载完成后(PostLoad),引擎检查每个对象是否 CanBeClusterRoot(),是就调用 CreateCluster():
加载材质包
→ PostLoad 完成
→ UMaterial::CanBeClusterRoot() == true
→ UMaterial::CreateCluster() // 这个函数可能在基类 UObjectBaseUtility::CreateCluster
→ FClusterReferenceProcessor 遍历所有引用
├─ 同包、可簇化的对象 → Cluster.Objects(成员)
├─ 已属于其他簇的对象 → Cluster.ReferencedClusters(簇间引用)
├─ DisregardForGC 对象 → 忽略
└─ 不同包/不可簇化 → Cluster.MutableObjects(需单独追踪)
→ 成员数 < MinClusterSize → 簇太小,丢弃
GC 期间的处理:
性能收益:一个有 100 个成员的簇,GC 只需处理 1 个簇根 + 少量簇间引用和 MutableObjects,而不是 100 个对象的引用遍历。
簇溶解:
- GC 期间:簇根是垃圾 →
DissolveClusterAndMarkObjectsAsUnreachable(),成员标记为 MaybeUnreachable - 运行时:簇根或成员被销毁 → DissolveCluster(),清除成员的 OwnerIndex
- 级联溶解:如果簇 A 引用簇 B,簇 B 溶解时会通过
ReferencedByClusters级联溶解簇 A
关卡 Actor 簇(特殊处理):
ULevel 不直接当簇根,而是创建一个 ULevelActorContainer 作为簇根,把关卡中可簇化的 Actor 全部装入,不可簇化的 Actor 放入 ActorsForGC 单独追踪。
如何标记为垃圾(Garbage)
// UObjectBaseUtility.h
FORCEINLINE void MarkAsGarbage()
{
check(!IsRooted());
AtomicallySetFlags(RF_MirroredGarbage);
GUObjectArray.IndexToObject(InternalIndex)->SetGarbage(); // 设置 bit 21
}
这是 UE5 替代旧版 MarkPendingKill() 的接口。当引擎/游戏代码决定某个对象"逻辑上已死"时调用。比如 Actor 被 Destroy() 时,最终会调到 MarkAsGarbage()。
剧透: GC流程会找出一些根,一定不会被回收的东西,然后顺着做可达性查询。
- 这种标记为垃圾的对象就不会被标记为根。
- 遍历引用时发现指向 Garbage 对象的引用,直接 null 掉
- 簇根是 Garbage → 整个簇溶解
GUObjectArray
GUObjectArray 是 UE 中所有 UObject 的全局注册表——每个 UObject 创建时都会在这个数组里占一个槽位。
核心结构
class FUObjectArray;
GUObjectArray (FUObjectArray)
│
├── ObjObjects (FChunkedFixedUObjectArray)
│ ├── Chunk 0: [FUObjectItem × 65536] ─┐
│ ├── Chunk 1: [FUObjectItem × 65536] │ DisregardForGC 区(永不扫描)
│ ├── ... ─┘ 索引 0 ~ ObjLastNonGCIndex
│ ├── Chunk N: [FUObjectItem × 65536] ─┐
│ └── ... │ GC 管辖区
│ ─┘ 索引 ObjFirstGCIndex ~ 末尾
│
├── ObjAvailableList: [可复用的空闲索引]
├── ObjFirstGCIndex: GC 扫描的起始索引
└── ObjLastNonGCIndex: DisregardForGC 区的末尾索引
struct FUObjectItem
{
UObjectBase* Object; // 指向 UObject 的指针
int32 Flags; // EInternalObjectFlags(Garbage、Reachable、ClusterRoot 等)
int32 ClusterRootIndex; // 簇索引(负值)或 Owner 索引(正值)
int32 SerialNumber; // TWeakObjectPtr 用的序列号,防止悬空引用
int32 RefCount; // 引用计数,>0 时阻止 GC 回收
};
UObject 自身的 InternalIndex 字段就是它在这个数组中的下标。通过 GUObjectArray.IndexToObject(Index) 即可 O(1) 访问元数据。
两个区域
- DisregardForGC
- 0 ~ ObjLastNonGCIndex
- 引擎初始化期间创建的对象(CDO、引擎单例等),GC 永远不扫描。性能优化——这些对象生命周期等同进程
- GC 管辖区
- ObjFirstGCIndex ~ 末尾
- GC 每次从这里开始扫描。游戏运行时创建的对象都在这里
初始加载期间 OpenForDisregardForGC = true,新对象进入 DisregardForGC 区。加载完成后调用 CloseDisregardForGC() 关闭,之后的新对象都进入 GC 管辖区。编辑器中此优化被禁用(MaxObjectsNotConsideredByGC = 0)。
对象的增删
创建函数(AllocateUObjectIndex):
- 优先从空闲列表
ObjAvailableList复用已释放的索引 - 没有空闲索引则在数组末尾追加
销毁函数(FreeUObjectIndex):
- 清空槽位(Object 指针置 null、Flags 归零)
- GC 区的索引回收到
ObjAvailableList;DisregardForGC 区的不回收
FChunkedFixedUObjectArray 是什么
class FUObjectArray
{
typedef FChunkedFixedUObjectArray TUObjectArray;
TUObjectArray ObjObjects;
}
为什么用分块数组 FChunkedFixedUObjectArray
这里面维护了一个二维数组 FUObjectItem。每一行是 n = 10 * 1024 个 FUObjectItem
扩容就一次扩一行。寻址就 Index / n 第几行,在取余第几个。在operator [] 里面处理。
对多线程有什么意义?
- 游戏线程分配新 Chunk 时(扩容),只写
Objects[N] = 新Chunk指针(一次原子写) - 已有 Chunk 的地址不变,GC 线程持有的指针仍然有效
- 读已有索引永远安全,不需要加锁
- 只有写操作(添加新对象)需要加锁,而且只锁很短的时间(AllocateUObjectIndex函数里面)
=====
GC 标记阶段直接遍历 GUObjectArray 从 ObjFirstGCIndex 开始的所有槽位,读取每个 FUObjectItem 的 Flags 判断可达性。这就是代码中各种 GUObjectArray.GetObjectItemArrayUnsafe()[Index] 调用的来源。TWeakObjectPtr 也基于此——用 (Index, SerialNumber) 对来安全地引用对象,SerialNumber 不匹配则说明对象已被销毁。
=====
这一趴有理解什么呢?
UObject 会挂在一个全局数组上,这个数组分段了,前一段GC不扫描,来放一些CDO,引擎单例这些生命周期就是进程。后面是需要GC的对象。
UObject 自身的 InternalIndex 字段就是它在这个数组中的下标。通过 GUObjectArray.IndexToObject(Index) 即可 O(1) 访问元数据。
这个元数据就是 FUObjectItem
怎么拿的给看这个的 operator[] 重载,里面有一些chunk分块逻辑
GC的入口
E:\Epic\UE_5.6\Engine\Source\Runtime\CoreUObject\Private\UObject\GarbageCollection.cpp
UE::GC::CollectGarbageInternal(KeepFlags, bPerformFullPurge);
然后就到了 FReachabilityAnalysisState::CollectGarbage
GReachabilityState 是一个全局对象
class FReachabilityAnalysisState
{
private:
// 工作线程数,以及上下文对象
int32 NumWorkers = 0;
FWorkerContext* Contexts[MaxWorkers] = { nullptr };
// 用于启动 UObject 可达性分析溯源的附加对象标志
EObjectFlags ObjectKeepFlags = RF_NoFlags;
// 如果是True,拿这个就是一次全量的GC
bool bPerformFullPurge = false;
// 可达性分析,由于时间片被挂起了就是True
bool bIsSuspended = false;
// 上次可达性分析运行的统计数据(所有迭代)
UE::GC::FProcessorStats Stats;
// 所有可达性分析迭代的总时长
double IncrementalMarkPhaseTotalTime = 0.0;
// 实际引用遍历的总时长
double ReferenceProcessingTotalTime = 0.0;
// 可达性分析过程中执行的可达性分析迭代次数
int32 NumIterations = 0;
// 启用 gc.DelayReachabilityIterations 运行时,需要跳过的可达性分析迭代次数
int32 NumRechabilityIterationsToSkip = 0;
void FReachabilityAnalysisState::CollectGarbage(EObjectFlags KeepFlags, bool bFullPurge)
{
using namespace UE::GC::Private;
// [1] 处理增量可达性被打断
if (GIsIncrementalReachabilityPending)
{
bPerformFullPurge = true;
PerformReachabilityAnalysisAndConditionallyPurgeGarbage(/*bReachabilityUsingTimeLimit =*/ false);
checkf(!GIsIncrementalReachabilityPending, TEXT("Flushing incremental reachability analysis did not complete properly"));
AcquireGCLock(); // PostCollectGarbageImpl 中释放了锁,需要重新获取
}
// [2] 保存本轮参数
ObjectKeepFlags = KeepFlags;
bPerformFullPurge = bFullPurge;
// [3] 决定是否使用时间限制
const bool bReachabilityUsingTimeLimit = !bFullPurge && GAllowIncrementalReachability;
PerformReachabilityAnalysisAndConditionallyPurgeGarbage(bReachabilityUsingTimeLimit);
}
- 增量上一轮增量可达性分析(Incremental Reachability Analysis)在某帧挂起后、尚未完成时,又有新的
CollectGarbage请求进来。处理方式:强制升级为 Full Purge(bPerformFullPurge = true),不使用时间限制一口气跑完剩余分析 + 清除不可达对象。完成后checkf断言验证增量状态已清除。因为PostCollectGarbageImpl内部释放了 GC 锁,所以需要AcquireGCLock()重新获取。 ObjectKeepFlags(带哪些标志的对象视为根,如编辑器中RF_Standalone)和bPerformFullPurge(是否完整清除)保存为成员变量,供后续PreCollectGarbageImpl/CollectGarbageImpl/PostCollectGarbageImpl使用。bFullPurge=true或GAllowIncrementalReachability=false→ 不使用时间限制(一次性完成)。否则启用增量模式,每帧处理一个时间片。
PerformReachabilityAnalysisAndConditionallyPurgeGarbage
这个函数主要三个流程
void FReachabilityAnalysisState::PerformReachabilityAnalysisAndConditionallyPurgeGarbage(
bool bReachabilityUsingTimeLimit)
{
LLM_SCOPE(ELLMTag::GC);
// [1] 统计初始化(仅新轮次首次迭代)
if (!GIsIncrementalReachabilityPending)
{
GGCStats = FStats();
GGCStats.NumObjects = GUObjectArray.GetObjectArrayNumMinusAvailable() - GUObjectArray.GetFirstGCIndex();
GGCStats.NumClusters = GUObjectClusters.GetNumAllocatedClusters();
}
// [2] Verse VM:释放 Verse 堆访问权,避免与 GC 同时操作内存
#if WITH_VERSE_VM
Verse::FIOContext Context = RelinquishVerseHeapAccess(RunningContext);
#endif
// [3] 预处理阶段
PreCollectGarbageImpl<bPerformFullPurge>(ObjectKeepFlags);
// [4] 决定是否强制非增量
const bool bForceNonIncrementalReachability =
!GIsIncrementalReachabilityPending &&
(bPerformFullPurge || !GAllowIncrementalReachability);
// [5] 执行可达性分析
PerformReachabilityAnalysis();
GIsIncrementalReachabilityPending = GReachabilityState.IsSuspended();
// [6] 垃圾引用追踪重跑
if (!GIsIncrementalReachabilityPending && Stats.bFoundGarbageRef && GGarbageReferenceTrackingEnabled > 0)
{
FRealtimeGC GC;
GC.Stats = Stats;
GC.PerformReachabilityAnalysis(ObjectKeepFlags, GetReferenceCollectorOptions(bPerformFullPurge));
}
// [7] 后处理阶段
PostCollectGarbageImpl<bPerformFullPurge>(ObjectKeepFlags);
// [8] 恢复 Verse 堆访问
#if WITH_VERSE_VM
AcquireVerseHeapAccess(Context);
#endif
}
Verse不看,是虚幻那个编程语言,但是没推广开吧。
第6点不看,这个是分析GC流程用的。在第5点里面 PerformReachabilityAnalysis 本质上也是调用到 FRealtimeGC 的 PerformReachabilityAnalysis
总共就三个流程,我先简单概括一下
- PreCollectGarbageImpl
- 预先的处理,如果之前有时间分片挂起的GC要完成,避免干扰。维护出一个InitObject的根列表。这些肯定是不GC的,后面顺着这个去标记其他可达对象。
- PerformReachabilityAnalysis
- 具体的可达性分析对象,核心逻辑也会走到 FRealtimeGC 里面的 PerformReachabilityAnalysis
- PostCollectGarbageImpl
- 具体对不可达的对象做清理的地方。
PreCollectGarbageImpl
就叫他GC预处理吧,用true,false 区别是不是全量
/**
* Deletes all unreferenced objects, keeping objects that have any of the passed in KeepFlags set
*
* @param KeepFlags objects with those flags will be kept regardless of being referenced or not
* @param bPerformFullPurge if true, perform a full purge after the mark pass
*/
template<bool bPerformFullPurge>
void PreCollectGarbageImpl(EObjectFlags KeepFlags)
{
// GC 已获得控制权,重置"正在等待"标记
FGCCSyncObject::Get().ResetGCIsWaiting();
// 加载期间 Import 引用未建立,此时 GC 会产生错误的可达性结果
check(!IsLoading());
// 重置 GC 跳过计数器。(TryCollectGarbage 获锁失败时递增,超过 gc.NumRetriesBeforeForcingGC 次强制 GC)
GNumAttemptsSinceLastGC = 0;
// 非增量GC,仅在新轮次首次迭代执行
if (!GIsIncrementalReachabilityPending)
{
// 先释放 GC 锁,后面要调用用户代码和异步逻辑,持有锁容易出问题
ReleaseGCLock();
// 若 CVar gc.FlushStreamingOnGC 开启,强制完成所有异步加载,确保对象引用关系稳定
if (GFlushStreamingOnGC && IsAsyncLoading())
FlushAsyncLoading();
// 广播预 GC 委托,外部系统做准备(如释放临时强引用)
FCoreUObjectDelegates::GetPreGarbageCollectDelegate().Broadcast();
// 防御性设计,上面的委托可能触发了新的异步加载,再刷一遍
if (GFlushStreamingOnGC && IsAsyncLoading())
FlushAsyncLoading();
// 重新获取 GC 独占锁,进入临界区
AcquireGCLock();
}
GIsGarbageCollecting = true; // 设置全局标记,已经开始GC了
if (!GIsIncrementalReachabilityPending)
FCoreUObjectDelegates::GetGarbageCollectStartedDelegate().Broadcast(); // 广播 GC 已开始委托
// 如果上轮 GC 标记的不可达对象还没清除完(增量销毁仍在进行),必须在新一轮标记前【强制完成】
// 否则旧的 `RF_Unreachable` 标记会干扰本轮可达性分析。`false` = 不使用时间限制
if (IsIncrementalPurgePending())
{
IncrementalPurgeGarbage(false);
if (!bPerformFullPurge) FMemory::Trim();
}
// 锁定 UObject 哈希表,分析期间禁止 `NewObject()` / `StaticFindObject()` / 重命名等操作
GIsGarbageCollectingAndLockingUObjectHashTables = true;
LockUObjectHashTables();
// 断言 + 集群溶解 + 校验
if (!GIsIncrementalReachabilityPending)
{
check(!GObjIncrementalPurgeIsInProgress);
check(!GObjPurgeIsRequired); // 断言确认上轮清除已完成
if (!GCreateGCClusters && GUObjectClusters.GetNumAllocatedClusters())
GUObjectClusters.DissolveClusters(true); // 若运行时禁用了集群(`gc.CreateGCClusters=0`)但有存量集群→溶解
// 调试构建中验证三项假设
// - DisregardForGC 池对象不引用可 GC 对象
// - 集群引用一致性
// - 对象标记无矛盾
#if VERIFY_DISREGARD_GC_ASSUMPTIONS
VerifyGCAssumptions();
VerifyClustersAssumptions();
VerifyObjectFlags();
#endif
}
}
尝试总结一下,预处理阶段
- 做了一些清理和重置,例如运行GC时候有增量的GC未完成要强制他完成。
- 锁UObject哈希表,不允许GC的时候 NewObject, FindStaticObject, 重命名操作。Post的时候才解开。(FUObjectHashTables::Get().Lock();)
CollectGarbageImpl
标记阶段。
void FReachabilityAnalysisState::PerformReachabilityAnalysisAndConditionallyPurgeGarbage(
bool bReachabilityUsingTimeLimit)
{
// ...
// [3] 预处理阶段
PreCollectGarbageImpl<bPerformFullPurge>(ObjectKeepFlags);
// [5] 执行可达性分析
PerformReachabilityAnalysis();
// [7] 后处理阶段
PostCollectGarbageImpl<bPerformFullPurge>(ObjectKeepFlags);
// ...
}
我们要看这里的可达性分析
这个 PerformReachabilityAnalysis
void FReachabilityAnalysisState::PerformReachabilityAnalysis()
{
if (!bIsSuspended) // 全新开始(非恢复)
{
Init(); // 初始化标记状态,里面只是一个赋值
NumRechabilityIterationsToSkip = FMath::Max(0, GDelayReachabilityIterations);
}
// → CollectGarbageImpl<true>
if (bPerformFullPurge)
{
UE::GC::CollectGarbageFull(ObjectKeepFlags);
}
// → CollectGarbageImpl<false>
else if (NumRechabilityIterationsToSkip == 0 || // Delay reachability analysis by NumRechabilityIterationsToSkip (if desired)
!bIsSuspended || // but only but only after the first iteration (which also does MarkObjectsAsUnreachable)
IterationTimeLimit <= 0.0f) // and only when using time limit (we're not using the limit when we're flushing reachability analysis when starting a new one or on exit)
{
UE::GC::CollectGarbageIncremental(ObjectKeepFlags);
}
else
{
--NumRechabilityIterationsToSkip; // 跳过本次迭代(延迟启动优化)
}
FinishIteration(); // 更新迭代状态,可能设置 bIsSuspended
}
- Full Purge → CollectGarbageFull → 不限时完成所有标记
- 增量模式且不跳过 → CollectGarbageIncremental → 受时间片限制,可能挂起
- 跳过 → GDelayReachabilityIterations CVar 控制,允许延迟几帧再开始真正的标记遍历(给其他系统喘息时间)
下一步会到这里,用ture false 区别是否全量
CollectGarbageImpl
template<bool bPerformFullPurge>
void CollectGarbageImpl(EObjectFlags KeepFlags)
{
{
// Reachability analysis.
{
const EGCOptions Options = GetReferenceCollectorOptions(bPerformFullPurge);
// Perform reachability analysis.
FRealtimeGC GC;
GC.PerformReachabilityAnalysis(KeepFlags, Options);
}
}
}
创建 FRealtimeGC 实例,根据 Options 选择:
- 并行 / 单线程(EGCOptions::Parallel)
- 是否启用垃圾消除(EGCOptions::EliminateGarbage)
- 是否增量可达性(EGCOptions::IncrementalReachability)
FRealtimeGC::PerformReachabilityAnalysis
// GarbageCollection.cpp:4466
void PerformReachabilityAnalysis(EObjectFlags KeepFlags, const EGCOptions Options)
{
StartReachabilityAnalysis(KeepFlags, Options);
StartVerseGC(); // UE 5.4+ Verse 集成
while (true)
{
PerformReachabilityAnalysisPass(Options); // 多线程引用遍历
if (GReachabilityState.IsSuspended()) // 增量模式:可挂起
{
if (EnumHasAnyFlags(Options, EGCOptions::IncrementalReachability))
break;
}
else if (Private::GReachableObjects.IsEmpty() /* ... */)
{
StopVerseGC();
break; // 标记完成
}
}
}
verse不看。
主要是两块:
- StartReachabilityAnalysis 构建了一个可达性查询的根列表。TArray<UObject*> InitialObjects;
- PerformReachabilityAnalysisPass 多线程引用遍历。
StartReachabilityAnalysis
在首次进入可达性分析时(非从增量挂起恢复),执行初始化:
压缩一下
void StartReachabilityAnalysis(EObjectFlags KeepFlags, const EGCOptions Options)
{
BeginInitialReferenceCollection(Options); // [1] 后台收集 FGCObject 引用
GObjectCountDuringLastMarkPhase.Reset();
InitialObjects.Reset();
// [2] cooked 平台特殊处理
if (FPlatformProperties::RequiresCookedData() && GUObjectArray.IsDisregardForGC(FGCObject::GGCObjectReferencer))
InitialObjects.Add(FGCObject::GGCObjectReferencer);
// [3] 标记所有对象为 MaybeUnreachable + 收集根集合
MarkObjectsAsUnreachable(KeepFlags);
}
【1】BeginInitialReferenceCollection 并行模式下在后台 Task 中提前调用 【FGCObject::GGCObjectReferencer->AddInitialReferences(InitialReferences)】收集所有FGCObject(非 UObject 的 C++ 对象,如 Subsystem 等)持有的 UObject 引用。主线程可同时执行[3] 的标记工作
- 这个就是继承 FGCObject 然后 AddReferencedObjects 的那种不被GC的那种方式
- 这可以是异步的(UE::Tasks::Launch)
【2】GGCObjectReferencer 通常在 DisregardForGC 池中(GC 跳过的永久对象),但它引用了所有 FGCObject 注册的对象,必须被扫描。在 cooked 平台上特殊添加到初始对象集
- 和【1】乍一看好像呀,其实这个只是把 GGCObjectReferencer 这个UObject加入根集合。别漏了
【3】MarkObjectsAsUnreachable 标记完成后,InitialObjects 就有了完整的根集合。
- 有好几步,来一个大标题
MarkObjectsAsUnreachable
MarkObjectsAsUnreachable_翻转
// GarbageCollection.cpp:4313
FORCENOINLINE void MarkObjectsAsUnreachable(const EObjectFlags KeepFlags)
{
// 原子翻转,所有对象 → MaybeUnreachable
if (const bool bInitialMark = !Stats.bFoundGarbageRef)
{
FGCFlags::SwapReachableAndMaybeUnreachable();
}
else
{
// 。。。
}
// 簇根和根对象立即重标记为 Reachable
MarkClusteredObjectsAsReachable(GatherOptions, InitialObjects);
MarkRootObjectsAsReachable(GatherOptions, KeepFlags, InitialObjects);
}
第一步是反转枚举的语义。
/** Current EInternalObjectFlags value representing a reachable object */
EInternalObjectFlags FGCFlags::ReachableObjectFlag = EInternalObjectFlags::ReachabilityFlag0;
/** Current EInternalObjectFlags value representing a maybe unreachable object */
EInternalObjectFlags FGCFlags::MaybeUnreachableObjectFlag = EInternalObjectFlags::ReachabilityFlag1;
Swap(ReachableObjectFlag, MaybeUnreachableObjectFlag);
由于前面pre阶段,对增量GC的要全部完成,所以不可达的对象都已经没了。
剩下的都是可达的对象。直接改变枚举的语义。O(1) 状态下完成全部标记为不可达。
这个 if (const bool bInitialMark = !Stats.bFoundGarbageRef) 什么时候是改变?
99%都是跑上面的翻转的,特殊情况,垃圾引用追踪重跑(else里面的逻辑)
在非 Shipping 的调试构建中,如果开启了 CVar gc.GarbageReferenceTrackingEnabled,GC 的第一轮可达性分析完成后可能会发现:
- 某个已被标记为垃圾(Garbage)的对象,仍然被一个可达对象引用着。这属于逻辑错误(悬挂引用/泄漏),
此时引擎会设置 Stats.bFoundGarbageRef = true。
在后面的流程中会用 TDebugReachabilityProcessor 进行额外的记录。这里刚刚跑完一轮,不满足那个pre阶段不可达对象都没了。所以只能一个一个检查。
====
接下来是这两个函数
MarkClusteredObjectsAsReachable(GatherOptions, InitialObjects);
MarkRootObjectsAsReachable(GatherOptions, KeepFlags, InitialObjects);
MarkObjectsAsUnreachable_簇标记
MarkClusteredObjectsAsReachable 簇对象标记
对所有对象簇进行并行遍历,决策保留或溶解:
对象簇的概念我们上面已经聊过了。
另外ParallelFor是多线程的写法,会阻塞在这里,全部完成才会下去
// Num = 总工作量,引擎自动把 1000 个元素分配到多个线程
ParallelFor(1000, [&](int32 Index)
{
ProcessItem(Index); // 每次调用处理 1 个元素
});
代码做了一定简化,去除了一下合法性校验,保留关键代码。
ParallelFor(TEXT("GC.MarkClusteredObjectsAsReachable"), NumWorkerThreads, 1,
[&ThreadIterators, &ClusterArray, &TotalClusteredObjects](int32 ThreadIndex)
{
while (ThreadState.Index <= ThreadState.LastIndex)
{
FUObjectCluster& Cluster = ClusterArray[ClusterIndex];
FUObjectItem* RootItem = &GUObjectArray.GetObjectItemArrayUnsafe()[Cluster.RootIndex];
if (!RootItem->IsGarbage())
{
bool bKeepCluster = RootItem->HasAnyFlags(EInternalObjectFlags_RootFlags);
if (bKeepCluster)
FGCFlags::FastMarkAsReachableInterlocked_ForGC(RootItem); // 根标记为可达
for (int32 ObjectIndex : Cluster.Objects)
{
FUObjectItem* ClusteredItem = &GUObjectArray.GetObjectItemArrayUnsafe()[ObjectIndex];
FGCFlags::FastMarkAsReachableAndClearReachableInClusterInterlocked_ForGC(ClusteredItem);
if (!bKeepCluster && ClusteredItem->HasAnyFlags(EInternalObjectFlags_RootFlags))
{
ThreadState.Payload.KeepClusters.Add(RootItem);
bKeepCluster = true; // 成员是根 → 整个簇保留
}
}
}
else
{
ThreadState.Payload.ClustersToDissolve.Add(RootItem); // 根是垃圾 → 溶解簇
}
}
});
先不纠结一下多线程。ParallelFor 可以理解为一个任务池,多个线程做完就抢下一个。
int32 ClusterIndex = ThreadState.Index++; 这里的 ClusterIndex 就是抢到了,要开始做了。
FUObjectItem* RootItem = &GUObjectArray.GetObjectItemArrayUnsafe()[Cluster.RootIndex];
这个我们前面也提到过,UObject在全局Array的元数据,里面是这些东西。
struct FUObjectItem
{
UObjectBase* Object; // 指向 UObject 的指针
int32 Flags; // EInternalObjectFlags(Garbage、Reachable、ClusterRoot 等)
int32 ClusterRootIndex; // 簇索引(负值)或 Owner 索引(正值)
int32 SerialNumber; // TWeakObjectPtr 用的序列号,防止悬空引用
int32 RefCount; // 引用计数,>0 时阻止 GC 回收
};
RootItem->IsGarbage() 就是检查这些标记位,标记位上面时候被标记呢?例如一个Actor销毁后,被逻辑标记为垃圾。
MarkAsGarbage, 以前叫 PendingKill
if (!RootItem->IsGarbage())
{
bool bKeepCluster = RootItem->HasAnyFlags(EInternalObjectFlags_RootFlags);
if (bKeepCluster)
FGCFlags::FastMarkAsReachableInterlocked_ForGC(RootItem); // 根标记为可达
for (int32 ObjectIndex : Cluster.Objects)
{
FUObjectItem* ClusteredItem = &GUObjectArray.GetObjectItemArrayUnsafe()[ObjectIndex];
FGCFlags::FastMarkAsReachableAndClearReachableInClusterInterlocked_ForGC(ClusteredItem);
if (!bKeepCluster && ClusteredItem->HasAnyFlags(EInternalObjectFlags_RootFlags))
{
ThreadState.Payload.KeepClusters.Add(RootItem);
bKeepCluster = true; // 成员是根 → 整个簇保留
}
}
}
else
{
ThreadState.Payload.ClustersToDissolve.Add(RootItem); // 根是垃圾 → 溶解簇
}
判断后两个分支
- 簇根不是垃圾
- 标记根和所有成员为 Reachable。如果簇根或任何成员具有根标志(
RootSet、Native等)→ 整个簇保留并加入KeepClusters
- 标记根和所有成员为 Reachable。如果簇根或任何成员具有根标志(
- 簇根是垃圾
- 加入
ClustersToDissolve,后续调用GUObjectClusters.DissolveClusterAndMarkObjectsAsUnreachable()溶解簇,成员对象单独参与后续 GC
- 加入
保留的簇根后续调用 MarkReferencedClustersAsReachable() 递归标记所有被引用的簇
===
在这个多线程任务返回后。合并线程结果。(目前我们先纠结GC流程,这里面应该还有一些多线程的写法值得探讨)
【合并线程结果】
FMarkClustersArrays MarkClustersResults;
GatherClustersState.Finish(MarkClustersResults);
Finish() 将所有线程的 KeepClusters 和 ClustersToDissolve 拼接到一个结果中(单线程时直接 Move,多线程时先 Reserve 再逐线程 Append)。
【溶解垃圾簇 + 传播可达性】
// 溶解垃圾簇,成员对象恢复为独立对象参与后续 GC
for (FUObjectItem* ObjectItem : MarkClustersResults.ClustersToDissolve)
{
// 检查该对象是否仍为集群根节点 —— 有可能是之前其中一个
// DissolveClusterAndMarkObjectsAsUnreachable calls already dissolved its cluster
if (ObjectItem->HasAnyFlags(EInternalObjectFlags::ClusterRoot))
{
GUObjectClusters.DissolveClusterAndMarkObjectsAsUnreachable(ObjectItem);
GUObjectClusters.SetClustersNeedDissolving();
}
}
// 递归传播可达性
for (FUObjectItem* ObjectItem : MarkClustersResults.KeepClusters)
{
checkSlow(ObjectItem->HasAnyFlags(EInternalObjectFlags::ClusterRoot));
// this thing is definitely not marked unreachable, so don't test it here
// Make sure all referenced clusters are marked as reachable too
MarkReferencedClustersAsReachable<EGCOptions::None>(ObjectItem->GetClusterIndex(), OutRootObjects);
}
挺复杂的。。。都是总体来说是这样。最后拿到了一个 InitialObjects
MarkClusteredObjectsAsReachable()
│
├─ [并行] 对每个簇分类:
│ ├─ 簇根 NOT Garbage + 有 RootFlags → KeepClusters(标记根+成员 Reachable)
│ ├─ 簇根 NOT Garbage + 无 RootFlags 但成员有 → KeepClusters
│ ├─ 簇根 NOT Garbage + 均无 RootFlags → 仅标记 Reachable,不加入 Keep
│ └─ 簇根 IS Garbage → ClustersToDissolve
│
├─ [主线程] 合并各线程的 KeepClusters / ClustersToDissolve
│
├─ [主线程] 溶解垃圾簇 → 成员变为独立对象参与后续 GC
│
└─ [主线程] 对保留簇调用 MarkReferencedClustersAsReachable()
├─ 递归标记被引用簇根为 Reachable
├─ 标记簇内可变对象为 Reachable
└─ 若发现被引用簇根已是 Garbage → 清空引用 + 标记当前簇需溶解
MarkObjectsAsUnreachable_根对象可达
MarkRootObjectsAsReachable(GatherOptions, KeepFlags, InitialObjects);
【显式根对象(GRoots)】
GRootsCritical.Lock();
TArray<int32> RootsArray(GRoots.Array());
GRootsCritical.Unlock();
ParallelFor(TEXT("GC.MarkRootObjectsAsReachable"), NumWorkerThreads, 1,
[&](int32 ThreadIndex)
{
while (ThreadState.Index <= ThreadState.LastIndex)
{
FUObjectItem* RootItem = &GUObjectArray.GetObjectItemArrayUnsafe()[RootsArray[ThreadState.Index++]];
UObject* Object = static_cast<UObject*>(RootItem->GetObject());
FGCFlags::FastMarkAsReachableInterlocked_ForGC(RootItem);
ThreadState.Payload.Add(Object); // 加入初始对象集
}
});
GRoots 存储通过 UObject::AddToRoot() 注册的对象索引。加锁拷贝数组后并行处理。
【带 KeepFlags 的对象】
if (KeepFlags != RF_NoFlags)
{
ParallelFor(TEXT("GC.SlowMarkObjectAsReachable"), NumWorkerThreads, 1,
[&](int32 ThreadIndex)
{
while (ThreadState.Index <= ThreadState.LastIndex)
{
FUObjectItem* ObjectItem = &GUObjectArray.GetObjectItemArrayUnsafe()[ThreadState.Index++];
UObject* Object = static_cast<UObject*>(ObjectItem->GetObject());
if (Object &&
!ObjectItem->HasAnyFlags(EInternalObjectFlags_RootFlags) && // 已在第一步处理
!(bWithGarbageElimination && ObjectItem->IsGarbage()) &&
Object->HasAnyFlags(KeepFlags))
{
FGCFlags::FastMarkAsReachableInterlocked_ForGC(ObjectItem);
ThreadState.Payload.Add(Object);
}
}
});
}
遍历所有 GC 对象(很慢)检查 EObjectFlags。
通常 KeepFlags 在编辑器中是 RF_Standalone
总结:
两步结果合并后,InitialObjects 包含:簇根对象 + 显式根对象 + KeepFlags 对象 = 完整的根集合。GGCStats.NumRoots 记录根集合大小。
PerformReachabilityAnalysisPass
前面一堆是StartReachabilityAnalysis里面的流程,拿到了 InitialObjects
void PerformReachabilityAnalysis(EObjectFlags KeepFlags, const EGCOptions Options)
{
if (!GReachabilityState.IsSuspended()) // 仅全新开始时
{
StartReachabilityAnalysis(KeepFlags, Options);
StartVerseGC(); // Verse VM 同步启动 GC
}
while (true)
{
PerformReachabilityAnalysisPass(Options); // 执行一轮遍历
if (GReachabilityState.IsSuspended()) // 时间片用完或 Verse 仍在标记
{
if (EnumHasAnyFlags(Options, EGCOptions::IncrementalReachability))
break; // 增量模式:挂起退出,下帧继续
// 非增量模式但被挂起(Verse GC 仍在标记)→ 继续循环
}
else if (Private::GReachableObjects.IsEmpty()
&& Private::GReachableClusters.IsEmpty()
/* && Verse structs empty */)
{
StopVerseGC();
break; // 所有工作完成,退出
}
// 否则:GC Write Barrier 或 Verse 发现了新的可达对象 → 继续循环
}
}
接下来主要是 while true 和 PerformReachabilityAnalysisPass 里面的流程了。
循环反复执行 Pass 直到没有更多新可达对象被发现。退出条件有两种:
- 增量挂起:时间片用完,
IsSuspended()返回 true,增量模式下break退出 - 正常完成:未挂起且所有并发队列为空(
GReachableObjects、GReachableClusters)
PerformReachabilityAnalysisPass 里面的流程
void PerformReachabilityAnalysisPass(const EGCOptions Options)
{
FContextPoolScope Pool;
FWorkerContext* Context;
if (!GReachabilityState.IsSuspended())
Context = Pool.AllocateFromPool(); // 全新 Pass:从池分配工作上下文
else
{
Context = GReachabilityState.GetContextArray()[0]; // 恢复:使用挂起的上下文
Context->bDidWork = false;
InitialObjects.Reset();
}
// [A] 处理 GC Write Barrier 新发现的对象
if (!Private::GReachableObjects.IsEmpty())
{
Private::GReachableObjects.PopAllAndEmpty(InitialObjects);
GGCStats.NumBarrierObjects += InitialObjects.Num();
}
else if (GReachabilityState.GetNumIterations() == 0 || ...)
{
Context->InitialNativeReferences = GetInitialReferences(Options); // 首次:获取 FGCObject 引用
}
// [B] 处理 GC Write Barrier 新发现的簇
if (!Private::GReachableClusters.IsEmpty())
{
TArray<FUObjectItem*> KeepClusterRefs;
Private::GReachableClusters.PopAllAndEmpty(KeepClusterRefs);
for (FUObjectItem* ObjectItem : KeepClusterRefs)
MarkReferencedClustersAsReachable<EGCOptions::None>(ObjectItem->GetClusterIndex(), InitialObjects);
}
// [C] 设置初始对象并执行引用遍历
Context->SetInitialObjectsUnpadded(InitialObjects);
PerformReachabilityAnalysisOnObjects(Context, Options);
// [D] 完成后更新统计
if (!GReachabilityState.IsSuspended())
{
GReachabilityState.ResetWorkers();
Stats.AddStats(Context->Stats);
Pool.ReturnToPool(Context);
}
}
FWorkerContext 是每个工作线程的私有上下文,包含:待处理对象队列(ObjectsToSerialize)、发现的弱引用列表、垃圾引用记录、GC 历史记录等。
======
// [A] 处理 GC Write Barrier 新发现的对象
// Private::GReachableObjects 是一个无锁并发栈。它的作用是:当增量 GC 正在进行标记时,游戏线程仍然在运行,
// 可能修改 UObject 的引用关系(比如 ObjectA->Ref = ObjectB)。这时游戏线程通过 Write Barrier(写屏障)
// 把 ObjectB 推入这个栈,告诉 GC:"我刚让一个对象变得可达了,你别漏掉它。"
if (!Private::GReachableObjects.IsEmpty())
{
// 一次性取出所有这些新发现的对象,放进 InitialObjects。这些对象将作为本轮 Pass 的起点,沿着它们的引用继续往下遍历。
Private::GReachableObjects.PopAllAndEmpty(InitialObjects);
GGCStats.NumBarrierObjects += InitialObjects.Num();
}
else if (GReachabilityState.GetNumIterations() == 0 || ...)
{
// 如果没有 Write Barrier 对象,且这是第一次迭代(就是整个 GC 刚开始的首轮 Pass)
// 那就去调用 GetInitialReferences(Options)。
// 等待之前 Task 中启动的 FGCObject 引用收集完成(对应 BeginInitialReferenceCollection 那一步)
Context->InitialNativeReferences = GetInitialReferences(Options); // 首次:获取 FGCObject 引用
}
这里else if 有点怪。是说首轮GC的 GReachableObjects 一定是空的,走下面的流程。
// [B] 处理 GC Write Barrier 新发现的簇
if (!Private::GReachableClusters.IsEmpty())
{
TArray<FUObjectItem*> KeepClusterRefs;
Private::GReachableClusters.PopAllAndEmpty(KeepClusterRefs);
for (FUObjectItem* ObjectItem : KeepClusterRefs)
MarkReferencedClustersAsReachable<EGCOptions::None>(ObjectItem->GetClusterIndex(), InitialObjects);
}
感觉是一样的,之前前一段的UObject这里变成了“簇”
// Private::GReachableObjects 是一个无锁并发栈。它的作用是:当增量 GC 正在进行标记时,游戏线程仍然在运行,
// 可能修改 UObject 的引用关系(比如 ObjectA->Ref = ObjectB)。这时游戏线程通过 Write Barrier(写屏障)
// 把 ObjectB 推入这个栈,告诉 GC:"我刚让一个对象变得可达了,你别漏掉它。"
======
// [C] 设置初始对象并执行引用遍历
Context->SetInitialObjectsUnpadded(InitialObjects);
PerformReachabilityAnalysisOnObjects(Context, Options);
SetInitialObjectsUnpadded
【cache miss 问题】
CPU 访问内存时,真正读写的不是主存(RAM),而是缓存(L1/L2/L3 Cache)。缓存比主存快几十到几百倍。当 CPU 需要的数据不在缓存中时,就发生 cache miss,CPU 必须等待数据从主存加载到缓存——这个等待通常是 100~300 个时钟周期,期间 CPU 基本在空转。
GC 遍历对象引用时,对每个对象都需要访问:
UObject* Obj → Obj->GetClass() → Class 内存(另一个地址)
→ Class->ReferenceSchema → Schema 内存(又一个地址)
→ Obj->GetOuter() → Outer 对象内存(又一个地址)
这些数据分散在堆内存的不同位置。UE 的 GC 可能有几十万个对象要遍历,每个对象的 Class、Schema、Outer 几乎不可能恰好在缓存里。所以每处理一个对象就要等 3~4 次 cache miss,效率很低。
预取(Prefetch)怎么解决
CPU 有一条特殊指令 prefetch:提前告诉 CPU "我一会要用这个地址的数据,你现在就开始从主存搬到缓存"。CPU 收到指令后在后台搬运,不会阻塞当前执行。
FPrefetchingObjectIterator 的策略是——处理当前对象时,提前告诉 CPU 去准备后面第 2、6、16 个对象的数据:
// class FPrefetchingObjectIterator
void Advance()
{
// 我正在处理 It[0],但同时:
Prefetch(It[2]->GetClass()->ReferenceSchema); // 预取第 2 个的 Schema 数据
PrefetchOuter(It[6]); // 预取第 6 个的 Outer 对象
Prefetch(It[6]->GetClass(), offsetof(UClass, ReferenceSchema)); // 预取第 6 个的 Class→Schema 指针
PrefetchClass(It[16]); // 预取第 16 个的 Class 对象
++It;
}
回到 SetInitialObjectsUnpadded
- 填充尾部(Pad)
- GC 遍历时使用
FPrefetchingObjectIterator,会提前 16 个对象预取内存(预取 Class、Schema、Outer 等),避免 cache miss 造成的延迟: - 这里填充是为了别越界
- GC 遍历时使用
- 存入 Context->InitialObjects
=======
PerformReachabilityAnalysisOnObjects(Context, Options);
这里根据 EGCOptions 的枚举,重定向到不同的函数,当然是同一个函数,只是基于模板填了不同的参数
virtual void PerformReachabilityAnalysisOnObjects(FWorkerContext* Context, EGCOptions Options) override
{
(this->*ReachabilityAnalysisFunctions[GetGCFunctionIndex(Options)])(*Context);
}
然后到了 PerformReachabilityAnalysisOnObjectsInternal 这里。
template <EGCOptions Options>
void PerformReachabilityAnalysisOnObjectsInternal(FWorkerContext& Context)
{
#if !UE_BUILD_SHIPPING
// 调试模式:使用 TDebugReachabilityProcessor 记录引用历史/追踪垃圾
TDebugReachabilityProcessor<Options> DebugProcessor;
if (DebugProcessor.IsForceEnabled() | DebugProcessor.TracksHistory() | ...)
{
CollectReferencesForGC<TDebugReachabilityCollector<Options>>(DebugProcessor, Context);
return;
}
#endif
// 正常路径
TReachabilityProcessor<Options> Processor;
CollectReferencesForGC<TReachabilityCollector<Options>>(Processor, Context);
}
TReachabilityProcessor 处理每个发现的引用:若目标对象是 MaybeUnreachable → 原子地标记为 Reachable → 加入工作队列继续遍历。
TReachabilityCollector 是 FReferenceCollector 的模板特化,配合 TFastReferenceCollector 使用预计算的 Schema(Token Stream)高效遍历 UPROPERTY 引用。
Schema是什么?
Schema 是 UE 为每个 UClass 预编译的一张"引用地图"——它记录了这个类的实例中,哪些字节偏移处存放着 UObject 引用,以及引用的类型(单指针、数组、结构体数组等)。在UClass 初始化的时候构建。struct FMemberPacked 结构叫这个,回头看看。
没有这个,只能 AddReferencedObjects 里面告诉GC。
CollectReferencesForGC
单线程和多线程的遍历了
template<class CollectorType, class ProcessorType>
void CollectReferencesForGC(ProcessorType& Processor, FWorkerContext& Context)
{
using FastReferenceCollector = TFastReferenceCollector<ProcessorType, CollectorType>;
if constexpr (IsParallel(ProcessorType::Options))
{
ProcessAsync(/*...*/, &Processor, Context); // 多线程路径
}
else
{
GReachabilityState.SetupWorkers(1);
FastReferenceCollector(Processor).ProcessObjectArray(Context); // 单线程路径
GReachabilityState.CheckIfAnyContextIsSuspended();
}
}
TFastReferenceCollector::ProcessObjectArray
是引用遍历的最内层核心,执行 BFS:
- 从
Context.ObjectsToSerialize队列取出对象(使用FPrefetchingObjectIterator提前 16 个对象预取) - 通过 UClass 的 GC Token Stream(
FSchemaView)遍历所有UPROPERTY声明的引用——这是编译期预计算的引用偏移/类型布局,无虚函数开销(虚函数开销指子类需要 AddReferencedObjects) - 对有自定义引用的类调用
AddReferencedObjects()——Schema 无法捕获的引用模式 - 每个被引用对象若仍是
MaybeUnreachable→ 原子标记为Reachable→ 入队继续遍历 - 增量模式下定期调用
IsTimeLimitExceeded(),超时则设置bIsSuspended = true退出
ProcessAsync — 多线程
void ProcessAsync(void (*ProcessSync)(void*, FWorkerContext&), void* Processor, FWorkerContext& InContext)
{
// 将初始对象均匀分配到 N 个 `FWorkerContext`,每个 Context 有独立的工作队列
TArrayView<FWorkerContext*> Contexts = InitializeAsyncProcessingContexts(InContext);
// 协调工作窃取。当某线程的队列为空时,可从其他线程的队列窃取工作
TSharedRef<FWorkCoordinator> WorkCoordinator = MakeShared<FWorkCoordinator>(Contexts, NumWorkerThreads);
// 启动 N-1 个后台 Task
for (int32 Idx = 1; Idx < GReachabilityState.GetNumWorkers(); ++Idx)
{
AllWorkerTasks.Add(Tasks::Launch(TEXT("CollectReferences"), [=]()
{
if (FWorkerContext* Context = WorkCoordinator->TryStartWorking(Idx))
ProcessSync(Processor, *Context);
}));
}
// 主线程自己也参与工作,自己也作为 Worker 0
if (FWorkerContext* Context = WorkCoordinator->TryStartWorking(0))
ProcessSync(Processor, *Context);
// 等待所有工作完成(可窃取其他线程的工作)《= 阻塞
WorkCoordinator->SpinUntilAllStopped();
// 处理挂起的上下文(增量模式可能有未完成的工作)
for (FWorkerContext* Context : Contexts)
{
if (Context->ObjectsToSerialize.HasWork() && !Context->bDidWork)
{
if (GReachabilityState.IsTimeLimitExceeded())
Context->bIsSuspended = true; // 时间用完,标记挂起
else
{
// 罕见情况:工作被丢弃(增量模式不支持上下文窃取),重新处理
ProcessSync(Processor, *Context);
}
}
}
}
增量超时处理:
时间片用完时,未完成工作的上下文标记为挂起(bIsSuspended = true),下次 PerformReachabilityAnalysisPass 从这些上下文恢复
IsTimeLimitExceeded 增量的判断接口。
这里 ProcessSync 是输入参数进来的
[](void* P, FWorkerContext& C) { FastReferenceCollector(*reinterpret_cast<ProcessorType*>(P)).ProcessObjectArray(C); }
这个多线程BFS是怎么回事?
。。。下一篇把,截断
标记总结
UE 的可达性分析(PerformReachabilityAnalysis)是一个典型的三色标记 GC 变体,核心思路是"从根出发,沿引用链传播可达性,遍历结束后仍不可达的对象即为垃圾"。
具体分为两大阶段:
首先通过 O(1) 的标志位语义交换将所有对象瞬间标记为"可能不可达",
然后从根集合(显式 AddToRoot 对象、带根标志的原生/异步加载对象、编辑器下带 RF_Standalone 的资产、以及簇根)出发,
利用每个 UClass 预计算的 Schema 偏移表高效遍历实例中的所有 UObject 引用,将可达对象重新标记为"可达"。
遍历采用多线程工作窃取模型(最多 16 线程),配合多级预取策略(提前 2/6/16 个对象分层预取 Schema、Outer、Class 数据)隐藏内存延迟(避免内存cache miss 那一个操作)。对于 Schema 无法覆盖的自定义引用模式,回退到 AddReferencedObjects 虚函数慢路径(一般也不用这个吧?就是子类重写这个函数,然后告诉UE的系统);非 UObject 的 C++ 对象则通过 FGCObject → UGCObjectReferencer 桥接纳入遍历。
整个过程支持增量模式——按时间片挂起/恢复,避免单帧卡顿。当工作列表排空且无新增可达对象时,分析结束,仍带"可能不可达"标志的对象进入后续的清除和销毁流程。