UObject / GC / 反射
属性复制凭什么能自动遍历一个类的所有
UPROPERTY?靠的是 UObject 的反射系统。GC 又凭什么知道哪个对象还活着?靠的是同一套反射生成的引用图。这两件事是整个复制栈的底座——搞懂它们,复制、序列化、内存管理就都通了。
一句话结论
UHT 把宏标记的类成员生成反射数据(FProperty 链表),复制栈靠它遍历属性,GC 靠它做可达性分析——反射是复制与 GC 的共同底座。
场景问题
打个比方:
UPROPERTY像在物业登记处备案。UCLASS相当于一份户型图,写清这套房有哪些房间、哪些家具位;CDO是样板间,所有新房先按样板间装一遍,再动个性化改造。你屋里放的东西,只有备案过的(UPROPERTY)物业才认:搬家(GC 清理)时物业照备案单挨个核对——只要有一张备案单指着它,这东西就不许扔;东西真被扔了,物业还会顺手把你的备案单划掉(自动置nullptr)。而你私藏没备案的(裸指针
AActor*),物业当它不存在:别人搬走时不通知你,你手里那张纸还写着「东西在 3 号房」——去了才发现房子拆了。这就是悬垂指针。复制栈也是靠这张备案单干活的:它不认识你的业务字段,只会问物业「这个类备案了哪些带
Replicated标记的东西」,然后逐条比对上次的值。没备案的字段,复制栈根本看不见。
「你在 Actor 里存了一个指向另一个 Actor 的裸指针 AActor* Target;,那个 Actor 被销毁后,你的指针会怎样?」——如果不是 UPROPERTY,它会变成悬垂指针,GC 不知道你在引用它、销毁时也不会帮你置空,下次解引用直接崩。这个经典坑背后,是「GC 只认 UPROPERTY 引用图」的机制。要答清这类问题,得先理解 UObject 反射与 GC 怎么协作。
实现方案
1. UObject 与 UClass / CDO
UObject 是所有引擎对象的基类,头部携带元信息:所属 UClass、名字、Outer(外层对象)、flags。
UClass 是「类的运行时描述」——每个 UCLASS() 都有一个 UClass 实例,记录这个类有哪些属性、哪些函数、父类是谁。它还持有一个 CDO(Class Default Object,类默认对象):一个该类的模板实例,存所有属性的默认值。new 一个 Actor 时,引擎先拷贝 CDO 再跑构造,DOREPLIFETIME 里比较「是否等于默认值」用的也是 CDO。
2. UHT 与反射生成
UHT(Unreal Header Tool) 在编译前扫描头文件里的宏,生成 .generated.h/.gen.cpp:
UCLASS()
class AMyActor : public AActor {
GENERATED_BODY()
public:
UPROPERTY(Replicated) // ← UHT 看到这个宏
int32 Health;
UFUNCTION(Server, Reliable) // ← 和这个宏
void ServerDoThing();
};
UHT 把每个 UPROPERTY 生成一个 FProperty 对象(FIntProperty/FObjectProperty/FStructProperty…),串成链表挂在 UClass 上。运行时想知道「这个类有哪些属性、每个属性在内存的偏移和类型」,遍历这个 FProperty 链表即可——不需要硬编码。
反射为什么是复制栈的前提:属性复制的核心 RepLayout 就是遍历 FProperty 链表,把标了 Replicated 的属性收集成一张扁平表,按偏移读值、做差分。没有反射,引擎无法「自动」知道该复制哪些字段。详见 属性复制。
3. GC:标记—清除
UObject 的内存由引擎 GC 管理(非 C++ delete)。算法是标记—清除(mark & sweep),周期性运行:
- Root Set(根集):GC 的起点。
AddToRoot()加入的对象、以及被GUObjectArray中标了 root flag 的对象永不回收。 - 引用图:GC 只沿
UPROPERTY声明的引用遍历。一个对象只要没有任何UPROPERTY从根可达地指向它,就是垃圾。 - Actor 稍特殊:由所属
UWorld/ULevel持有,Destroy()后从关卡移除、下次 GC 回收。
4. 关键坑:非 UPROPERTY 裸指针会悬垂
AActor* RawTarget; // ✗ GC 看不见:不参与可达性,
// Target 被 GC 后不置空 → 悬垂崩溃
UPROPERTY() AActor* SafeTarget; // ✓ GC 可见:Target 被销毁时自动置 nullptr
UPROPERTY 指针有两个作用:① 让 GC 知道你在引用(保活/纳入图);② 目标被销毁时引擎自动把它置 nullptr(不会悬垂)。这是 UE 内存安全的核心约定。
5. GC 性能:Cluster 与增量清除
朴素 mark & sweep 要遍历所有 UObject,对象一多(几十万级)会卡顿。UE 的优化:
- Cluster(聚簇 GC):把一批生命周期一致的对象(如一个资产及其依赖)聚成一个 cluster,GC 时整簇当一个节点处理,大幅减少遍历节点数。
- 增量式清除
IncrementalPurge:清除阶段分摊到多帧,避免单帧长时间卡顿。 - 并行标记:标记阶段可多线程并行(这不违反「网络在 GameThread」——GC 标记不碰复制逻辑),但 GC 触发点会 STW(stop the world)一小段。
卡顿量级与规避:大世界一次 GC 可能几毫秒到几十毫秒(取决于对象数)。规避手段:减少 UObject 数量(能用 USTRUCT/普通 C++ 类的别用 UObject)、用对象池复用而非频繁 new/destroy、拉长 GC 间隔(gc.TimeBetweenPurgingPendingKillObjects)。这与 Redis/Go 的 GC 权衡 是同一类问题——用空间/复用换 STW 时间。
6. 指针语义对比
选对指针类型,直接决定「会不会保活、会不会悬垂、GC 认不认」:
| 类型 | GC 参与 | 目标销毁后 | 适用 |
|---|---|---|---|
裸指针 T* | ✗ 不参与 | 悬垂(不置空) | 仅临时局部、确定不跨帧 |
UPROPERTY() T* | ✓ 强引用,保活 | 自动置 nullptr | 需要持有对象 |
TObjectPtr<T> | ✓ 强引用(UE5) | 自动置 nullptr | UE5 推荐替代裸 UPROPERTY 指针,支持惰性加载 |
TWeakObjectPtr<T> | ✗ 弱引用,不保活 | IsValid() 返回 false | 缓存、观察者,不想阻止对象回收 |
TSoftObjectPtr<T> | ✗ 软引用(路径) | 按需异步加载 | 引用尚未加载的资产 |
TSharedPtr<T> | 引用计数(非 GC) | 计数归零析构 | 非 UObject 的普通 C++ 对象 |
UE4/UE5 差异
TObjectPtr 是 UE5 引入的,UE4 里成员指针直接用 UPROPERTY() T*。UE5 官方建议新代码用 TObjectPtr 替代裸指针成员(编辑器下有访问追踪、支持增量 GC 优化),打包后与裸指针等价、零开销。
一句话记住四种指针的差别(就问两件事:保不保活、目标死了我知不知道):
| 一句话 | 类比 | |
|---|---|---|
裸指针 T* | 不保活,目标死了也不告诉你 | 手抄的地址纸条:房子拆了纸上还写着 |
UPROPERTY() T* / TObjectPtr<T> | 保活,目标死了自动置空 | 物业备案:备案在就不许拆,拆了帮你划掉 |
TWeakObjectPtr<T> | 不保活,但目标死了 IsValid() 会说实话 | 备案但注明「别为我留着」:随时可查还在不在 |
TSoftObjectPtr<T> | 只存路径,用时才加载 | 只记地址不占房:要去了再导航过去 |
选择口径:成员变量默认 TObjectPtr;只是缓存别人的东西、不想拦着回收用 TWeakObjectPtr;引用还没加载的资产用 TSoftObjectPtr;非 UObject 的普通 C++ 对象才轮到 TSharedPtr。
7. 序列化
反射还驱动序列化:UObject::Serialize 遍历 FProperty 链表把属性读写进 FArchive(存档、网络、复制的影子拷贝都走它)。网络序列化是其中一条特化路径——NetSerialize 允许属性自定义压缩写法,详见 属性复制。
为什么这么做
反射换来自动化:手写「复制哪些字段、序列化哪些字段」既繁琐又易漏。UHT 在编译期一次性生成元数据,运行时复制/存档/编辑器面板全都自动遍历,一处声明处处可用。
GC 换来内存安全:游戏里对象引用关系复杂(Actor 互相引用、组件挂载、委托绑定),手动 delete 极易 use-after-free。GC + UPROPERTY 自动置空把这类崩溃从根上消除,代价是周期性 STW,用 Cluster/增量清除把代价摊薄。
为什么别的选择不行
- 纯手动
new/delete:复杂引用关系下必然出现悬垂/泄漏;游戏对象生命周期交织,人管不过来。 - 纯引用计数(如
shared_ptr):解决不了循环引用(A 引用 B、B 引用 A 就永不释放),而游戏里循环引用极常见。mark & sweep 天然处理循环引用。 - 不用反射、硬编码复制列表:每加一个字段都要改多处,且无法支持编辑器面板、蓝图、存档共用同一套元数据。
沉淀结论
UHT 把宏生成 FProperty 反射数据,复制靠它遍历属性、GC 靠它做可达性分析。GC 是 mark & sweep + Cluster/增量优化。铁律:成员指针必须 UPROPERTY(UE5 用 TObjectPtr),否则 GC 看不见会悬垂。
记忆口诀
反射:UHT 编译期生成 / FProperty 链表挂 UClass / 复制存档面板共用
CDO:每类一个默认对象模板 / 存默认值 / 差分比较基准
GC:mark & sweep / 只认 UPROPERTY 引用图 / Cluster 聚簇 + 增量清除摊薄 STW
指针:UPROPERTY/TObjectPtr 保活自动置空 / WeakObjectPtr 不保活 / 裸指针悬垂
内容来源
综合整理。主要参考:Epic 官方文档 Unreal Object Handling、Garbage Collection、Unreal Property System (Reflection)(Epic 官方博客 by Michael Noland)、UE 源码 UObjectGlobals.cpp/GarbageCollection.cpp、社区分析 Alex Forsythe《The UObject System》。数字为业界数量级估算。
相关专题:反射如何驱动属性复制见 属性复制;GC STW 与其他运行时的对比见 GC 与 STW;引擎一帧中 GC 的触发位置见 引擎架构。
自测:合上资料能说清楚吗?
为什么 UObject 的成员指针必须用 UPROPERTY(或 TObjectPtr)?
参考答案
GC 只沿 UPROPERTY 声明的引用做可达性分析。裸指针 GC 看不见:① 不参与保活,目标可能被提前回收;② 目标销毁时不会帮你置空,变成悬垂指针,解引用崩溃。加了 UPROPERTY/TObjectPtr 后,GC 纳入引用图保活,且目标销毁时自动置 nullptr。
反射为什么是属性复制的前提?
参考答案
UHT 编译期把每个 UPROPERTY 生成 FProperty 对象串成链表挂在 UClass 上,记录属性的类型和内存偏移。属性复制的 RepLayout 就是遍历这个链表,收集标了 Replicated 的属性成扁平表,按偏移读值做差分。没有反射,引擎无法自动知道该复制哪些字段。
UE 的 GC 用什么算法?为什么不用引用计数?
参考答案
标记—清除(mark & sweep):从 Root Set 出发沿 UPROPERTY 引用图遍历,可达的保留、不可达的回收,配 Cluster 聚簇与增量清除摊薄 STW。不用引用计数是因为它解决不了循环引用(游戏里 A↔B 互相引用极常见,计数永不归零),而 mark & sweep 天然处理循环引用。
TObjectPtr、TWeakObjectPtr、TSharedPtr 有什么区别?
参考答案
TObjectPtr(UE5):UObject 强引用,保活、自动置空,替代裸 UPROPERTY 指针。TWeakObjectPtr:UObject 弱引用,不保活,用 IsValid() 检查,适合缓存/观察者。TSharedPtr:引用计数,用于非 UObject 的普通 C++ 对象,不参与 GC。选错会导致保活失败或该回收的不回收。