笃行
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
  • 引擎骨架

    • UE 引擎(客户端—服务器交互)
    • 引擎架构与一帧的时序
    • UObject / GC / 反射
    • Gameplay Framework 与端存在性矩阵
  • 复制栈底层

    • NetDriver / Channel / Bunch
    • 属性复制
    • RPC
  • 带宽与规模

    • Relevancy 与带宽预算
    • UE5 Iris 复制系统
  • 局内同步

    • 移动预测与回滚
    • 延迟补偿
    • 网络物理同步
  • 系统与运维

    • GAS 网络模型
    • 专用服务器 · Session · Travel
    • 服务器权威与反作弊

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)自动置 nullptrUE5 推荐替代裸 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。选错会导致保活失败或该回收的不回收。

最近更新: 2026/9/10 11:38
Prev
引擎架构与一帧的时序
Next
Gameplay Framework 与端存在性矩阵