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

引言

这篇文章讲的是 UE4 的 Garbage Collection(GC,垃圾回收)机制。原文基于 UE 4.26,源码细节比较多,不过核心思想其实没那么吓人:UE 在 C++ 之上自己做了一套针对 UObject 的自动内存管理系统。你可以先把整篇文章压缩成一句话:UE 的 GC 会定期从"肯定活着的对象"出发,沿着 UObject 的引用关系往下找;能找到的留下,找不到的标记成垃圾,之后再统一销毁。这就是所谓的 Mark-Sweep(标记-清扫)。

一、为什么 UE 需要 GC?

先从普通 C++ 开始。假设你写:

cpp 复制代码
Enemy* EnemyA = new Enemy();

正常 C++ 里,你最后必须:

cpp 复制代码
delete EnemyA;

不然内存一直占着,就可能出现 memory leak(内存泄漏)。所以传统 C++ 的逻辑是:

text 复制代码
new
 ↓
使用对象
 ↓
delete
程序员负责对象的生老病死。

但 UE 里面动不动就有:

  • Actor
  • Component
  • Material
  • Texture
  • Widget
  • Animation
  • DataAsset
  • ...

一局游戏可能产生非常多对象。如果每一个都让 Gameplay Programmer 自己判断:"这个对象还有没有人在用?"会非常痛苦。所以 UE 对 UObject 体系做了一套自动管理。比如:

cpp 复制代码
UObject* Obj = NewObject<UMyObject>();

通常不是让你:

cpp 复制代码
delete Obj;

而是:

text 复制代码
创建 UObject
       ↓
UE 追踪引用关系
       ↓
发现没有任何有效引用
       ↓
      GC
       ↓
     销毁

所以第一个非常重要的结论:UObject 一般不是通过 delete 来管理生命周期,而是交给 UE 的 GC。

二、GC 到底怎么知道一个对象"没用了"?

这是整篇文章最重要的问题。假设现在有:

text 复制代码
Player
  |
  v
Weapon
  |
  v
Bullet

Player 引用 Weapon:

cpp 复制代码
UPROPERTY()
UWeapon* Weapon;

Weapon 又引用 Bullet:

cpp 复制代码
UPROPERTY()
UBullet* Bullet;

那么 UE 会认为:

text 复制代码
Player
 ↓
Weapon
 ↓
Bullet

它们都是 Reachable(可达)对象。只要能从一个"根"一路找到它,GC 就认为:"这个东西还有人用,不能删。"

三、什么叫 Root?

你可以把 GC 想象成一次公司点名。UE 先找一批:"这些员工确定还在公司。"这些就是 Root Set(根集合)。然后开始问:

text 复制代码
Root A 认识谁?
   ↓
Object B
Object B 又引用谁?
   ↓
Object C
Object C 又引用谁?
   ↓
Object D

于是:

text 复制代码
Root
 ↓
 A
 ↓
 B
 ↓
 C
全被标成:
Reachable

但是如果旁边还有一个 Object X,没人能从 Root 找到它:

text 复制代码
Root → A → B → C
X

那么:

text 复制代码
X = Unreachable

GC 就认为:"这哥们已经没人用了,可以准备清理。"

四、所以 UE 的 GC 本质是什么?

可以画成:

text 复制代码
             Root Set
                |
         ┌──────┴──────┐
         ↓             ↓
       Player        GameMode
         |
         ↓
       Weapon
         |
         ↓
       Bullet


     TempObject



GC 开始遍历:

Root Set
   ↓
Player ✔
   ↓
Weapon ✔
   ↓
Bullet ✔


最后发现:
TempObject ✘


于是:
TempObject
    ↓
Unreachable
    ↓
Garbage
    ↓
   销毁

这就是 Mark(标记)之后:把 Unreachable 的对象真正清掉,就是 Sweep/Purge(清扫)。

五、为什么 UPROPERTY() 和 GC 有关系?

这部分特别重要,而且特别容易面试。比如:

cpp 复制代码
UObject* MyObject;

你虽然从 C++ 的角度保存了一个指针,但是:UE 的 GC 不一定知道这是一条需要追踪的 UObject 引用。因此通常会写:

cpp 复制代码
UPROPERTY()
UObject* MyObject;

这样 UE 的反射系统能够知道:

text 复制代码
当前 UObject
     ↓
MyObject
     ↓
另一个 UObject

这是一条需要 GC 考虑的引用关系。例如:

cpp 复制代码
UPROPERTY()
UTexture2D* Texture;

可以简单理解为:"GC,你记一下,我这个对象还引用着 Texture。"这就是为什么 Unreal C++ 代码里经常看到:

cpp 复制代码
UPROPERTY()
TObjectPtr<UStaticMesh> Mesh;
UPROPERTY()
TObjectPtr<UMaterial> Material;

而不仅仅是普通裸指针。UE 的 UObject/反射系统会利用这些属性信息来识别引用关系。

六、一个特别容易误解的地方

很多人会记成:UPROPERTY = 不会被 GC。这句话不够准确。更准确应该说:UPROPERTY 可以让 GC 识别这条 UObject 引用,从而使被引用对象在引用仍然有效时保持可达。比如:

cpp 复制代码
UPROPERTY()
UObject* Obj;

如果:

cpp 复制代码
Obj = NewObject<UObject>();

那么:

text 复制代码
this
 ↓
Obj
GC 能看到引用。

但如果之后:

cpp 复制代码
Obj = nullptr;

引用断掉:

text 复制代码
this     ObjObject
           ↑
        没人引用

那这个对象最终还是可以被 GC。所以不是:

text 复制代码
UPROPERTY
   ↓
对象永生

而是:

text 复制代码
UPROPERTY
   ↓
让 GC 知道引用关系

这个区别很重要。

七、那 AddToRoot() 又是什么?

还有一种比较"暴力"的方法:

cpp 复制代码
MyObject->AddToRoot();

相当于直接跟 GC 说:这个对象你别碰,它现在就是 Root。于是:

text 复制代码
MyObject
 ↓
Root Set
 ↓
GC 永远认为它可达

直到:

cpp 复制代码
MyObject->RemoveFromRoot();

相当于:"好了,现在你可以正常考虑回收它了。"因此:

cpp 复制代码
Obj->AddToRoot();
// 使用 Obj
Obj->RemoveFromRoot();

一定要成对考虑。因为如果一直:

cpp 复制代码
AddToRoot()

却不:

cpp 复制代码
RemoveFromRoot()

一定要成对考虑。因为如果一直:

cpp 复制代码
AddToRoot()

却不:

cpp 复制代码
RemoveFromRoot()

对象可能一直活着。这实际上就变成另一种形式的"内存泄漏"。AddToRoot() 的作用就是让 UObject 一直留在根集合、避免被 GC。

八、GC 的真正源码流程

文章往后开始进入源码。你不用第一次就把函数全部背下来。先记住:

text 复制代码
CollectGarbage()
        ↓
CollectGarbageInternal()
        ↓
Reachability Analysis
        ↓
找出 Reachable / Unreachable
        ↓
Purge

文章里提到了 CollectGarbage(),它的思想大概是:

cpp 复制代码
AcquireGCLock();
CollectGarbageInternal(...);
ReleaseGCLock();

也就是:

text 复制代码
拿 GC Lock
    ↓
执行 GC
    ↓
释放 GC Lock

核心工作在 CollectGarbageInternal() 里面。

九、Reachability Analysis 是什么?

这个名字看着很唬人:PerformReachabilityAnalysis,其实翻译过来就是:看看哪些 UObject 还能从根对象找到。假设:

text 复制代码
Root
 |
 A
 |
 B
 |
 C
 D
 |
 E

开始:Root → A,A 可达。然后 A → B,B 可达。再 B → C,C 可达。最终:

text 复制代码
A ✔
B ✔
C ✔
D ✘
E ✘

那么 D、E 进入待回收区域。所以你以后看到 PerformReachabilityAnalysis,脑子里直接翻译:沿引用链找活对象,就够了。

十、文章里的 ObjectsToSerialize 是干嘛的?

文章源码里有一个比较显眼的东西:ObjectsToSerialize。可以简单理解成:GC 当前还需要继续检查引用关系的一批对象。比如:

text 复制代码
Root
 ↓
Player

首先:

text 复制代码
ObjectsToSerialize = [Player]

检查 Player:Player → Weapon,那么把 Weapon 加进去:

text 复制代码
ObjectsToSerialize =
[
 Player,
 Weapon
]

继续 Weapon:Weapon → Bullet,于是:

text 复制代码
ObjectsToSerialize =
[
 Player,
 Weapon,
 Bullet
]

再继续。本质上就是一个:"这些对象还得继续顺藤摸瓜"的工作列表。你不需要把源码变量名死背,但理解它的角色很有用。

十一、Strong Reference 和 Weak Reference

文章里还出现:FGCArrayStruct,其中包含类似:

cpp 复制代码
TArray<UObject*> ObjectsToSerialize;
TArray<UObject**> WeakReferences;

这里顺便理解一个特别重要的概念:

Strong Reference 强引用基本意思:我引用你,所以你不能因为 GC 被回收。比如:

text 复制代码
Player
 ↓ strong
Weapon

Player 活着:Weapon 通常也会保持可达。

Weak Reference 弱引用意思:我想知道你在不在,但我不负责让你活着。例如逻辑上:

text 复制代码
EnemyTarget ----weak----> Player

就算:

text 复制代码
例如逻辑上:
EnemyTarget ----weak----> Player

就算:

text 复制代码
EnemyTarget

还在,也不能仅仅因为这个 weak reference,Player 就永远不被回收。

这个概念和你之前学 C++ 智能指针时的 shared_ptr、weak_ptr 思想其实很像。不过注意:UE 的 UObject GC 和 std::shared_ptr 引用计数不是同一个系统。

十二、UE GC ≠ 引用计数

这是一个很重要的面试点。很多新人会说:UE UObject 使用引用计数。严格来说,不应该这么回答。UE UObject GC 的核心是:

text 复制代码
Tracing GC
+
Mark & Sweep

即:

text 复制代码
从 Root 出发
   ↓
遍历引用图
   ↓
判断可达性

而不是:

text 复制代码
每个 UObject:
ReferenceCount = 3
少一个引用:
ReferenceCount = 2
ReferenceCount = 0
立即 delete

那是典型引用计数方案。所以:

text 复制代码
shared_ptr
更像:
reference count
UObject GC
更像:
Root
 ↓
Reachability
 ↓
Mark
 ↓
Sweep

千万别混。

十三、Actor 又是怎么回事?

这也是 UE 面试经常问的。比如:

cpp 复制代码
AEnemy* Enemy =
    GetWorld()->SpawnActor<AEnemy>();

为什么你:

cpp 复制代码
Enemy = nullptr;

Actor 不一定立刻消失?因为:

text 复制代码
World
 ↓
Level
 ↓
Actor

Actor 仍然被 World / Level 等 UE 系统持有。所以它依然是 Reachable,GC 不会因为你自己的一个变量 Enemy = nullptr 就认为它没用了。文章中的测试也展示了:通过 SpawnActor 创建的 Actor,即使本地指针置空并强制 GC,Actor 仍可能保持可达。所以 Actor 通常应该:

cpp 复制代码
Enemy->Destroy();

Destroy 的意思更接近:"我要让这个 Actor 从 World 的生命周期中退出。"然后最终涉及的 UObject 内存回收仍由 UE 的系统处理。

十四、Destroy() ≠ delete

这个一定要刻在脑子里。

cpp 复制代码
Actor->Destroy();

并不是:

cpp 复制代码
delete Actor;

可以简单理解成:

text 复制代码
Destroy()
   ↓
告诉 World:
这个 Actor 要死了
   ↓
从 Gameplay 生命周期退出
   ↓
最终变成不可达/可回收
   ↓
GC 处理内存

所以:

cpp 复制代码
delete MyActor;

通常不是 UE Gameplay 代码应该干的事情。

十五、对象真正销毁大概经过什么过程?

文章后面会涉及类似:

text 复制代码
BeginDestroy
      ↓
FinishDestroy
      ↓
析构 / 内存释放

你可以把它想成一个人的离职流程。不是:

text 复制代码
发现垃圾
↓
砰!
内存瞬间没了

而更像:

text 复制代码
发现不可达
    ↓
BeginDestroy
"我要开始清理资源了"
    ↓
等必要的异步/资源清理完成
    ↓
FinishDestroy
"准备好了"
    ↓
最终释放

这样做非常适合游戏引擎,因为 UObject 可能关联:

  • Render Resource
  • Audio
  • Texture
  • GPU Resource
  • Render Resource
  • Audio
  • Texture
  • GPU Resource
  • Async Task
  • ...

并不是所有东西都适合瞬间硬删。

十六、为什么 GC 会造成卡顿?

这个问题在游戏开发里非常实际。假设某一刻:100000 个 UObject。GC 开始:

text 复制代码
扫描 Root
↓
遍历引用
↓
检查十万个 UObject
↓
标记
↓
清理垃圾

这些工作需要 CPU 时间。如果一次做太多:

text 复制代码
Frame 1
Gameplay 5 ms
Rendering 6 ms
GC 30 ms

这一帧:41 ms,换算 FPS:≈ 24 FPS。于是玩家就会感受到:"怎么突然卡了一下?"因此 UE 会做各种 GC 优化,其中清扫阶段可以采用渐进处理思路,避免一次大量销毁对象。

十七、文章提到的 Incremental Purge 是什么?

名字:IncrementalPurgeGarbage。拆开:

text 复制代码
Incremental
=
渐进式
Purge
=
清理 Garbage

假设有:10000 个垃圾 UObject。不要:

text 复制代码
Frame 100:
一次全部销毁
████████████████████
卡死

而可以类似:

text 复制代码
Frame 100  ██
Frame 101  ██
Frame 102  ██
Frame 103  ██
Frame 104  ██

把清理压力分散。文章所讨论的清扫阶段正是这种增量处理思路。

十八、Cluster 是什么?

这是文章更进阶的内容。假设:

text 复制代码
BigObject
 ├── Object A
 ├── Object B
 ├── Object C
 ├── Object D
 ├── Object E
 └── ...

如果这些东西本来就是一个整体,经常一起活、一起死。普通 GC:检查 A、检查 B、检查 C、检查 D、检查 E,... 很贵。于是 UE 可以把一些 UObject 组织成:

text 复制代码
GC Cluster
[ Root ]
   |
 ┌─┼─┬─┬─┐
 A B C D E

然后:Cluster Root 活着,可以快速认为这个簇相关对象整体处于存活状态。或者整个 Cluster 不可达,就按簇处理。核心目的就两个字:性能。相关资料也描述了 UE 通过对象簇减少对子对象逐个追踪和处理的成本。

十九、把整篇文章压缩成一张图

你以后复习只需要记这个:

text 复制代码
                  UE UObject GC
                       │
                       ▼
                CollectGarbage
                       │
                       ▼
             找到 Root / 活跃对象
                       │
                       ▼
              Reachability Analysis
                       │
            ┌──────────┴──────────┐
            ▼                     ▼
       Reachable               Unreachable
       可达对象                  不可达对象
可达对象                  不可达对象
            │                     │
            │                     ▼
            │                标记为 Garbage
            │                     │
            │                     ▼
            │                BeginDestroy
            │                     │
            │                     ▼
            │                FinishDestroy
            │                     │
            │                     ▼
            │                   Purge
            │
            ▼
           保留

这基本就是整篇文章的骨架。

二十、结合你做 UE C++,最应该记哪些?

如果你是为了 UE Gameplay Programmer 面试,这篇文章不用把源码全部背下来。我建议你分三个层级。

第一层:必须会

这几个一定会:

  • UE 的 GC 主要针对 UObject
  • UObject 通常不能直接 delete
  • UE 使用 可达性分析 + Mark/Sweep
  • GC 从 Root Set 开始遍历
  • 可达对象保留,不可达对象回收
  • UPROPERTY / UE 反射可让 UObject 引用被 GC 正确追踪
  • AddToRoot() 可以阻止 UObject 被 GC
  • Actor 用 Destroy(),不是 delete

第二层:中级 UE 面试

最好知道:

text 复制代码
CollectGarbage
CollectGarbageInternal
Reachability Analysis
BeginDestroy
FinishDestroy
Incremental Purge

以及:

  • GC 会有性能开销
  • 大量 UObject 会增加 GC 成本

第三层:引擎岗 / 高级面试

才需要深入:

text 复制代码
GUObjectArray
FUObjectItem
EObjectFlags
EInternalObjectFlags
FGCObject
Token Stream
FGCReferenceProcessor
TFastReferenceCollector
GC Cluster
Parallel GC

如果你主要投 Gameplay,这一级属于加分项,没必要一开始陷进源码。

二十一、面试最可能怎么问?

比如面试官:"UE 的垃圾回收机制了解吗?"你可以答:

UE 的 UObject 使用 GC 管理生命周期,核心是基于可达性分析的 Mark-Sweep。GC 会从 Root Set 等确定存活的对象开始,沿 UObject 引用关系遍历,把能访问到的对象标记为 Reachable,剩余对象认为是 Unreachable,之后进入销毁和清理流程。在 Gameplay 开发中,UObject 引用通常通过 UPROPERTY 或现代 UE 中对应的 UObject 引用类型让反射和 GC 系统能够追踪;如果需要一个 UObject 强制长期存活,也可以使用 AddToRoot,不过之后要 RemoveFromRoot。Actor 则通常通过 Destroy() 请求销毁,而不是手动 delete。

这已经是一个相当靠谱的 Gameplay 面试回答。

然后面试官可能追问:

为什么 UObject 不能用 shared_ptr 管?

你可以答:UObject 自己已经属于 UE 的对象系统,生命周期主要由 GC 管理;普通 C++ 对象才更常用 UE 的 TSharedPtr/TWeakPtr 或标准智能指针。两套生命周期系统不能简单混用。

再追:UPROPERTY 有什么作用?

不要只说:"暴露给蓝图。"这是新人答案。应该说:UPROPERTY 是 UE 反射系统的一部分,可以提供编辑器、序列化、网络复制等元数据,同时对于 UObject 引用来说,也能让 GC 系统识别需要追踪的引用关系。这样一下就显得你懂 UE 对象系统了。

再追:为什么 GC 会导致游戏卡顿?

答:GC 的 Mark 阶段需要遍历 UObject 引用图,可达性分析本身有 CPU 成本;对象数量和引用关系复杂时成本会增大。清扫大量对象也有成本,因此 UE 会通过增量清扫、并行处理、GC Cluster 等方式优化。这个回答已经比较进阶。

最后:记忆口诀

你可以把 UE GC 记成:

"从根出发,顺藤摸瓜;找到的活,找不到的杀。"

对应:

text 复制代码
Root
 ↓
Reachability
 ↓
Mark
 ↓
Unreachable
 ↓
Purge

这其实就是这篇看起来源码很多的文章,最核心的东西。

另外有一点值得注意:这篇文章写的是 UE 4.26,今天 UE5 的具体 GC 实现细节、类型和优化机制已经有演进,所以我建议你把文章用来理解 GC 思想,而不要把里面每一个源码函数/结构体当成 UE5.6/5.7 的绝对现状。文章本身确实以 UE4.26 为背景。

相关推荐
清泓y1 个月前
UE移动开发技术面试题
android·面试·ue5·ue4·游戏程序
清泓y1 个月前
UE基础知识与引擎架构面试题
面试·架构·ue5·ue4·游戏程序
Duo1J1 个月前
【UE】Slate 编辑器工具开发03 - 节点编辑器 (EdGraph)
ue5·编辑器·游戏引擎·ue4
学习!!!2 个月前
针对虚幻引擎4中光线追踪内容兼容性的实用解决方案
ue4
Duo1J2 个月前
【UE】Slate 编辑器工具开发01 - Slate语法 & 常用控件
ue5·编辑器·游戏引擎·ue4
DoomGT2 个月前
UE5 - 制作游戏中切换窗口后游戏自动暂停功能
游戏·ue5·ue4·虚幻·虚幻引擎·unreal engine
Yuk丶3 个月前
厌倦了假AI对话?本地 LLM 语音对话 + 口型同步系统 2.0(已开源!)
c++·人工智能·语言模型·开源·ue4·语音识别·游戏开发
归真仙人3 个月前
【UE】LineTraceByProfile
ue5·游戏引擎·ue4·unreal engine
上山老人3 个月前
UE4布娃娃约束修改
ue4