一个中等规模的 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()非空)。 - 不要手动把对象加进官方管理的簇——编辑器自动簇化的范围是精心设计的,手动干预容易打破拆分时机。