引言
这篇文章讲的是 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 为背景。