技术博客

GC Cluster:UObjectCluster 如何减少标记开销

第 17 / 100 篇 · 2025-09-05 · ue5 · GCCluster性能优化源码分析

一个中等规模的 UE 关卡,UObject 数量轻松到几十万。GC 的可达性分析要遍历每个对象的 FProperty 链,逐个检查 UObject 指针属性——对象越多,标记时间越长。UObjectCluster 是引擎对这个问题的答案:把一坨强耦合的对象合成一个"簇",GC 只要确认簇根可达,整簇当可达处理,簇内对象之间的引用边全部跳过。下面基于 UE5.3(Engine/Source/Runtime/CoreUObject/Private/UObject/GarbageCollection.cpp)把机制拆开。

凝聚:把引用图缩成超级节点

从图论角度看,cluster 化是对引用图做凝聚(condensation)。原始引用图里,每个对象是一个节点,每个 UObject 指针属性是一条边。关卡场景里 Actor 互相引用(门引用开关、AI 引用巡逻点、 trigger 引用目标),这些边占了边集的大头。凝聚之后,一坨互相引用的对象缩成一个超级节点,GC 要遍历的边数从"对象间引用数"降到"簇间引用数"。

举例:一个关卡有 5000 个 Actor,每个 Actor 平均 3 个组件、2 个引用其他 Actor 的属性。原始图的边数大约是 5000×(3+2) = 25000。如果把这 5000 个 Actor 和它们的组件簇化,GC 只需要处理簇根(Level)的可达性,25000 条内部边全部跳过——标记时间从"遍历 25000 条边"降到"确认 1 个节点可达"。

代价是粒度丢失:簇内对象"同生共死"。一个对象被簇外引用,整簇都活;簇根不可达,整簇一起回收——即使簇内某些对象理论上已经不可达。这是拿回收精度换标记速度。

创建条件:谁能进簇

不是所有对象都能簇化。UObject::CanBeInCluster()(UObject/UObject.cpp)是准入检查,默认返回 false。返回 true 的主要是:

  • AActor 及其组件(通过 UActorComponent 的覆盖)
  • ULevel(作为簇根)
  • UParticleSystem 等资源对象

Actor 能进簇的原因是它的生命周期由 Level 管理:Level 卸载时 Actor 一起销毁,不太存在"Actor 单独活着但 Level 死了"的场景。这正是簇化安全的前提——簇内对象的生命周期应该和簇根强绑定。

创建簇的入口是 UObject::CreateCluster()(UObject/UObjectGlobals.cpp 的 CreateGarbageCollectionUObjectCluster)。编辑器在关卡加载后自动对 Level 调用,运行时可以通过 gc.CreateClusters CVar 控制。

簇的内部结构

UObjectCluster 本身是个 UObject,内部维护:

  • RootObject:簇根,通常是 Level。
  • Objects:簇内所有对象的数组。
  • ReferencedClusters:本簇引用的其他簇。
  • ObjectsThatNeedInstanceSubgraph:需要处理子对象图的特殊对象。

GC 标记时,TFastReferenceCollector::ProcessObjectArray 遇到簇内对象会直接跳过——因为簇根可达时整簇已标记,不需要逐个遍历。源码里的判断大致是:

// 简化:如果对象在簇里且簇根已可达,跳过
if (Object->GetCluster() != nullptr && Object->GetCluster()->RootObject->IsReachable())
{
    continue;  // 簇内引用边不遍历
}

关卡流送与簇的拆分

簇化最大的坑在关卡流送。Streaming Level 卸载时,它里面的 Actor 应该被 GC 回收。但如果这些 Actor 在簇里,簇根(PersistentLevel 或另一个 StreamingLevel)还可达,整簇就不会被回收——卸载的 Level 内存释放不干净。

UE 的解法是 DissolveCluster:StreamingLevel 卸载前,把它的 Actor 从簇里移除(UObject::RemoveFromCluster),让这些对象恢复"独立可达性"状态,之后正常走 GC。这个逻辑在 ULevel::ReleaseResources / UWorld::RemoveFromWorld 的路径里。

如果项目里有自定义的簇化逻辑(手动 CreateCluster),流送时必须配套做拆分——否则会出现"关卡卸载了但内存没降"的泄漏。排查手段是 obj list class=ULevel 看卸载的 Level 是否还在,再用 obj refs 查谁还引用着它。

收益与开销的量化

官方数据(UE5.3 文档):

  • 簇化后 GC 标记时间减少 30%-70%,取决于场景的引用密度。
  • 簇本身占内存:每个簇一个 UObject + 对象数组,几千个对象的簇大约几十 KB——可以忽略。
  • 簇化操作本身有开销:CreateCluster 要遍历子对象图建数组,关卡加载时这步可能花几毫秒到几十毫秒。

判断是否该簇化的经验法则:对象间引用边多、生命周期和簇根强绑定、不需要单独回收。UI 控件、临时计算对象不适合簇化;关卡静态几何、场景装饰 Actor 适合。

手动簇化示例

引擎只对 Level 自动簇化,自定义系统可以手动建簇。典型的做法是以一个管理器对象为根:

// 以 UMyManager 为根,把一批生命周期一致的对象簇化
void UMyManager::BuildCluster(const TArray<UObject*>& Members)
{
    // 要求所有成员 CanBeInCluster() 为 true,或先 OnClusterMarkAsPendingClusterRoot
    UObject* ClusterRoot = this;
    for (UObject* Obj : Members)
    {
        Obj->AddToCluster(ClusterRoot);  // UObject/UObjectGlobals.cpp
    }
}

AddToCluster 会把对象挂到根的簇上(根没有簇时自动创建)。注意几点:

  • 簇根自己必须有办法被 GC 视为可达(比如被 GameInstance 的 UPROPERTY 持有),否则整簇被回收。
  • 成员对象的 CanBeInCluster() 默认 false,需要在自己的类里重写返回 true。
  • 簇内对象之间的引用(UPROPERTY 互指)是安全的;簇内对象指向簇外的引用会让 GC 继续追踪,属于正常边。

stat gc 输出解读

stat gc 的关键计数里和簇相关的有:

  • Clusters:当前簇总数。
  • ClusteredObjects:簇内对象总数。这个数占 UObject 总数的比例越高,标记阶段的收益越大。
  • Mark 阶段耗时如果随着 ClusteredObjects 比例上升而没有下降,说明簇间引用边太多——常见于大量 Actor 引用 PlayerController 或全局管理器,这些跨簇边没法凝聚。

实战结论

  • 大项目 GC 卡顿先看 stat gc:如果 Mark 阶段占大头,考虑给高频访问的区域手动簇化(自定义 CreateCluster)。
  • 流送关卡内存泄漏,优先怀疑簇没拆分——obj list class=ULevel 确认 Level 是否还在。
  • 簇内对象被簇外引用会导致整簇存活,排查"该死不死"的对象时注意检查它是否在簇里(GetCluster() 非空)。
  • 不要手动把对象加进官方管理的簇——编辑器自动簇化的范围是精心设计的,手动干预容易打破拆分时机。

GC Cluster:凝聚引用图,减少标记边数 原始图:5000 Actor,25000 条引用边 ... 还有更多节点和边 凝聚 凝聚后:1 个超级节点,内部边跳过 UObjectCluster Root=Level 含 5000 个对象 内部边不遍历 GC 标记:簇根可达 → 整簇标记为可达,跳过 25000 条内部边。 代价:簇内同生共死,一个被引用全簇都活;流送时必须 DissolveCluster。 收益:标记时间减少 30%-70%,取决于场景引用密度。