Unreal对C++做了什么 · Part2根基 · 第 5 章 · 垃圾回收:你不再需要 delete

📅 发布时间:2026/7/17 1:03:51 👁️ 浏览次数:
Unreal对C++做了什么 · Part2根基 · 第 5 章 · 垃圾回收:你不再需要 delete
第 5 章 · 垃圾回收你不再需要 delete第 3 章中我们说过UObject 不能手动delete它的内存由 GC 自动回收。第 4 章中我们看到UPROPERTY()除了驱动反射还有一个看不见的作用——告诉 GC “这个指针指向的对象正在被引用”。现在该正式拆开 Unreal 的垃圾回收器了。垃圾回收不是什么新概念——Java 从诞生之日就有 GCC# 也有甚至 Python、Go、Lua 都有。但在 C 中实现 GC 是一件非同寻常的事。C 的设计哲学是你不用的东西不付代价而 GC 恰恰是那种你为了方便而付出的代价。Unreal 的立场很务实对于 UObject 世界GC 的便利远大于代价对于非 UObject 世界F 前缀类型继续用手动管理或智能指针。两个世界并存各管各的地盘。本章的核心问题只有一个GC 是怎么知道哪些 UObject 还活着、哪些已经死了的5.1 为什么不用引用计数在深入 Unreal 的 GC 之前先排除一个常见的误解“为什么不直接用std::shared_ptr管理 UObject”引用计数看似完美——每个对象维护一个计数器有人引用就加一解除引用就减一计数归零就销毁。但它有两个致命问题循环引用。对象 A 引用了对象 BB 又引用了 A。两者的计数永远不会归零永远不会被回收。在游戏中对象之间的相互引用极其常见——角色引用武器武器引用持有者组件引用 ActorActor 引用组件。std::weak_ptr可以打断循环但你需要在设计阶段就明确哪些是强引用、哪些是弱引用。在一个拥有数万个类、数十万个引用关系的引擎中这个负担太重了。性能开销。每次指针赋值都需要原子地增减计数器。在多线程环境下原子操作的代价不容忽视。游戏的每一帧都有大量的对象引用传递引用计数的隐性开销会累积。所以 Unreal 选择了另一条路标记-清除Mark-Sweep。5.2 标记-清除算法标记-清除是最经典的 GC 算法之一思路简洁标记阶段从一组根对象Root Set出发沿着引用链遍历所有可以到达的对象把它们标记为存活。清除阶段扫描所有 UObject没有被标记的就是不可达的——回收它们。就像在一栋废弃的大楼里找人从入口开始沿着走廊和门逐间搜索标记所有你能走到的房间。搜索结束后没被标记的房间就是空的——可以拆除。根集合根集合Root Set是 GC 遍历的起点。Unreal 中以下对象会进入根集合通过AddToRoot()显式添加的对象被全局引擎对象如 GEngine、GWorld持有的对象当前世界中存活的 Actor通过 ULevel 持有正在被栈上的局部变量引用的对象通过FGCObject机制引用追踪反射的又一回报标记阶段的关键操作是从一个对象出发找到它引用了哪些其他 UObject。这就是反射系统的又一个应用场景GC 遍历到对象 A │ ├── 通过反射查询 A 的所有 FProperty │ ├── FProperty: Weapon (类型UObject*, 有 UPROPERTY 标记) │ │ └── Weapon 指向对象 B → 标记 B 为存活 → 递归遍历 B │ ├── FProperty: Health (类型float) │ │ └── 不是 UObject 指针跳过 │ └── FProperty: Inventory (类型TArrayUObject*, 有 UPROPERTY 标记) │ └── 数组中每个元素 → 标记为存活 → 递归遍历 │ └── 完成 A 的引用追踪注意关键点GC 只能追踪 UPROPERTY 标记的 UObject 指针。这不是设计缺陷而是必然——GC 依赖反射来发现引用关系而反射只记录了 UPROPERTY 标记的成员。这就是为什么第 1 章和第 4 章都反复强调UPROPERTY()不只是给编辑器看的元数据它是GC 追踪引用的唯一线索。清除阶段标记阶段结束后所有存活的对象都被标记了。GC 扫描全局 UObject 表未被标记的对象进入销毁流程未标记的对象 │ ├── 调用 ConditionalBeginDestroy() ├── 调用 BeginDestroy() ← 释放非托管资源GPU 资源、文件句柄等 ├── 调用 FinishDestroy() ← 最终清理 └── 释放内存注意这是一个异步的、分阶段的过程。BeginDestroy()和FinishDestroy()可能不在同一帧执行——有些资源比如 GPU 上的纹理需要等渲染线程完成操作后才能安全释放。GC 的触发时机Unreal 的 GC 不是持续运行的——它在特定时机被触发定时触发引擎有一个可配置的 GC 间隔默认约 60 秒在低负载时自动运行内存压力触发当 UObject 数量或内存占用超过阈值时手动触发调用GEngine-ForceGarbageCollection()或CollectGarbage()关卡切换加载新关卡时通常会触发一次完整 GC增量 GC在一帧之内完成所有标记和清除可能导致卡顿——尤其当对象数量巨大时。Unreal 支持增量 GCIncremental GC把标记工作分散到多帧完成每帧只处理一部分对象用时间预算控制每帧的 GC 耗时需要额外的写屏障Write Barrier机制来追踪增量期间新产生的引用变化增量 GC 可以通过项目设置配置。对于大多数游戏项目默认的 GC 配置足够好——但如果你的游戏动态创建了大量对象比如弹幕射击游戏理解这些参数就很有价值。5.3 UPROPERTY 是 GC 安全的关键理解了标记-清除的流程后UPROPERTY()在 GC 中的角色就完全清楚了。让我们用一个具体的例子来说明安全的写法UCLASS()classAMyCharacter:publicACharacter{GENERATED_BODY()UPROPERTY()UWeapon*CurrentWeapon;// GC 能看到这个引用};当 GC 遍历到AMyCharacter实例时它通过反射发现CurrentWeapon是一个 UPROPERTY 标记的 UObject 指针。GC 读取指针值将它指向的UWeapon标记为存活。一切正常。危险的写法UCLASS()classAMyCharacter:publicACharacter{GENERATED_BODY()UWeapon*CurrentWeapon;// 没有 UPROPERTYGC 看不到};同一个指针去掉了UPROPERTY()。现在这个成员变量对反射系统不可见——GC 遍历到AMyCharacter时根本不知道CurrentWeapon的存在。如果没有其他 UPROPERTY 引用指向这个UWeaponGC 会认为它不可达将其回收。然后你下一次通过CurrentWeapon访问它——崩溃。悬空指针。这是 Unreal C 中最经典也最危险的 bug。而且它不是每次都发生——只在 GC 恰好运行时才会暴露导致问题极难复现。规则很简单任何 UObject 子类的成员变量如果持有 UObject 指针并且需要保持引用关系就必须加 UPROPERTY()。例外情况极少。如果你确实需要一个不影响 GC 的弱引用用TWeakObjectPtr第 9 章详述。5.4 根对象与手动控制有时你需要在 UPROPERTY 体系之外保持一个 UObject 存活。比如一个全局管理器、一个缓存中的资产、一个被非 UObject 代码持有的对象。AddToRoot / RemoveFromRootUMyManager*ManagerNewObjectUMyManager();Manager-AddToRoot();// 加入根集合GC 不会回收// ... 使用 Manager ...Manager-RemoveFromRoot();// 从根集合移除下次 GC 可能回收AddToRoot()把对象直接加入 GC 的根集合。只要在根集合中对象就永远不会被回收——即使没有任何 UPROPERTY 指向它。代价很明显如果你忘了RemoveFromRoot()就是内存泄漏。这相当于回到了手动管理的世界。所以AddToRoot()应该谨慎使用通常只用于全局单例对象。FGCObject另一个场景一个非 UObject 的 C 类比如一个 F 前缀的管理器需要持有 UObject 引用。因为它不是 UObject不能有 UPROPERTY。怎么办Unreal 提供了FGCObject接口classFMyManager:publicFGCObject{UTexture*CachedTexture;// 告诉 GC我引用了这些 UObjectvirtualvoidAddReferencedObjects(FReferenceCollectorCollector)override{Collector.AddReferencedObject(CachedTexture);}virtualFStringGetReferencerName()constoverride{returnTEXT(FMyManager);}};通过实现FGCObject接口并覆盖AddReferencedObjects()你可以手动告诉 GC “我引用了这些对象”。GC 在标记阶段会查询所有注册的FGCObject实例。这是 UPROPERTY 的手动替代品——当你不能使用 UPROPERTY 时的逃生通道。5.5 GC 集群在大型游戏中UObject 的数量可能达到数十万甚至更多。GC 每次标记都要遍历所有可达对象这在性能上可能成为瓶颈。GC 集群Cluster是 Unreal 的优化手段将逻辑上紧密相关的对象聚合为一组——整组要么全部存活要么全部回收。典型的例子是一个 StaticMesh 资产它包含一个UStaticMesh对象、若干UMaterial引用、LOD 数据等。这些子对象的生命周期完全由父对象决定——父对象活着它们就活着父对象死了它们也没有独立存在的意义。把它们聚合为一个集群后GC 只需要检查集群的根对象是否可达不需要逐个遍历集群内部的对象。当集群内有上百个子对象时这个优化效果非常显著。集群的创建通常由引擎内部完成比如资产加载时应用层代码很少需要手动管理集群。但理解它的存在有助于解释一个现象有时候你只删除了一个对象却发现关联的一批对象都跟着消失了——那是因为它们在同一个 GC 集群中。5.6 GC 与手动管理并存的规则Unreal 的世界不是纯 GC 的。UObject 走 GC非 UObject 走手动管理或智能指针。两个世界并存但边界需要小心维护。清晰的分界线┌──────────────────────────────────────────────────┐ │ UObject 世界 (GC 管理) │ │ │ │ UObject* 指针 UPROPERTY() GC 自动追踪 │ │ 不需要 delete不需要智能指针 │ │ 规则UPROPERTY 标记所有 UObject 指针 │ │ │ ├──────────────────────────────────────────────────┤ │ 非 UObject 世界 (手动管理) │ │ │ │ F 前缀类型、原生 C 类 │ │ TSharedPtr / TUniquePtr 或栈分配 │ │ 规则与标准 C 相同 │ │ │ ├──────────────────────────────────────────────────┤ │ 跨越边界 │ │ │ │ 非 UObject 持有 UObject → FGCObject │ │ UObject 持有非 UObject → 普通成员或智能指针 │ │ 弱引用 UObject → TWeakObjectPtr │ │ │ └──────────────────────────────────────────────────┘五条生存规则我把 GC 相关的注意事项浓缩为五条规则。遵守它们你就不会遇到 GC 相关的 bug规则一UObject 指针成员变量必须加 UPROPERTY()。这是最重要的一条。没有例外——除非你刻意使用TWeakObjectPtr弱引用不阻止回收。规则二不要手动 delete UObject。不要delete MyActor。对 Actor 调用Destroy()对其他 UObject 移除所有引用即可。GC 会处理剩下的事。规则三局部变量中的 UObject 指针在当前作用域内是安全的。GC 不会在你的函数执行到一半时突然运行。GC 只在安全点通常是帧间触发。所以下面的代码是安全的voidAMyActor::DoSomething(){UMyObject*TempNewObjectUMyObject(this);Temp-Initialize();// Temp 在这个函数内是安全的——GC 不会在函数执行中运行// 但如果你把 Temp 存到一个没有 UPROPERTY 的成员变量里// 函数返回后下次 GC 就可能回收它}规则四非 UObject 类持有 UObject 引用时用 FGCObject。这是跨越边界的标准方式。规则五AddToRoot() 是最后手段用了就必须 RemoveFromRoot()。大多数情况下你不需要它。5.7 常见的 GC 陷阱陷阱一忘记 UPROPERTY 导致悬空指针// 错误UWeapon*Weapon;// 忘了 UPROPERTY// 正确UPROPERTY()UWeapon*Weapon;症状偶尔崩溃通常在玩了一段时间后GC 触发时。难以复现日志中显示访问已销毁的对象。陷阱二在容器中存储 UObject 裸指针// 错误TArrayUObject*MyObjects;// 没有 UPROPERTY// 正确UPROPERTY()TArrayUObject*MyObjects;// UPROPERTY 也能保护容器内的指针UPROPERTY()对TArrayUObject*、TMap..., UObject*、TSetUObject*都有效——GC 会遍历容器内的所有元素。陷阱三Lambda 捕获 UObject 指针// 危险UMyObject*ObjNewObjectUMyObject(this);SomeAsyncOperation([Obj](){// 当这个 lambda 执行时Obj 可能已经被 GC 回收了Obj-DoSomething();// 可能崩溃});Lambda 捕获的 UObject 指针不受 UPROPERTY 保护。如果 Lambda 的执行时机不确定比如异步回调你需要用TWeakObjectPtr来安全地检查对象是否仍然存活TWeakObjectPtrUMyObjectWeakObjObj;SomeAsyncOperation([WeakObj](){if(UMyObject*StrongObjWeakObj.Get()){StrongObj-DoSomething();// 安全}});陷阱四跨帧缓存 UObject 指针在非 UPROPERTY 变量中voidAMyActor::Tick(floatDeltaTime){// 错误CachedTarget 是一个非 UPROPERTY 成员if(!CachedTarget){CachedTargetFindNearestEnemy();// 找到一个敌人并缓存}// 下一帧 Tick 时CachedTarget 可能已经被 GC 回收了}只要指针需要跨帧存活就必须有 UPROPERTY 保护。没有例外。5.8 与其他语言的 GC 对比维度Java GCC# GCUnreal GC管理范围所有对象所有托管对象仅 UObject 子类算法分代收集 多种收集器分代收集标记-清除可选增量引用追踪语言级支持语言级支持依赖反射UPROPERTY并发并发 GCG1、ZGC并发工作站 GC主线程或增量可控性有限System.gc()只是建议有限较强可手动触发、配置参数与手动管理共存否几乎纯 GCJNI 互操作较复杂是两个世界并存Unreal GC 最独特的特征是它的选择性——只管 UObject不管其他。这让游戏引擎可以在需要 GC 便利的地方游戏对象、资产、组件使用它在需要极致性能的地方数学运算、底层渲染数据继续用零开销的值类型。一句话总结Unreal 的 GC 通过标记-清除算法自动管理 UObject 的生命周期而 UPROPERTY 是它追踪引用关系的唯一线索——漏标一个指针就可能导致悬空引用这是 Unreal C 中最常见也最隐蔽的 bug。实验亲眼看 GC 回收创建一个会被回收的对象。在你的 Actor 中// .hUCLASS()classAMyActor:publicAActor{GENERATED_BODY()public:// 注意故意不加 UPROPERTYUObject*UnsafeRef;UPROPERTY()UObject*SafeRef;virtualvoidBeginPlay()override;};// .cppvoidAMyActor::BeginPlay(){Super::BeginPlay();UnsafeRefNewObjectUObject(this,UObject::StaticClass(),TEXT(UnsafeObject));SafeRefNewObjectUObject(this,UObject::StaticClass(),TEXT(SafeObject));UE_LOG(LogTemp,Warning,TEXT(创建完成));UE_LOG(LogTemp,Warning,TEXT( UnsafeRef: %s),*UnsafeRef-GetName());UE_LOG(LogTemp,Warning,TEXT( SafeRef: %s),*SafeRef-GetName());// 延迟几秒后强制 GC然后检查FTimerHandle TimerHandle;GetWorldTimerManager().SetTimer(TimerHandle,[this](){GEngine-ForceGarbageCollection(true);UE_LOG(LogTemp,Warning,TEXT(GC 完成后:));UE_LOG(LogTemp,Warning,TEXT( SafeRef 有效: %s),IsValid(SafeRef)?TEXT(是):TEXT(否));// UnsafeRef 可能已经是悬空指针——访问它可能崩溃// 这就是没有 UPROPERTY 的后果},3.0f,false);}运行后观察SafeRef在 GC 后仍然有效而UnsafeRef指向的对象已经被回收。这个实验让你亲眼见证 UPROPERTY 对 GC 的关键作用。警告在实际项目中访问被 GC 回收的指针是未定义行为可能崩溃。此实验仅用于学习目的理解风险后再运行。