技术博客

UObject 反射与 GC:从 UClass 到增量可达性分析

第 11 / 100 篇 · 2025-07-06 · ue5 · UObject反射GC源码分析

读 UE 源码绕不开两件事:对象怎么被"认识"(反射),对象怎么被"回收"(GC)。这两件事在 UE 里是同一条链路上的——反射产出的元数据,正是 GC 做可达性分析的地图。下面基于 UE5.3 源码(Engine/Source/Runtime/CoreUObject)把这条链路走一遍。

为什么 UE 必须自己造反射

C++ 自带的 RTTI 只能回答"这个指针实际是什么类型",回答不了"这个类的第三个成员在内存里偏移多少、是不是指针、要不要参与序列化"。而 UE 需要的是一份能在运行时遍历任意对象内存布局的元数据:序列化、GC、编辑器属性面板、蓝图 VM、网络复制(FRepLayout 同样靠它遍历属性)五个系统共用同一份描述。如果每个系统各自维护一份类型信息,蓝图改一个变量要同步五个地方,这在工程上不可维护。所以反射在 UE 里不是便利设施,而是对象模型的地基——UPROPERTY() 宏本质上是在向这份公共元数据登记,登记之后所有下游系统自动获得感知能力。

这也解释了新手常困惑的一点:为什么裸 UObject* 成员"编译没问题但运行时会丢"。因为 C++ 编译器只负责内存布局,不负责把布局告诉引擎;不写 UPROPERTY(),这个成员对反射系统就是不可见的,序列化跳过它,GC 也看不到它持有的引用。

反射数据是怎么来的

写下一个 UCLASS() 时,UHT(Unreal Header Tool)会解析头文件,生成 .generated.h。展开后核心是这个样子:

// MyActor.generated.h 展开后的形态(简化)
static UClass* GetPrivateStaticClass();
virtual UClass* GetClass() const override { return GetPrivateStaticClass(); }

完整的生成物比这两个函数多得多:UHT 还会产出类的注册函数(Z_Construct_UClass_... 形态),模块启动时执行,在函数里逐个 new 出 FProperty 并挂到类的属性链上。也就是说反射数据不是运行时解析头文件得到的,而是编译期被翻译成了 C++ 构造代码,运行时只负责执行。这个设计把解析成本挪到了编译期,运行时建类的开销就是一串函数调用。

运行时 StaticClass() 拿到的 UClass,就是该类型的全部元信息入口。UClass 继承链是 UField → UStruct → UClass,UStruct 上挂着 ChildProperties 链表,每个节点是一个 FProperty。FProperty 记录了偏移量、大小、序列化标志,所以引擎能在不看 C++ 类型的情况下遍历任意对象的内存布局。

这里值得展开的是 5.x 的一次拆分:4.x 时代每个属性是一个 UProperty,它本身就是个 UObject。一个普通类几十个属性,全项目就是几十万个属性 UObject,每轮 GC 都要遍历它们,而且它们散在堆里,缓存命中率很差。5.0 把属性拆成纯数据的 FProperty(不参与 GC、由 UStruct 连续持有)和只保留 UObject 外壳语义的 UFProperty。这是典型的"用 API 破坏换运行时收益"——插件迁移时满屏的编译错误就是这次拆分的账单,换来的是 GC 标记阶段扫描属性链时的内存局部性。

实测验证反射数据最直接的办法是在控制台敲 obj dump <对象名>,它会走 FProperty 链把每个 UPROPERTY 的当前值打出来——整个输出过程引擎完全没碰 C++ 的成员访问语法,全靠偏移量。

对象表:FUObjectArray

所有 UObject 都登记在全局的 GUObjectArray(类型 FUObjectArray,见 UObject/UObjectArray.h)里。它是个分块的数组,每块 64K 个 FUObjectItem,对象分配时拿一个 index,返回的指针直接指向块内内存。

选"分块定长数组"而不是链表或哈希表,是个明确的权衡:index 编码成 chunk 号 + 块内偏移,index → 对象指针是两次数组下标,没有哈希冲突、没有链表遍历,分配一个 UObject 的开销接近数组 push_back。代价是表本身常驻内存——每块 64K 个 item,每个 item 存对象指针加 flag、serial number 等记账字段,空 slot 也占着。UE 赌的是"对象总数有界且访问极频繁",这个场景下 O(1) 直查完胜省内存的结构。

5.x 里这个结构有个关键变化:UObjectBase 里不再存原始指针相关的 flag 位布局,而是通过 UObjectHandle 间接访问(TObjectPtr 的底层就是它)。这带来两个实际影响:

  • TObjectPtr 解引用多了一次 handle 查表,换来的是编辑器下可以追踪访问、做懒加载;
  • index + serial number 的组合让过期检测不依赖对象本身内存是否有效,FWeakObjectPtr 就是靠这个判断悬空的。

serial number 的机制值得多说一句:对象销毁后它占的 slot 会被后来的新对象复用,但 item 里的 serial number 单调递增。弱引用存的是 (index, serial) 二元组,判定时查 index 找到 item、再比 serial——slot 复用不会造成误判,旧引用的 serial 对不上就是过期。这就是为什么 UE 的弱引用不需要任何"对象销毁时通知引用方"的回调机制,判定是纯查询式的,代价只是每个对象几字节记账。

GC 的完整流程

GC 入口在 UObject/GarbageCollection.cpp 的 CollectGarbageInternal。一轮 GC 大体是:

  1. 清掉上一轮留下的 RF_Unreachable 标记;
  2. 可达性分析(Reachability Analysis):从 root set 出发遍历引用图,把能摸到的对象标记为可达;
  3. 不可达对象打上 EInternalObjectFlags::Garbage;
  4. 对标记对象调用 ConditionalBeginDestroy,走 FinishDestroy 真正释放。

可达性分析怎么做

root set 由几部分构成:显式 AddToRoot 的对象、RF_MarkAsRootSet 标记的、GUObjectClusters 里的 cluster,以及 DisregardForGC 集合。从 root 出发,引擎对每个对象遍历它的 FProperty 链,遇到 UObject* 类型的属性就递归下去。这套遍历代码在 TFastReferenceCollector::ProcessObjectArray 里。

这套算法的理论原型是教科书里的三色标记(tri-color marking):白 = 尚未发现,灰 = 已发现但子引用没扫完,黑 = 扫描完成。root 先染灰,然后循环取灰对象、沿属性链把新发现的子对象染灰、自己染黑,直到灰集清空——此时剩下的白色对象就是垃圾。UE 的实现没有显式存三种颜色,而是用 flag 位和待处理数组编码同样的状态机,但状态转移一模一样。

这里就看出反射的价值了:GC 不需要知道你的类长什么样,只需要 FProperty 描述"偏移 +0x38 处有一个指向 UObject 的指针"。如果成员是裸 UObject* 而没加 UPROPERTY(),GC 会把它当垃圾收掉——这是新手最常踩的坑,崩溃信息往往是随机位置的访问违例,因为对象在任意一次 GC 后被释放了。

UPROPERTY()  // 不加这行,GC 随时可能把 _Mesh 收走
UMeshComponent* _Mesh;

增量式标记

全量标记一次要几十毫秒,直接卡帧。所以 5.x 默认开增量 GC:每帧只标记一部分对象,通过 GCMaxObjectsPerTick(实际由 TimeLimit 控制,默认每帧几毫秒的预算)切分工作量。三色模型里这相当于把"取灰对象扫描"的循环切成多帧执行。

切片执行带来一个正确性问题:标记进行到一半,应用代码改了引用关系怎么办?设想一个已染黑的对象 A,标记间隙里被赋了一个新的引用指向白色对象 B——如果什么都不做,B 永远不会被发现,最终作为"不可达"被回收,但它明明可达。这就是增量 GC 理论里的经典漏标场景,教科书结论是必须维持黑对象不直接指向白对象这条不变量。

UE 的解法是写屏障:UPROPERTY/TObjectPtr 的赋值路径上挂了钩子,检测到引用关系变化时调 MarkUObjectAsDirty 把相关对象重新放回待处理集合——用三色语言说就是把可能漏标的对象重新染灰。这等价于 Dijkstra 式插入屏障的变体:用每次属性赋值多一次 flag 检查的代价,换标记过程可以安全地跨帧切片。裸 UObject* 成员连这个屏障都挂不上去,这是它比"被误回收"更隐蔽的第二宗罪。

Cluster 优化

Level 这类大对象下面挂着成千上万个 Actor,逐个标记太贵。UObjectCluster(CreateGarbageCollectionUObjectCluster)把一组对象合成一个 cluster,GC 只要确认 cluster 根部可达,就把整簇当可达处理,内部对象之间的引用边全部跳过。

从图论上看,cluster 化就是对引用图做凝聚(condensation):把强耦合的一坨对象缩成一个超级节点,GC 要遍历的边数从"对象间引用数"降到"簇间引用数"。关卡场景里 Actor 互相引用(门引用开关、AI 引用巡逻点)占了边的大头,簇化之后这些边全部内部化。代价是簇内对象"同生共死":一个被引用,全簇都活——凝聚图丢失了内部粒度,这是拿回收精度换标记速度。关卡流送时要特别注意 streaming level 卸载后 cluster 的拆分(DissolveCluster),否则会出现卸载不干净的内存残留。

增量三色标记与写屏障:防止黑对象漏标白对象 Root Set A 黑 已扫描完成 B 灰 待扫描子引用 C 白 未发现 → 若保持白色则回收 标记间隙:代码执行 A.ref = C(危险!黑 → 白) 写屏障(MarkUObjectAsDirty):UPROPERTY / TObjectPtr 赋值时触发, 把 C 重新染灰放回待处理集合,维持"黑不指白"不变量。 代价:每次属性赋值多一次检查 裸 UObject* 挂不上屏障 收益:标记可跨帧切片(gc.TimeLimit 控制每帧预算) 全量几十毫秒的卡顿被摊平成每帧几毫秒

异步 GC 与帧时间预算

5.1 之后 GC 的可达性分析可以放到 worker 线程(gc.AsyncReachability 相关开关),GT 只在帧边界做同步点。但注意异步标记期间创建对象的开销会上升——新对象要同时进标记集合的快照逻辑,高频 spawn 的系统(弹幕、粒子宿主 actor)在异步 GC 下反而要测一遍。帧预算由 gc.TimeLimit 控制,默认偏保守;大地图项目把预算放宽、让单帧多标记一些,往往比频繁触发全量 GC 平滑。

几个实战结论

  • 排查"对象莫名变空"优先查 UPROPERTY 漏标,其次查是不是持有了 DisregardForGC 之外没有 root 保护的裸指针。
  • obj refs name=X 能看到谁还引用着对象,是定位泄漏(该死不死)的第一手段。
  • 大项目 GC 卡顿先看 stat gc:如果 Mark 阶段长,考虑给高频访问的对象簇化;如果 Purge 长,说明单帧销毁对象太多,考虑对象池。
  • 不要在 BeginDestroy 里创建新 UObject,此时 GC 上下文是受限的,源码里有明确的 ensure 拦这个。

反射和 GC 是 UE 对象模型的地基,后面讲序列化、网络复制(FRepLayout 同样靠 FProperty 遍历)时还会反复回到这条链路上。