尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

UE4 垃圾回收(GC)机制详解:从可达性分析到 Mark/Sweep

UE4 垃圾回收(GC)机制详解:从可达性分析到 Mark/Sweep 引言这篇文章讲的是 UE4 的 Garbage CollectionGC垃圾回收机制。原文基于 UE 4.26源码, UE 在 C 之上自己做了一套针对 UObject 的自动内存管理系统。整篇文章压缩成一句话UE 的 GC 会定期从“肯定活着的对象”出发沿着 UObject 的引用关系往下找能找到的留下找不到的标记成垃圾之后再统一销毁。这就是所谓的 Mark-Sweep标记-清扫。一、为什么 UE 需要 GC先从普通 C 开始。假设你写Enemy* EnemyA new Enemy();正常 C 里你最后必须delete EnemyA;不然内存一直占着就可能出现 memory leak内存泄漏。传统 C 的逻辑是new ↓ 使用对象 ↓ delete 程序员负责对象的生老病死。但 UE 里面动不动就有ActorComponentMaterialTextureWidgetAnimationDataAsset...一局游戏可能产生非常多对象。如果每一个都让 Gameplay Programmer 自己判断“这个对象还有没有人在用”会非常痛苦。所以 UE 对 UObject 体系做了一套自动管理。比如UObject* Obj NewObjectUMyObject();通常不是让你delete Obj;而是创建 UObject ↓ UE 追踪引用关系 ↓ 发现没有任何有效引用 ↓ GC ↓ 销毁所以第一个非常重要的结论UObject 一般不是通过 delete 来管理生命周期而是交给 UE 的 GC。二、GC 到底怎么知道一个对象“没用了”这是整篇文章最重要的问题。假设现在有Player | v Weapon | v BulletPlayer 引用 WeaponUPROPERTY() UWeapon* Weapon;Weapon 又引用 BulletUPROPERTY() UBullet* Bullet;那么 UE 会认为Player ↓ Weapon ↓ Bullet它们都是 Reachable可达对象。只要能从一个“根”一路找到它GC 就认为“这个东西还有人用不能删。”三、什么叫 Root你可以把 GC 想象成一次公司点名。UE 先找一批“这些员工确定还在公司。”这些就是 Root Set根集合。然后开始问Root A 认识谁 ↓ Object B Object B 又引用谁 ↓ Object C Object C 又引用谁 ↓ Object D于是Root ↓ A ↓ B ↓ C 全被标成 Reachable但是如果旁边还有一个 Object X没人能从 Root 找到它Root → A → B → C X那么X UnreachableGC 就认为“这哥们已经没人用了可以准备清理。”四、所以 UE 的 GC 本质是什么可以画成Root Set | ┌──────┴──────┐ ↓ ↓ Player GameMode | ↓ Weapon | ↓ Bullet TempObject GC 开始遍历 Root Set ↓ Player ✔ ↓ Weapon ✔ ↓ Bullet ✔ 最后发现 TempObject ✘ 于是 TempObject ↓ Unreachable ↓ Garbage ↓ 销毁这就是 Mark标记之后把 Unreachable 的对象真正清掉就是 Sweep/Purge清扫。五、为什么 UPROPERTY() 和 GC 有关系这部分特别重要而且特别容易面试。比如UObject* MyObject;你虽然从 C 的角度保存了一个指针但是UE 的 GC 不一定知道这是一条需要追踪的 UObject 引用。因此通常会写UPROPERTY() UObject* MyObject;这样 UE 的反射系统能够知道当前 UObject ↓ MyObject ↓ 另一个 UObject这是一条需要 GC 考虑的引用关系。例如UPROPERTY() UTexture2D* Texture;可以简单理解为“GC你记一下我这个对象还引用着 Texture。”这就是为什么 Unreal C 代码里经常看到UPROPERTY() TObjectPtrUStaticMesh Mesh; UPROPERTY() TObjectPtrUMaterial Material;而不仅仅是普通裸指针。UE 的 UObject/反射系统会利用这些属性信息来识别引用关系。六、一个特别容易误解的地方很多人会记成UPROPERTY 不会被 GC。这句话不够准确。更准确应该说UPROPERTY 可以让 GC 识别这条 UObject 引用从而使被引用对象在引用仍然有效时保持可达。比如UPROPERTY() UObject* Obj;如果Obj NewObjectUObject();那么this ↓ Obj GC 能看到引用。但如果之后Obj nullptr;引用断掉this ObjObject ↑ 没人引用那这个对象最终还是可以被 GC。所以不是UPROPERTY ↓ 对象永生而是UPROPERTY ↓ 让 GC 知道引用关系这个区别很重要。七、那 AddToRoot() 又是什么还有一种比较“暴力”的方法MyObject-AddToRoot();相当于直接跟 GC 说这个对象你别碰它现在就是 Root。于是MyObject ↓ Root Set ↓ GC 永远认为它可达直到MyObject-RemoveFromRoot();相当于“好了现在你可以正常考虑回收它了。”因此Obj-AddToRoot(); // 使用 Obj Obj-RemoveFromRoot();一定要成对考虑。因为如果一直AddToRoot()却不RemoveFromRoot()一定要成对考虑。因为如果一直AddToRoot()却不RemoveFromRoot()对象可能一直活着。这实际上就变成另一种形式的“内存泄漏”。AddToRoot() 的作用就是让 UObject 一直留在根集合、避免被 GC。八、GC 的真正源码流程文章往后开始进入源码。你不用第一次就把函数全部背下来。先记住CollectGarbage() ↓ CollectGarbageInternal() ↓ Reachability Analysis ↓ 找出 Reachable / Unreachable ↓ Purge文章里提到了 CollectGarbage()它的思想大概是AcquireGCLock(); CollectGarbageInternal(...); ReleaseGCLock();也就是拿 GC Lock ↓ 执行 GC ↓ 释放 GC Lock核心工作在 CollectGarbageInternal() 里面。九、Reachability Analysis 是什么这个名字看着很唬人PerformReachabilityAnalysis其实翻译过来就是看看哪些 UObject 还能从根对象找到。假设Root | A | B | C D | E开始Root → AA 可达。然后 A → BB 可达。再 B → CC 可达。最终A ✔ B ✔ C ✔ D ✘ E ✘那么 D、E 进入待回收区域。所以你以后看到 PerformReachabilityAnalysis脑子里直接翻译沿引用链找活对象就够了。十、文章里的 ObjectsToSerialize 是干嘛的文章源码里有一个比较显眼的东西ObjectsToSerialize。可以简单理解成GC 当前还需要继续检查引用关系的一批对象。比如Root ↓ Player首先ObjectsToSerialize [Player]检查 PlayerPlayer → Weapon那么把 Weapon 加进去ObjectsToSerialize [ Player, Weapon ]继续 WeaponWeapon → Bullet于是ObjectsToSerialize [ Player, Weapon, Bullet ]再继续。本质上就是一个“这些对象还得继续顺藤摸瓜”的工作列表。十一、Strong Reference 和 Weak Reference文章里还出现FGCArrayStruct其中包含类似TArrayUObject* ObjectsToSerialize; TArrayUObject** WeakReferences;这里顺便理解一个特别重要的概念Strong Reference 强引用基本意思我引用你所以你不能因为 GC 被回收。比如Player ↓ strong WeaponPlayer 活着Weapon 通常也会保持可达。Weak Reference 弱引用意思我想知道你在不在但我不负责让你活着。例如逻辑上EnemyTarget ----weak---- Player就算例如逻辑上 EnemyTarget ----weak---- Player就算EnemyTarget还在也不能仅仅因为这个 weak referencePlayer 就永远不被回收。这个概念和你之前学 C 智能指针时的 shared_ptr、weak_ptr 思想其实很像。不过注意UE 的 UObject GC 和 std::shared_ptr 引用计数不是同一个系统。十二、UE GC ≠ 引用计数很多人会说UE UObject 使用引用计数。严格来说不应该这么回答。UE UObject GC 的核心是Tracing GC Mark Sweep即从 Root 出发 ↓ 遍历引用图 ↓ 判断可达性而不是每个 UObject ReferenceCount 3 少一个引用 ReferenceCount 2 ReferenceCount 0 立即 delete那是典型引用计数方案。所以shared_ptr 更像 reference count UObject GC 更像 Root ↓ Reachability ↓ Mark ↓ Sweep千万别混。十三、Actor这也是 UE 面试经常问的。比如AEnemy* Enemy GetWorld()-SpawnActorAEnemy();为什么你Enemy nullptr;Actor 不一定立刻消失因为World ↓ Level ↓ ActorActor 仍然被 World / Level 等 UE 系统持有。所以它依然是 ReachableGC 不会因为你自己的一个变量 Enemy nullptr 就认为它没用了。文章中的测试也展示了通过 SpawnActor 创建的 Actor即使本地指针置空并强制 GCActor 仍可能保持可达。所以 Actor 通常应该Enemy-Destroy();Destroy 的意思更接近“我要让这个 Actor 从 World 的生命周期中退出。”然后最终涉及的 UObject 内存回收仍由 UE 的系统处理。十四、Destroy() ≠ delete这个一定要刻在脑子里。Actor-Destroy();并不是delete Actor;可以简单理解成Destroy() ↓ 告诉 World 这个 Actor 要死了 ↓ 从 Gameplay 生命周期退出 ↓ 最终变成不可达/可回收 ↓ GC 处理内存所以delete MyActor;通常不是 UE Gameplay 代码应该干的事情。十五、对象真正销毁大概经过什么过程文章后面会涉及类似BeginDestroy ↓ FinishDestroy ↓ 析构 / 内存释放你可以把它想成一个人的离职流程。不是发现垃圾 ↓ 砰 内存瞬间没了而更像发现不可达 ↓ BeginDestroy “我要开始清理资源了” ↓ 等必要的异步/资源清理完成 ↓ FinishDestroy “准备好了” ↓ 最终释放这样做非常适合游戏引擎因为 UObject 可能关联Render ResourceAudioTextureGPU ResourceRender ResourceAudioTextureGPU ResourceAsync Task...并不是所有东西都适合瞬间硬删。十六、为什么 GC 会造成卡顿假设某一刻100000 个 UObject。GC 开始扫描 Root ↓ 遍历引用 ↓ 检查十万个 UObject ↓ 标记 ↓ 清理垃圾这些工作需要 CPU 时间。如果一次做太多Frame 1 Gameplay 5 ms Rendering 6 ms GC 30 ms这一帧41 ms换算 FPS≈ 24 FPS。于是玩家就会感受到“怎么突然卡了一下”因此 UE 会做各种 GC 优化其中清扫阶段可以采用渐进处理思路避免一次大量销毁对象。十七、文章提到的 Incremental Purge 是什么名字IncrementalPurgeGarbage。拆开Incremental 渐进式 Purge 清理 Garbage假设有10000 个垃圾 UObject。不要Frame 100 一次全部销毁 ████████████████████ 卡死而可以类似Frame 100 ██ Frame 101 ██ Frame 102 ██ Frame 103 ██ Frame 104 ██把清理压力分散。文章所讨论的清扫阶段正是这种增量处理思路。十八、Cluster这是文章更进阶的内容。假设BigObject ├── Object A ├── Object B ├── Object C ├── Object D ├── Object E └── ...如果这些东西本来就是一个整体经常一起活、一起死。普通 GC检查 A、检查 B、检查 C、检查 D、检查 E... 很贵。于是 UE 可以把一些 UObject 组织成GC Cluster [ Root ] | ┌─┼─┬─┬─┐ A B C D E然后Cluster Root 活着可以快速认为这个簇相关对象整体处于存活状态。或者整个 Cluster 不可达就按簇处理。核心目的就两个字性能。相关资料也描述了 UE 通过对象簇减少对子对象逐个追踪和处理的成本。十九、把整篇文章压缩成一张图你以后复习只需要记这个UE UObject GC │ ▼ CollectGarbage │ ▼ 找到 Root / 活跃对象 │ ▼ Reachability Analysis │ ┌──────────┴──────────┐ ▼ ▼ Reachable Unreachable 可达对象 不可达对象 可达对象 不可达对象 │ │ │ ▼ │ 标记为 Garbage │ │ │ ▼ │ BeginDestroy │ │ │ ▼ │ FinishDestroy │ │ │ ▼ │ Purge │ ▼ 保留这基本就是整篇文章的骨架。
返回列表