UE GC(垃圾回收)与内存管理完整解析
目录
- [GC 总览:它是什么,为什么要关心它](#GC 总览:它是什么,为什么要关心它)
- [创建 UObject 的三种方法](#创建 UObject 的三种方法)
- [GC 核心原理详解](#GC 核心原理详解)
- [引用保持------如何防止对象被 GC 回收](#引用保持——如何防止对象被 GC 回收)
- [GC 的触发](#GC 的触发)
- [GC 的优化](#GC 的优化)
- [GC 相关的常用工具](#GC 相关的常用工具)
一、GC 总览:它是什么,为什么要关心它
1.1 通俗解释
GC(Garbage Collection,垃圾回收) 就像一个"自动清洁工"。你在游戏里创建了一把剑、一个特效、一个 NPC ------ 这些都是内存中的对象。当这些对象不再被任何人使用时,GC 会自动把它们回收掉,把内存还回去。
如果没有 GC 会怎样?
- 不回收 → 内存越来越大 → 游戏卡顿 → 最终 OOM 崩溃
- 回收太早(对象还在被使用时就回收了) → 访问空指针 → 崩溃
UE GC 的核心思路:
从"根集"出发,标记所有能"摸到"的对象为"活的",摸不到的就是"垃圾",清除掉。
这就是经典的 标记-清除(Mark-Sweep) 算法,和 Java、C# 的 GC 思路一致。
1.2 UE GC 的整体流程
┌──────────────────────────────────────────────────────────────────┐
│ UE GC 执行流程 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ 1. 触发条件满足(定时/手动/内存阈值) │
│ │ │
│ ▼ │
│ 2. 锁定 ------ 暂停游戏线程,防止并发修改对象图 │
│ │ │
│ ▼ │
│ 3. 标记阶段(Mark Phase) │
│ ├─ 从根集(Root Set)出发 │
│ ├─ 遍历 UPROPERTY 引用链 │
│ ├─ 遍历 FGCObject::AddReferencedObjects │
│ └─ 标记所有可达对象为 Reachable │
│ │ │
│ ▼ │
│ 4. 清除阶段(Sweep Phase) │
│ ├─ 遍历 GUObjectArray │
│ ├─ 未被标记的对象 → 调用 BeginDestroy() → 回收内存 │
│ └─ 使用并查集(Cluster)加速簇对象处理 │
│ │ │
│ ▼ │
│ 5. 解锁 ------ 恢复游戏线程 │
│ │
└──────────────────────────────────────────────────────────────────┘
1.3 核心概览
下面对每个章节的核心做一个总结
GC 是怎么判断回收谁的? (第三章)
标记阶段:UE从根集,进行广度优先搜索,沿着 UPROPERTY 引用链遍历,通过反射系统找出当前对象的所有的 UObject 引用,然后将其标记为可达。
清除阶段:不是可达的物体则对其进行清除(调用其Destroy、析构函数、释放其内存)
那我怎么让 GC 知道"这个对象我还在用"? (第四章)
UE 给了三种方式:
- UPROPERTY 标一下:99% 的场景用它。GC 通过反射就看见了。忘了加 → 对象被回收 → 崩溃。
- AddToRoot:钉死在根集里,适合全局单例管理器。
- FGCObject :你写的是纯 C++ 类(不是 UObject)但里面存了 UObject 指针,就继承它,在
AddReferencedObjects里报告一下。
GC 什么时候会执行? (第五章)
引擎自己会跑:默认 60 秒一次,加载新关卡时、对象数过多时、内存压力大时也会跑。
有些时候你希望自己主动调一次------刚释放了一大堆东西 的那个时刻。典型的:对象数逼近上限、换装/切关/CG 结束/战斗结束/卸 Pak 之后。原则是用一次可控的小卡顿 换下一阶段的低内存水位。
GC 太慢了怎么办?UE 自己做了什么优化? (第六章)
一次要扫几十万上百万个对象肯定不能硬扫,UE 干了三件事:
- Cluster 簇:把一堆生死绑在一起的对象(比如一张贴图和它所有的 Mip)打包成一个簇,扫的时候只看簇根就行,省下海量遍历。
- 增量清除 :标记必须一次性做完,但清除可以分到多帧慢慢做,每帧最多花 2ms,不卡帧。
- 对象池(项目层做) :对频繁新建销毁的小对象,别每次都 new/destroy,复用着用,从源头减少 GC 的活儿。
核心:
GC = 从根出发摸引用,摸不到的就杀;你要做的只有两件事------让 GC 看得见你要保留的,在合适的时机提醒它开工。
二、创建 UObject 的三种方法
在介绍GC前,需要先说明创建UObject的三种方法,这样才知道这些物体是会进入到UE的GC链上的。
2.1 NewObject
创建物体不能用new
cpp
// ❌ 错误写法 ------ 使用标准 new
AMyActor* BadActor = new AMyActor();
// 问题:
// 1. 不经过 GUObjectArray 注册 → GC 不知道这个对象的存在
// 2. 不执行 FObjectInitializer → 属性可能未正确初始化
// 3. 内存不从 UE 池中分配 → 无法被引擎统一追踪
正确的方式是用 NewObject
// ✅ 正确写法 ------ 使用 NewObject / SpawnActor
UMyObject* Obj = NewObject<UMyObject>(this, TEXT("MyObj"));
标准 new 绕过了 GUObjectArray(UE 维护的全局 UObject 注册表),也绕过了 FObjectInitializer(负责构造子对象、应用 CDO 默认值)。这样创建出的对象对 GC 不可见、对反射不可见、对蓝图和序列化也不可见,几乎没有任何用处。
2.2 子物体的创建
创建类里面的子物体一般用CreateDefaultSubobject
CreateDefaultSubobject(简称 CDSO)= "在构造 CDO 时,给 CDO 挂一个默认子对象"。
它干的事只有一件:让这个类的每个实例天生自带某个 UObject 组件/子对象。
CreateDefaultSubobject是用来创建子物体的
看 ACharacter 的构造函数:
cpp
ACharacter::ACharacter()
{
CapsuleComp = CreateDefaultSubobject<UCapsuleComponent>(TEXT("Capsule"));
Mesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("Mesh"));
MoveComp = CreateDefaultSubobject<UCharacterMovementComponent>(TEXT("CharMoveComp"));
}
这三行的意思是"ACharacter 这个类天生自带胶囊、Mesh、移动组件 "。后续每一个 ACharacter 派生类、每一个 Spawn 出来的实例,都会自动拥有这三个组件。
⚠️ 重点:这样创建出来的子对象,其生命周期(GC)是和父 Actor 绑定的------父对象被回收时,子对象会一起被回收,不需要手动销毁。
关于 CDO 和 CDSO 的内部实现细节,可以看另外一篇文章。
2.3 SpawnActor
Spawn 出来的 Actor,会一直被 World 所持有。
只要关卡还在,它就不会被 GC 回收。
cpp
AMyActor* Actor = GetWorld()->SpawnActor<AMyActor>(ActorClass);
如果希望 Spawn 出来的 Actor 随着某个持有者一起销毁,需要在持有者的 Destroyed / EndPlay 里主动调用这个 Actor 的 Destroy()。
2.4 三种创建方式对比
| 方式 | 适用场景 | 内存来源 | 注册到 GUObjectArray |
|---|---|---|---|
NewObject<T>() |
创建 UObject 子类(非 Actor) | GMalloc 池 | ✅ 是 |
SpawnActor<T>() |
创建 Actor(放入场景) | GMalloc 池 | ✅ 是 |
CreateDefaultSubobject<T>() |
在构造函数中创建组件/子对象 | GMalloc 池 | ✅ 是 |
标准 new T() |
普通 C++ 对象(非 UObject) | 系统 malloc | ❌ 否 |
理解了"怎么进入 GC 视野",接下来就可以看 GC 是怎么在这个视野里工作的了。
三、GC 核心原理详解
3.1 为什么需要 GC?
在原始 C++ 中,手动管理内存是一件很痛苦的事。经典的痛点是循环引用:
cpp
class A { B* b; }; // A 持有 B
class B { A* a; }; // B 也持有 A
// 两个对象互相引用,谁来先释放谁?
UE 的解决方案是自动 GC:
- 你不需要手动
deleteUObject - 引擎通过可达性分析自动检测哪些对象"不再被任何人需要"
- 自动回收这些对象占用的内存
3.2 可达性分析:Mark-and-Sweep
UE 采用经典的 "标记-清扫" 算法。核心思想一句话概括就是:从根出发能到达的就是活的,到达不了的就是垃圾。
┌──────────────┐
│ Root Set │ ← 根集合(永不回收)
└──────┬───────┘
│ 引用
┌──────────┼──────────┐
▼ ▼ ▼
┌──────────┐┌──────────┐┌──────────┐
│ GameMode ││GameState ││Controller│
└────┬─────┘└────┬─────┘└────┬─────┘
│引用 │引用 │引用
▼ ▼ ▼
┌──────────┐┌──────────┐┌──────────┐
│ Player ││ Enemy ││ Weapon │
│ (存活) ││ (存活) ││ (存活) │
└──────────┘└──────────┘└──────────┘
┌──────────────────────────┐
│ OrphanedEffect (孤立) │ ← 没有任何路径从根到达
│ (可回收) │
└──────────────────────────┘
3.3 什么是"根集"(Root Set)
根集是标记阶段的起点。以下来源会进入根集,它们持有的对象都不会被 GC 回收:
| 根集来源 | 说明 |
|---|---|
AddToRoot() 添加的对象 |
手动加入根集 |
UPROPERTY 引用的对象 |
被反射系统追踪的成员变量引用 |
| 永久对象 | CDO、引擎核心对象、当前关卡中的 Actor、GEngine/GWorld 等 |
另一方面,还有UPROPERTY的引用链
根对象(比如 GameInstance)通过 UPROPERTY 引用的链条上所有能到达的对象都是可达的。UPROPERTY 本身不是"根",它是反射系统让 GC 能够追踪下去的"桥梁"。
3.4 标记阶段(Mark)
它先将所有的对象(除根集外)都标记为不可达。
然后它会从根集,进行广度优先搜索,
每取出一个可达对象,沿着 UPROPERTY 引用链遍历,通过反射系统找出当前对象的所有的 UObject 引用(包括成员指针和容器里的元素),将其标记为可达,然后并入队。
没被打上标记的,下一阶段(清除)就会被当成垃圾回收掉。
cpp
void PerformMarkPhase() {
// Step 1: 将所有对象初始标记为 Unreachable
for (auto& Obj : GUObjectArray) {
if (!Obj.IsRootSet()) {
Obj.SetUnreachable();
} else {
Obj.SetReachable();
}
}
// Step 2: 所有根对象入队,进行广度优先搜索(BFS)
TArray<UObject*> WorkQueue;
for (auto& Obj : GUObjectArray) {
if (Obj.IsRootSet()) {
WorkQueue.Add(&Obj);
}
}
// Step 3: 沿着 UPROPERTY 引用链遍历
while (!WorkQueue.IsEmpty()) {
UObject* Current = WorkQueue.Pop();
// 通过反射系统获取当前对象的所有 UPROPERTY 字段
for (FProperty* Prop : Current->GetClass()->PropertyLink) {
if (Prop->IsA<FObjectProperty>()) {
UObject* Referenced =
static_cast<FObjectProperty*>(Prop)->GetObjectPropertyValue(Current);
if (Referenced && !Referenced->IsReachable()) {
Referenced->SetReachable();
WorkQueue.Add(Referenced);
}
}
}
}
}
3.5 清除阶段(Sweep / Purge)
标记完成后,GC 对所有"不可达"的对象执行清除:
它主要做的事就是:
1. 调用它的beginDestroy
2. 将指向它的UPROPERTY 指针置空
3. 调用它的析构函数
4. 释放内存
cpp
void PerformPurgePhase() {
for (auto& Obj : GUObjectArray) {
if (Obj.IsUnreachable()) {
Obj.BeginDestroy(); // 1. 给对象清理机会
NullifyReferencesToObject(&Obj); // 2. 把指向它的 UPROPERTY 指针置空
(&Obj)->~UObject(); // 3. 调用析构函数
GMalloc->Free(&Obj); // 4. 释放内存回 GMalloc 池
GUObjectArray.Remove(&Obj); // 5. 从注册表中移除
}
}
}
其中第 2 步尤其关键:GC 会把所有仍指向这个被销毁对象的 UPROPERTY 指针自动置为 nullptr 。这就是为什么用 UPROPERTY 保护的指针永远不会变成野指针------它要么指向有效对象,要么就是 nullptr。
四、引用保持------如何防止对象被 GC 回收
这是初中级开发者最容易踩坑 的地方。核心只有一条原则:你对 UObject 的每一个引用,都必须让 GC 知道。
UE 提供了四种方式来"让 GC 知道"。
4.1 方式一:UPROPERTY(首选、最常用)
cpp
UCLASS()
class MYGAME_API AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
// ✅ 正确:加了 UPROPERTY,GC 能看到这个引用,Weapon 不会被意外回收
UPROPERTY(VisibleAnywhere)
UWeaponComponent* Weapon;
// ⚠️ 危险!没有 UPROPERTY,GC 不知道 CachedTarget 的存在!
// 一旦其他地方不再引用 CachedTarget,它就会被回收,
// 而这里的指针变成悬空指针(Dangling Pointer)→ 访问就崩溃!
UEnemyBase* CachedTarget; // ❌ 缺少 UPROPERTY
// 容器同理:如果容器里装的是 UObject 指针,必须给容器本身加 UPROPERTY,
// 这样容器里的每一个元素都会被 GC 视为可达。
UPROPERTY()
TArray<UItemBase*> Inventory;
};
把有问题的 CachedTarget 修好,只需要加一行 UPROPERTY():
cpp
UPROPERTY()
UEnemyBase* CachedTarget; // ✅ 现在 GC 能看到它了
99% 的情况用这个就够了。 它是 GC 和开发者之间最基本的契约。
4.2 方式二:AddToRoot / RemoveFromRoot
用于手动 把对象钉死在根集里,典型场景是全局单例管理器。
cpp
MyDataAsset->AddToRoot(); // 加入根集,永远不被 GC
// ... 使用 ...
MyDataAsset->RemoveFromRoot(); // ⚠️ 记得配对调用!否则就是内存泄漏
MyDataAsset = nullptr;
典型用途是那些需要在整个游戏运行期间都存在的对象------比如全局的音频管理器、特效管理器、资源管理器等单例。对它们来说,生命周期和进程一致,直接挂进根集最简单直接。缺点是很"重"------对象一旦加进根集就不受任何引用关系约束,必须手动移除,非常容易漏。
4.3 方式三:FGCObject + AddReferencedObjects
用于非 UObject 的 C++ 类持有 UObject 指针的场景。
为什么需要它? 设想你要实现一个"UObject 对象池"。池子本身不需要反射、不需要蓝图、不需要网络复制,所以把它做成 UObject 是过度设计,自然会写成普通 C++ 类:
cpp
class FMyObjectPool // 不是 UObject
{
TArray<UObject*> ObjectPool; // 裸 UObject 指针,GC 看不见
};
这里 ObjectPool 里的对象一旦没人用了就会被 GC 回收,池子里的指针全部变成野指针。解决办法就是继承 FGCObject:
cpp
class FMyManager : public FGCObject
{
public:
virtual void AddReferencedObjects(FReferenceCollector& Collector) override
{
// 告诉 GC:这堆对象我还在用,别回收
Collector.AddReferencedObjects(ObjectPool);
}
private:
TArray<UObject*> ObjectPool;
};
FGCObject 的构造函数会把自己注册到一个全局列表中,GC 在标记阶段会调用每个实例的 AddReferencedObjects,把它们声明的引用都当成可达。相比 AddToRoot,它更优雅,因为引用的生命周期和持有者绑定,持有者析构时自动不再报告引用。
4.4 方式四:TWeakObjectPtr(弱引用,不阻止 GC 但安全)
前三种都是强引用(阻止 GC 回收)。如果你只是想"缓存"一个引用,不希望延长对象生命周期,就用弱引用:
cpp
TWeakObjectPtr<AActor> CachedTargetWeak; // 弱引用,不阻止 GC
// 使用时必须先判空
if (AActor* Target = CachedTargetWeak.Get())
{
Target->DoSomething();
}
弱引用的好处是永远安全 ------对象如果已经被 GC 回收了,Get() 会返回 nullptr,不会变成野指针。适合"我只是想看看它还在不在、不想干涉它的生死"这种场景,比如:
- AI 缓存的目标敌人引用
- UI 缓存的关注对象
- 跨系统的临时引用
4.5 四种方式速查表
| 方式 | 是否阻止 GC | 典型用途 | 使用难度 |
|---|---|---|---|
UPROPERTY(T*) |
✅ 强引用 | 成员变量持有所有权(日常 99%) | ⭐ 极简单 |
AddToRoot() |
✅ 永久阻止 | 全局单例/管理器 | ⭐⭐ 需手动配对 RemoveFromRoot |
FGCObject |
✅ 声明式 | 非 UObject 工具类持有多个 UObject | ⭐⭐⭐ 需继承并重写方法 |
TWeakObjectPtr<T> |
❌ 弱引用 | 缓存/临时引用,不拥有所有权 | ⭐ 简单且安全 |
五、GC 的触发
GC 并不是每一帧都在跑,它需要在合适的时机被"启动"。触发方式大体分两类:引擎自动触发 (满足某些条件时自己跑)和业务主动触发 (代码里显式调用)。理解这两类触发时机,是后面做优化的前提------不知道它什么时候跑,就没法谈怎么让它跑得更好。
本章先讲引擎默认的触发条件和配置参数,再讲什么时候适合由业务代码主动触发。
5.1 GC 触发时机与配置
自动触发的条件
引擎会在下面几种情况下自己发起一次 GC:
- 定时 :默认约 60 秒一次(每次 GC 循环之间的间隔,由
gc.TimeBetweenPurgingPendingKillObjects控制) - 加载新关卡时(旧关卡 Actor 清理)
GUObjectArray对象数量超过阈值- PIE 停止时
- 内存压力大时(引擎内部阈值或平台 OOM 信号)
手动触发的两种姿势
cpp
GEngine->ForceGarbageCollection(true); // true = 请求本帧末尾执行完整 GC
GEngine->ForceGarbageCollection(false); // false = 异步延迟执行
两者的核心区别是"要不要在本帧就把 Purge 也做完":true 走的是 FullPurge 模式,当场彻底清干净;false 则只是请求引擎稍后调度,清除阶段仍然按增量节奏分帧进行。
关键配置(BaseEngine.ini,[/Script/Engine.GarbageCollectionSettings] 段)
ini
; GC 触发间隔(秒)
gc.TimeBetweenPurgingPendingKillObjects=60
; 增量清除每帧最大耗时(秒)
gc.MaxTimeBetweenPurges=0.02
; 对象数量硬上限(UE5 默认约 200 万,由 gc.MaxObjectsInGame 控制;
; 也可以通过下面两个阈值在达到上限前主动预警/触发 GC)
gc.NumObjectsBeforeWarning=1500000
; 是否启用增量 GC(推荐开启)
gc.IncrementalPurgeEnabled=true
这些参数都可以在项目的 DefaultEngine.ini 里覆写,根据目标平台的内存容量和帧率预算做针对性调整。
5.2 需要的时候手动触发 GC
除了等引擎按 60 秒节奏自己跑,业务代码在几个关键节点主动触发一次 GC是常见做法------因为这些节点上一次性释放了大量对象,如果等被动 GC 处理,内存峰值会难看。下面是几类典型场景。
对象数量逼近上限时
UE 对单个进程内 UObject 总数有硬上限(由 gc.MaxObjectsInGame 控制,UE5 默认约 200 万)。一旦逼近这个上限,新创建 UObject 就会 OOM 崩溃。
一种常见做法是:在游戏内写一个周期性检查------定期统计 GUObjectArray 里的活跃对象数,一旦超过某个预设阈值(比如 90 万、100 万),就强制一次 full GC:
cpp
int32 ActiveCount = GUObjectArray.GetObjectArrayNumMinusAvailable();
if (ActiveCount >= ObjectCountThreshold)
{
GEngine->ForceGarbageCollection(true);
GEngine->PerformGarbageCollectionAndCleanupActors();
}
宁愿卡一下,也绝不能让对象数爆上限------这是经典的防御性编程。
一次性释放大量资源的业务节点
很多游戏业务会在某些节点一次性释放大批资源 ,典型的有:
此时还可以将GC的接口放在lua里进行调用。
- 角色换装:切换整套外观时会释放旧形象的贴图、骨骼网格、材质等
- 关卡切换:旧场景的 Actor、关卡资产、AI 数据大批卸载
- CG / 过场表演结束后:演出期间创建的临时特效、动态材质、粒子都不再需要
- 战斗结束:战斗期间的技能特效、临时角色实例大批失效
如果等引擎自己 60 秒后再收,这些资源就会在内存里赖着,让下一阶段"带着一身包袱"起跑。更糟的是,如果玩家在短时间内反复触发(比如连续尝试多套外观),每次都叠一层旧资源,内存峰值非常难看。
这类场景适合业务代码主动 GC:
cpp
// 确定"刚释放了一大堆东西"之后立刻 GC
GEngine->ForceGarbageCollection(true);
但要注意不要过度触发 。比如玩家每换一件衣服都 GC,会把每次换装都变成一次可感知的小卡顿。常见做法是累计 N 次再 GC:
cpp
static int32 GCounter = 0;
static int32 GThreshold = 7; // 攒够 7 次才 GC,可通过控制台变量调
if (++GCounter >= GThreshold)
{
GCounter = 0;
GEngine->ForceGarbageCollection(true);
}
需要"彻底断开引用"的系统性操作
有些操作在正确性上要求 GC 必须立刻把相关对象全部回收------最典型的就是 Pak 卸载。
Pak(.pak 文件)是 UE 的资源打包文件,热更新、DLC 按需加载等都会涉及"挂载 / 卸载 pak"。Pak 卸载后,里面的资产类(贴图、蓝图、Mesh 等)全部变成"悬空"状态,它们还被各种缓存引用着------必须立刻走一次 GC 把这些引用彻底切断,否则:
- pak 文件句柄无法释放,新 pak 覆盖或删除旧 pak 会失败
- Shader 库无法关闭(材质还引用着 shader)
- 旧 pak 里的 UClass 如果还被缓存,加载新 pak 时会出现"新老 UClass 同名但地址不同"的诡异 bug
所以卸 pak 的标准流程是:
cpp
// 1. 把所有已挂载的 pak 卸掉
for (const FString& Pak : MountedPakFiles) { Unmount(Pak); }
// 2. 立刻强制 full GC,让 pak 里的 UObject 全部回收
GEngine->ForceGarbageCollection(true);
GEngine->PerformGarbageCollectionAndCleanupActors();
// 3. 等 GC 完成后再做下一步(比如关 ShaderLibrary、挂新 pak)
这类场景和前面"为了降低内存峰值"不同------这里的 GC 是正确性要求,不是性能优化。
总结
主动 GC 的核心设计哲学可以概括为一句话:
不要等引擎自动收------在"刚释放了一大堆东西"的时机,主动要求 GC 立刻清干净。
这是用可控的小卡顿 替代不可控的内存峰值的经典权衡。
六、GC 的优化
一次完整的 GC 要处理几十万到上百万个 UObject,如果每次都老老实实"全量扫一遍、一次性清干净",对帧率是灾难性的。UE 在引擎层做了多种优化来摊薄这个成本。
6.1 Cluster 簇优化
问题背景
有很多对象天生就是绑在一起的 ------比如一张贴图和它的所有 Mip 层级、一个骨骼网格和它的 LOD、一个材质和它的 Shader 数据。这些对象的生命周期完全一致,一起活、一起死,单独标记它们是多余的。
引擎的做法:用并查集把它们打包成一个"簇"
UE 引入了 GC Cluster 机制:用并查集(Disjoint Set)数据结构把一组生命周期紧密绑定的对象打包成一个整体。GC 标记时只做一件事------问这个簇的根对象是否可达:
- 根对象可达 → 整个簇视为可达,无需逐个遍历
- 根对象不可达 → 整个簇一起回收
对于大型贴图、骨骼网格、复杂材质等"内部结构庞大"的资源,Cluster 能把标记成本从"扫几百个子对象"压缩到"查一次根",收益明显。
6.2 增量式清除(Incremental Purge)
问题背景
假设一次 GC 要回收 5000 个对象------每个对象都要依次调用 BeginDestroy → FinishDestroy → ~UObject → GMalloc::Free。如果一帧内干完,轻则卡顿十几毫秒,重则直接掉一两帧。
引擎的做法:把清除工作拆到多帧完成
标记阶段 必须一次性搞定(STW,因为不能让游戏线程中途改引用关系),但清除阶段 不一样------对象已经被判定为"死亡",业务代码也不再通过 IsValid 使用它们,真正释放内存放在哪一帧都不影响正确性。
所以 UE 把清除阶段拆开:
正常游戏循环:
Frame 100: Tick → Render → [GC Purge: 处理 50 个对象,耗时约 2ms] → Present
Frame 101: Tick → Render → [GC Purge: 处理 50 个对象,耗时约 2ms] → Present
Frame 102: Tick → Render → [GC Purge: 处理 30 个对象,耗时约 1.2ms] → Present
Frame 103: Tick → Render → [全部清除完成] → Present
默认每帧最多消耗约 2ms 用于清除 。这也是为什么 Destroy() 调用后对象不会立即消失------它只是被标记为 PendingKill,真正的销毁要跨若干帧才能完成。
控制参数
ini
gc.IncrementalPurgeEnabled=true ; 启用增量清除(默认开)
gc.MaxTimeBetweenPurges=0.02 ; 每帧最多 2ms 用于 Purge
强制同步清除的场景
如果调用 ForceGarbageCollection(true)(bFullPurge=true),引擎会强制在本帧把所有清除做完,不分帧。代价是一次明显的卡顿,但能立刻把内存吐回去------前面第五章讲到的"换装后""卸 Pak 后""对象数超阈值时"等场景,用的就是这种模式(详见上一章 5.2 节)。
6.3 UObject 对象池
问题背景
频繁创建/销毁 UObject 是 GC 的噩梦 。每创建一个新 UObject 要分配内存、注册到 GUObjectArray、构造;销毁时又要走完整的三阶段销毁流程。如果每帧都在新建释放几百个小对象,GC 的工作量直接爆炸。
典型场景是那些需要高频创建临时小对象 的子系统(比如大量 NPC 生成/回收、粒子相关的临时数据对象、UI 频繁开关的子控件等)。对这类场景,标准做法是用对象池------让对象复用,而不是反复创建-销毁,从源头上减少 GC 的工作量。
一个典型的 UObject 对象池实现大致长这样:
cpp
template<typename ObjectType, uint32 MAX = MAX_uint32>
class TSimpleObjectPool : public FGCObject // ★ 继承 FGCObject,让 GC 看见池里的对象
{
public:
ObjectType* GetFromPool(); // 取一个,池里有就复用,没有就 NewObject
void ReturnToPool(ObjectType* Object); // 还一个,放回池里待下次复用
virtual void AddReferencedObjects(FReferenceCollector& Collector) override
{
Collector.AddReferencedObjects(ObjectPool); // ★ 告诉 GC:"池里这些对象我还在用"
}
private:
TArray<ObjectType*> ObjectPool;
mutable FCriticalSection CriticalSection;
};
几个关键设计点:
- 池类本身不是 UObject :池子是纯 C++ 类(不需要反射、不需要蓝图),但它持有的是裸 UObject 指针------靠继承
FGCObject并在AddReferencedObjects里把池里的对象都报告给 GC,让它们不被误回收。这是FGCObject最经典的用法之一。 - 复用而非销毁 :
ReturnToPool里不调Destroy,只是把对象放回数组。下一次GetFromPool直接拿出来重置状态即可。 - 用
Rename切换 Outer :进阶做法是在入池时调用UObject::Rename把对象的 Outer 改到一个中立位置(比如nullptr或专用的"池 Outer"),避免原持有者销毁时把池里的对象也拖死。 - 线程安全 :如果池可能被多线程访问,需要加锁;但要注意
NewObject本身受 UE 反射系统限制,一般要求在游戏线程调用。
七、GC 相关的常用工具
理解 GC 原理是为了不出问题 ,熟悉工具则是为了出了问题能快速定位 。本章不深入任何单个工具的实现细节,只做一个**"地图级别"的介绍------让你知道做什么事该找什么工具**,真正要用时再翻对应的文档或代码。
7.1 控制台命令(~ 打开控制台输入)
| 命令 | 大概作用 |
|---|---|
stat memory |
看当前各模块内存占用总览 |
stat memoryplatform |
看平台相关的物理内存/虚拟内存状态 |
obj list class=XXX |
列出指定类型当前活着的对象数量和实例 |
obj gc |
打印 GC 统计信息 |
gc.Flush |
立刻执行一次完整 GC |
memreport -full |
导出一份完整内存快照到 Saved/Profiling/MemReports/ |
什么时候用:快速验证问题最顺手的工具。想知道"某类对象到底有多少""GC 到底收没收下去""刚才那波操作后内存变化了多少",直接敲命令就行。
7.2 Unreal Insights
UE 4.26+ 提供的图形化性能/内存分析器,能记录一段时间内的:
- 每帧 CPU / GPU 耗时(按线程、按函数)
- 内存分配情况(谁分配、分配到哪)
- GC 的触发时机和耗时
- 资源加载时间线
什么时候用 :做深度性能/内存诊断时的首选 。比起控制台命令,它能看到时间维度的完整轨迹------适合回答"这个卡顿是 GC 造成的还是加载造成的"这类问题。
7.3 PerfDog


7.4 工具选型小结
如果把上面的工具按"问题类型"对应起来:
| 问题 | 首选工具 |
|---|---|
| 想知道当前总内存占用 | stat memory |
| 想知道哪类资源最占内存 | memreport -full / Session Frontend |
| 想定位时间轴上的性能/内存波动 | Unreal Insights |
| 某个对象明明该死却没死 | 自建的引用链追踪工具 |
| 对象数量逼近 UE 硬上限 | 自建的对象数量守卫 |
| OOM 崩溃归因 | 自建 OOM 原因翻译 + crash 上报 |
| 想在对象被 GC 时做配套动作 | AddUObjectDeleteListener |
| 脚本层想主动 GC | 通过 BlueprintCallable / Lua 绑定暴露的接口 |
| 临时验证 "GC 有没有生效" | 控制台 gc.Flush + obj gc |
UE GC(垃圾回收)与内存管理完整解析
目录
- [GC 总览:它是什么,为什么要关心它](#GC 总览:它是什么,为什么要关心它)
- [创建 UObject 的三种方法](#创建 UObject 的三种方法)
- [GC 核心原理详解](#GC 核心原理详解)
- [引用保持------如何防止对象被 GC 回收](#引用保持——如何防止对象被 GC 回收)
- [GC 的触发](#GC 的触发)
- [GC 的优化](#GC 的优化)
- [GC 相关的常用工具](#GC 相关的常用工具)
一、GC 总览:它是什么,为什么要关心它
1.1 通俗解释
GC(Garbage Collection,垃圾回收) 就像一个"自动清洁工"。你在游戏里创建了一把剑、一个特效、一个 NPC ------ 这些都是内存中的对象。当这些对象不再被任何人使用时,GC 会自动把它们回收掉,把内存还回去。
如果没有 GC 会怎样?
- 不回收 → 内存越来越大 → 游戏卡顿 → 最终 OOM 崩溃
- 回收太早(对象还在被使用时就回收了) → 访问空指针 → 崩溃
UE GC 的核心思路:
从"根集"出发,标记所有能"摸到"的对象为"活的",摸不到的就是"垃圾",清除掉。
这就是经典的 标记-清除(Mark-Sweep) 算法,和 Java、C# 的 GC 思路一致。
1.2 UE GC 的整体流程
┌──────────────────────────────────────────────────────────────────┐
│ UE GC 执行流程 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ 1. 触发条件满足(定时/手动/内存阈值) │
│ │ │
│ ▼ │
│ 2. 锁定 ------ 暂停游戏线程,防止并发修改对象图 │
│ │ │
│ ▼ │
│ 3. 标记阶段(Mark Phase) │
│ ├─ 从根集(Root Set)出发 │
│ ├─ 遍历 UPROPERTY 引用链 │
│ ├─ 遍历 FGCObject::AddReferencedObjects │
│ └─ 标记所有可达对象为 Reachable │
│ │ │
│ ▼ │
│ 4. 清除阶段(Sweep Phase) │
│ ├─ 遍历 GUObjectArray │
│ ├─ 未被标记的对象 → 调用 BeginDestroy() → 回收内存 │
│ └─ 使用并查集(Cluster)加速簇对象处理 │
│ │ │
│ ▼ │
│ 5. 解锁 ------ 恢复游戏线程 │
│ │
└──────────────────────────────────────────────────────────────────┘
1.3 核心概览
下面对每个章节的核心做一个总结
GC 是怎么判断回收谁的? (第三章)
标记阶段:UE从根集,进行广度优先搜索,沿着 UPROPERTY 引用链遍历,通过反射系统找出当前对象的所有的 UObject 引用,然后将其标记为可达。
清除阶段:不是可达的物体则对其进行清除(调用其Destroy、析构函数、释放其内存)
那我怎么让 GC 知道"这个对象我还在用"? (第四章)
UE 给了三种方式:
- UPROPERTY 标一下:99% 的场景用它。GC 通过反射就看见了。忘了加 → 对象被回收 → 崩溃。
- AddToRoot:钉死在根集里,适合全局单例管理器。
- FGCObject :你写的是纯 C++ 类(不是 UObject)但里面存了 UObject 指针,就继承它,在
AddReferencedObjects里报告一下。
GC 什么时候会执行? (第五章)
引擎自己会跑:默认 60 秒一次,加载新关卡时、对象数过多时、内存压力大时也会跑。
有些时候你希望自己主动调一次------刚释放了一大堆东西 的那个时刻。典型的:对象数逼近上限、换装/切关/CG 结束/战斗结束/卸 Pak 之后。原则是用一次可控的小卡顿 换下一阶段的低内存水位。
GC 太慢了怎么办?UE 自己做了什么优化? (第六章)
一次要扫几十万上百万个对象肯定不能硬扫,UE 干了三件事:
- Cluster 簇:把一堆生死绑在一起的对象(比如一张贴图和它所有的 Mip)打包成一个簇,扫的时候只看簇根就行,省下海量遍历。
- 增量清除 :标记必须一次性做完,但清除可以分到多帧慢慢做,每帧最多花 2ms,不卡帧。
- 对象池(项目层做) :对频繁新建销毁的小对象,别每次都 new/destroy,复用着用,从源头减少 GC 的活儿。
核心:
GC = 从根出发摸引用,摸不到的就杀;你要做的只有两件事------让 GC 看得见你要保留的,在合适的时机提醒它开工。
二、创建 UObject 的三种方法
在介绍GC前,需要先说明创建UObject的三种方法,这样才知道这些物体是会进入到UE的GC链上的。
2.1 NewObject
创建物体不能用new
cpp
// ❌ 错误写法 ------ 使用标准 new
AMyActor* BadActor = new AMyActor();
// 问题:
// 1. 不经过 GUObjectArray 注册 → GC 不知道这个对象的存在
// 2. 不执行 FObjectInitializer → 属性可能未正确初始化
// 3. 内存不从 UE 池中分配 → 无法被引擎统一追踪
正确的方式是用 NewObject
// ✅ 正确写法 ------ 使用 NewObject / SpawnActor
UMyObject* Obj = NewObject<UMyObject>(this, TEXT("MyObj"));
标准 new 绕过了 GUObjectArray(UE 维护的全局 UObject 注册表),也绕过了 FObjectInitializer(负责构造子对象、应用 CDO 默认值)。这样创建出的对象对 GC 不可见、对反射不可见、对蓝图和序列化也不可见,几乎没有任何用处。
2.2 子物体的创建
创建类里面的子物体一般用CreateDefaultSubobject
CreateDefaultSubobject(简称 CDSO)= "在构造 CDO 时,给 CDO 挂一个默认子对象"。
它干的事只有一件:让这个类的每个实例天生自带某个 UObject 组件/子对象。
CreateDefaultSubobject是用来创建子物体的
看 ACharacter 的构造函数:
cpp
ACharacter::ACharacter()
{
CapsuleComp = CreateDefaultSubobject<UCapsuleComponent>(TEXT("Capsule"));
Mesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("Mesh"));
MoveComp = CreateDefaultSubobject<UCharacterMovementComponent>(TEXT("CharMoveComp"));
}
这三行的意思是"ACharacter 这个类天生自带胶囊、Mesh、移动组件 "。后续每一个 ACharacter 派生类、每一个 Spawn 出来的实例,都会自动拥有这三个组件。
⚠️ 重点:这样创建出来的子对象,其生命周期(GC)是和父 Actor 绑定的------父对象被回收时,子对象会一起被回收,不需要手动销毁。
关于 CDO 和 CDSO 的内部实现细节,可以看另外一篇文章。
2.3 SpawnActor
Spawn 出来的 Actor,会一直被 World 所持有。
只要关卡还在,它就不会被 GC 回收。
cpp
AMyActor* Actor = GetWorld()->SpawnActor<AMyActor>(ActorClass);
如果希望 Spawn 出来的 Actor 随着某个持有者一起销毁,需要在持有者的 Destroyed / EndPlay 里主动调用这个 Actor 的 Destroy()。
2.4 三种创建方式对比
| 方式 | 适用场景 | 内存来源 | 注册到 GUObjectArray |
|---|---|---|---|
NewObject<T>() |
创建 UObject 子类(非 Actor) | GMalloc 池 | ✅ 是 |
SpawnActor<T>() |
创建 Actor(放入场景) | GMalloc 池 | ✅ 是 |
CreateDefaultSubobject<T>() |
在构造函数中创建组件/子对象 | GMalloc 池 | ✅ 是 |
标准 new T() |
普通 C++ 对象(非 UObject) | 系统 malloc | ❌ 否 |
理解了"怎么进入 GC 视野",接下来就可以看 GC 是怎么在这个视野里工作的了。
三、GC 核心原理详解
3.1 为什么需要 GC?
在原始 C++ 中,手动管理内存是一件很痛苦的事。经典的痛点是循环引用:
cpp
class A { B* b; }; // A 持有 B
class B { A* a; }; // B 也持有 A
// 两个对象互相引用,谁来先释放谁?
UE 的解决方案是自动 GC:
- 你不需要手动
deleteUObject - 引擎通过可达性分析自动检测哪些对象"不再被任何人需要"
- 自动回收这些对象占用的内存
3.2 可达性分析:Mark-and-Sweep
UE 采用经典的 "标记-清扫" 算法。核心思想一句话概括就是:从根出发能到达的就是活的,到达不了的就是垃圾。
┌──────────────┐
│ Root Set │ ← 根集合(永不回收)
└──────┬───────┘
│ 引用
┌──────────┼──────────┐
▼ ▼ ▼
┌──────────┐┌──────────┐┌──────────┐
│ GameMode ││GameState ││Controller│
└────┬─────┘└────┬─────┘└────┬─────┘
│引用 │引用 │引用
▼ ▼ ▼
┌──────────┐┌──────────┐┌──────────┐
│ Player ││ Enemy ││ Weapon │
│ (存活) ││ (存活) ││ (存活) │
└──────────┘└──────────┘└──────────┘
┌──────────────────────────┐
│ OrphanedEffect (孤立) │ ← 没有任何路径从根到达
│ (可回收) │
└──────────────────────────┘
3.3 什么是"根集"(Root Set)
根集是标记阶段的起点。以下来源会进入根集,它们持有的对象都不会被 GC 回收:
| 根集来源 | 说明 |
|---|---|
AddToRoot() 添加的对象 |
手动加入根集 |
UPROPERTY 引用的对象 |
被反射系统追踪的成员变量引用 |
| 永久对象 | CDO、引擎核心对象、当前关卡中的 Actor、GEngine/GWorld 等 |
另一方面,还有UPROPERTY的引用链
根对象(比如 GameInstance)通过 UPROPERTY 引用的链条上所有能到达的对象都是可达的。UPROPERTY 本身不是"根",它是反射系统让 GC 能够追踪下去的"桥梁"。
3.4 标记阶段(Mark)
它先将所有的对象(除根集外)都标记为不可达。
然后它会从根集,进行广度优先搜索,
每取出一个可达对象,沿着 UPROPERTY 引用链遍历,通过反射系统找出当前对象的所有的 UObject 引用(包括成员指针和容器里的元素),将其标记为可达,然后并入队。
没被打上标记的,下一阶段(清除)就会被当成垃圾回收掉。
cpp
void PerformMarkPhase() {
// Step 1: 将所有对象初始标记为 Unreachable
for (auto& Obj : GUObjectArray) {
if (!Obj.IsRootSet()) {
Obj.SetUnreachable();
} else {
Obj.SetReachable();
}
}
// Step 2: 所有根对象入队,进行广度优先搜索(BFS)
TArray<UObject*> WorkQueue;
for (auto& Obj : GUObjectArray) {
if (Obj.IsRootSet()) {
WorkQueue.Add(&Obj);
}
}
// Step 3: 沿着 UPROPERTY 引用链遍历
while (!WorkQueue.IsEmpty()) {
UObject* Current = WorkQueue.Pop();
// 通过反射系统获取当前对象的所有 UPROPERTY 字段
for (FProperty* Prop : Current->GetClass()->PropertyLink) {
if (Prop->IsA<FObjectProperty>()) {
UObject* Referenced =
static_cast<FObjectProperty*>(Prop)->GetObjectPropertyValue(Current);
if (Referenced && !Referenced->IsReachable()) {
Referenced->SetReachable();
WorkQueue.Add(Referenced);
}
}
}
}
}
3.5 清除阶段(Sweep / Purge)
标记完成后,GC 对所有"不可达"的对象执行清除:
它主要做的事就是:
1. 调用它的beginDestroy
2. 将指向它的UPROPERTY 指针置空
3. 调用它的析构函数
4. 释放内存
cpp
void PerformPurgePhase() {
for (auto& Obj : GUObjectArray) {
if (Obj.IsUnreachable()) {
Obj.BeginDestroy(); // 1. 给对象清理机会
NullifyReferencesToObject(&Obj); // 2. 把指向它的 UPROPERTY 指针置空
(&Obj)->~UObject(); // 3. 调用析构函数
GMalloc->Free(&Obj); // 4. 释放内存回 GMalloc 池
GUObjectArray.Remove(&Obj); // 5. 从注册表中移除
}
}
}
其中第 2 步尤其关键:GC 会把所有仍指向这个被销毁对象的 UPROPERTY 指针自动置为 nullptr 。这就是为什么用 UPROPERTY 保护的指针永远不会变成野指针------它要么指向有效对象,要么就是 nullptr。
四、引用保持------如何防止对象被 GC 回收
这是初中级开发者最容易踩坑 的地方。核心只有一条原则:你对 UObject 的每一个引用,都必须让 GC 知道。
UE 提供了四种方式来"让 GC 知道"。
4.1 方式一:UPROPERTY(首选、最常用)
cpp
UCLASS()
class MYGAME_API AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
// ✅ 正确:加了 UPROPERTY,GC 能看到这个引用,Weapon 不会被意外回收
UPROPERTY(VisibleAnywhere)
UWeaponComponent* Weapon;
// ⚠️ 危险!没有 UPROPERTY,GC 不知道 CachedTarget 的存在!
// 一旦其他地方不再引用 CachedTarget,它就会被回收,
// 而这里的指针变成悬空指针(Dangling Pointer)→ 访问就崩溃!
UEnemyBase* CachedTarget; // ❌ 缺少 UPROPERTY
// 容器同理:如果容器里装的是 UObject 指针,必须给容器本身加 UPROPERTY,
// 这样容器里的每一个元素都会被 GC 视为可达。
UPROPERTY()
TArray<UItemBase*> Inventory;
};
把有问题的 CachedTarget 修好,只需要加一行 UPROPERTY():
cpp
UPROPERTY()
UEnemyBase* CachedTarget; // ✅ 现在 GC 能看到它了
99% 的情况用这个就够了。 它是 GC 和开发者之间最基本的契约。
4.2 方式二:AddToRoot / RemoveFromRoot
用于手动 把对象钉死在根集里,典型场景是全局单例管理器。
cpp
MyDataAsset->AddToRoot(); // 加入根集,永远不被 GC
// ... 使用 ...
MyDataAsset->RemoveFromRoot(); // ⚠️ 记得配对调用!否则就是内存泄漏
MyDataAsset = nullptr;
典型用途是那些需要在整个游戏运行期间都存在的对象------比如全局的音频管理器、特效管理器、资源管理器等单例。对它们来说,生命周期和进程一致,直接挂进根集最简单直接。缺点是很"重"------对象一旦加进根集就不受任何引用关系约束,必须手动移除,非常容易漏。
4.3 方式三:FGCObject + AddReferencedObjects
用于非 UObject 的 C++ 类持有 UObject 指针的场景。
为什么需要它? 设想你要实现一个"UObject 对象池"。池子本身不需要反射、不需要蓝图、不需要网络复制,所以把它做成 UObject 是过度设计,自然会写成普通 C++ 类:
cpp
class FMyObjectPool // 不是 UObject
{
TArray<UObject*> ObjectPool; // 裸 UObject 指针,GC 看不见
};
这里 ObjectPool 里的对象一旦没人用了就会被 GC 回收,池子里的指针全部变成野指针。解决办法就是继承 FGCObject:
cpp
class FMyManager : public FGCObject
{
public:
virtual void AddReferencedObjects(FReferenceCollector& Collector) override
{
// 告诉 GC:这堆对象我还在用,别回收
Collector.AddReferencedObjects(ObjectPool);
}
private:
TArray<UObject*> ObjectPool;
};
FGCObject 的构造函数会把自己注册到一个全局列表中,GC 在标记阶段会调用每个实例的 AddReferencedObjects,把它们声明的引用都当成可达。相比 AddToRoot,它更优雅,因为引用的生命周期和持有者绑定,持有者析构时自动不再报告引用。
4.4 方式四:TWeakObjectPtr(弱引用,不阻止 GC 但安全)
前三种都是强引用(阻止 GC 回收)。如果你只是想"缓存"一个引用,不希望延长对象生命周期,就用弱引用:
cpp
TWeakObjectPtr<AActor> CachedTargetWeak; // 弱引用,不阻止 GC
// 使用时必须先判空
if (AActor* Target = CachedTargetWeak.Get())
{
Target->DoSomething();
}
弱引用的好处是永远安全 ------对象如果已经被 GC 回收了,Get() 会返回 nullptr,不会变成野指针。适合"我只是想看看它还在不在、不想干涉它的生死"这种场景,比如:
- AI 缓存的目标敌人引用
- UI 缓存的关注对象
- 跨系统的临时引用
4.5 四种方式速查表
| 方式 | 是否阻止 GC | 典型用途 | 使用难度 |
|---|---|---|---|
UPROPERTY(T*) |
✅ 强引用 | 成员变量持有所有权(日常 99%) | ⭐ 极简单 |
AddToRoot() |
✅ 永久阻止 | 全局单例/管理器 | ⭐⭐ 需手动配对 RemoveFromRoot |
FGCObject |
✅ 声明式 | 非 UObject 工具类持有多个 UObject | ⭐⭐⭐ 需继承并重写方法 |
TWeakObjectPtr<T> |
❌ 弱引用 | 缓存/临时引用,不拥有所有权 | ⭐ 简单且安全 |
五、GC 的触发
GC 并不是每一帧都在跑,它需要在合适的时机被"启动"。触发方式大体分两类:引擎自动触发 (满足某些条件时自己跑)和业务主动触发 (代码里显式调用)。理解这两类触发时机,是后面做优化的前提------不知道它什么时候跑,就没法谈怎么让它跑得更好。
本章先讲引擎默认的触发条件和配置参数,再讲什么时候适合由业务代码主动触发。
5.1 GC 触发时机与配置
自动触发的条件
引擎会在下面几种情况下自己发起一次 GC:
- 定时 :默认约 60 秒一次(每次 GC 循环之间的间隔,由
gc.TimeBetweenPurgingPendingKillObjects控制) - 加载新关卡时(旧关卡 Actor 清理)
GUObjectArray对象数量超过阈值- PIE 停止时
- 内存压力大时(引擎内部阈值或平台 OOM 信号)
手动触发的两种姿势
cpp
GEngine->ForceGarbageCollection(true); // true = 请求本帧末尾执行完整 GC
GEngine->ForceGarbageCollection(false); // false = 异步延迟执行
两者的核心区别是"要不要在本帧就把 Purge 也做完":true 走的是 FullPurge 模式,当场彻底清干净;false 则只是请求引擎稍后调度,清除阶段仍然按增量节奏分帧进行。
关键配置(BaseEngine.ini,[/Script/Engine.GarbageCollectionSettings] 段)
ini
; GC 触发间隔(秒)
gc.TimeBetweenPurgingPendingKillObjects=60
; 增量清除每帧最大耗时(秒)
gc.MaxTimeBetweenPurges=0.02
; 对象数量硬上限(UE5 默认约 200 万,由 gc.MaxObjectsInGame 控制;
; 也可以通过下面两个阈值在达到上限前主动预警/触发 GC)
gc.NumObjectsBeforeWarning=1500000
; 是否启用增量 GC(推荐开启)
gc.IncrementalPurgeEnabled=true
这些参数都可以在项目的 DefaultEngine.ini 里覆写,根据目标平台的内存容量和帧率预算做针对性调整。
5.2 需要的时候手动触发 GC
除了等引擎按 60 秒节奏自己跑,业务代码在几个关键节点主动触发一次 GC是常见做法------因为这些节点上一次性释放了大量对象,如果等被动 GC 处理,内存峰值会难看。下面是几类典型场景。
对象数量逼近上限时
UE 对单个进程内 UObject 总数有硬上限(由 gc.MaxObjectsInGame 控制,UE5 默认约 200 万)。一旦逼近这个上限,新创建 UObject 就会 OOM 崩溃。
一种常见做法是:在游戏内写一个周期性检查------定期统计 GUObjectArray 里的活跃对象数,一旦超过某个预设阈值(比如 90 万、100 万),就强制一次 full GC:
cpp
int32 ActiveCount = GUObjectArray.GetObjectArrayNumMinusAvailable();
if (ActiveCount >= ObjectCountThreshold)
{
GEngine->ForceGarbageCollection(true);
GEngine->PerformGarbageCollectionAndCleanupActors();
}
宁愿卡一下,也绝不能让对象数爆上限------这是经典的防御性编程。
一次性释放大量资源的业务节点
很多游戏业务会在某些节点一次性释放大批资源 ,典型的有:
此时还可以将GC的接口放在lua里进行调用。
- 角色换装:切换整套外观时会释放旧形象的贴图、骨骼网格、材质等
- 关卡切换:旧场景的 Actor、关卡资产、AI 数据大批卸载
- CG / 过场表演结束后:演出期间创建的临时特效、动态材质、粒子都不再需要
- 战斗结束:战斗期间的技能特效、临时角色实例大批失效
如果等引擎自己 60 秒后再收,这些资源就会在内存里赖着,让下一阶段"带着一身包袱"起跑。更糟的是,如果玩家在短时间内反复触发(比如连续尝试多套外观),每次都叠一层旧资源,内存峰值非常难看。
这类场景适合业务代码主动 GC:
cpp
// 确定"刚释放了一大堆东西"之后立刻 GC
GEngine->ForceGarbageCollection(true);
但要注意不要过度触发 。比如玩家每换一件衣服都 GC,会把每次换装都变成一次可感知的小卡顿。常见做法是累计 N 次再 GC:
cpp
static int32 GCounter = 0;
static int32 GThreshold = 7; // 攒够 7 次才 GC,可通过控制台变量调
if (++GCounter >= GThreshold)
{
GCounter = 0;
GEngine->ForceGarbageCollection(true);
}
需要"彻底断开引用"的系统性操作
有些操作在正确性上要求 GC 必须立刻把相关对象全部回收------最典型的就是 Pak 卸载。
Pak(.pak 文件)是 UE 的资源打包文件,热更新、DLC 按需加载等都会涉及"挂载 / 卸载 pak"。Pak 卸载后,里面的资产类(贴图、蓝图、Mesh 等)全部变成"悬空"状态,它们还被各种缓存引用着------必须立刻走一次 GC 把这些引用彻底切断,否则:
- pak 文件句柄无法释放,新 pak 覆盖或删除旧 pak 会失败
- Shader 库无法关闭(材质还引用着 shader)
- 旧 pak 里的 UClass 如果还被缓存,加载新 pak 时会出现"新老 UClass 同名但地址不同"的诡异 bug
所以卸 pak 的标准流程是:
cpp
// 1. 把所有已挂载的 pak 卸掉
for (const FString& Pak : MountedPakFiles) { Unmount(Pak); }
// 2. 立刻强制 full GC,让 pak 里的 UObject 全部回收
GEngine->ForceGarbageCollection(true);
GEngine->PerformGarbageCollectionAndCleanupActors();
// 3. 等 GC 完成后再做下一步(比如关 ShaderLibrary、挂新 pak)
这类场景和前面"为了降低内存峰值"不同------这里的 GC 是正确性要求,不是性能优化。
总结
主动 GC 的核心设计哲学可以概括为一句话:
不要等引擎自动收------在"刚释放了一大堆东西"的时机,主动要求 GC 立刻清干净。
这是用可控的小卡顿 替代不可控的内存峰值的经典权衡。
六、GC 的优化
一次完整的 GC 要处理几十万到上百万个 UObject,如果每次都老老实实"全量扫一遍、一次性清干净",对帧率是灾难性的。UE 在引擎层做了多种优化来摊薄这个成本。
6.1 Cluster 簇优化
问题背景
有很多对象天生就是绑在一起的 ------比如一张贴图和它的所有 Mip 层级、一个骨骼网格和它的 LOD、一个材质和它的 Shader 数据。这些对象的生命周期完全一致,一起活、一起死,单独标记它们是多余的。
引擎的做法:用并查集把它们打包成一个"簇"
UE 引入了 GC Cluster 机制:用并查集(Disjoint Set)数据结构把一组生命周期紧密绑定的对象打包成一个整体。GC 标记时只做一件事------问这个簇的根对象是否可达:
- 根对象可达 → 整个簇视为可达,无需逐个遍历
- 根对象不可达 → 整个簇一起回收
对于大型贴图、骨骼网格、复杂材质等"内部结构庞大"的资源,Cluster 能把标记成本从"扫几百个子对象"压缩到"查一次根",收益明显。
6.2 增量式清除(Incremental Purge)
问题背景
假设一次 GC 要回收 5000 个对象------每个对象都要依次调用 BeginDestroy → FinishDestroy → ~UObject → GMalloc::Free。如果一帧内干完,轻则卡顿十几毫秒,重则直接掉一两帧。
引擎的做法:把清除工作拆到多帧完成
标记阶段 必须一次性搞定(STW,因为不能让游戏线程中途改引用关系),但清除阶段 不一样------对象已经被判定为"死亡",业务代码也不再通过 IsValid 使用它们,真正释放内存放在哪一帧都不影响正确性。
所以 UE 把清除阶段拆开:
正常游戏循环:
Frame 100: Tick → Render → [GC Purge: 处理 50 个对象,耗时约 2ms] → Present
Frame 101: Tick → Render → [GC Purge: 处理 50 个对象,耗时约 2ms] → Present
Frame 102: Tick → Render → [GC Purge: 处理 30 个对象,耗时约 1.2ms] → Present
Frame 103: Tick → Render → [全部清除完成] → Present
默认每帧最多消耗约 2ms 用于清除 。这也是为什么 Destroy() 调用后对象不会立即消失------它只是被标记为 PendingKill,真正的销毁要跨若干帧才能完成。
控制参数
ini
gc.IncrementalPurgeEnabled=true ; 启用增量清除(默认开)
gc.MaxTimeBetweenPurges=0.02 ; 每帧最多 2ms 用于 Purge
强制同步清除的场景
如果调用 ForceGarbageCollection(true)(bFullPurge=true),引擎会强制在本帧把所有清除做完,不分帧。代价是一次明显的卡顿,但能立刻把内存吐回去------前面第五章讲到的"换装后""卸 Pak 后""对象数超阈值时"等场景,用的就是这种模式(详见上一章 5.2 节)。
6.3 UObject 对象池
问题背景
频繁创建/销毁 UObject 是 GC 的噩梦 。每创建一个新 UObject 要分配内存、注册到 GUObjectArray、构造;销毁时又要走完整的三阶段销毁流程。如果每帧都在新建释放几百个小对象,GC 的工作量直接爆炸。
典型场景是那些需要高频创建临时小对象 的子系统(比如大量 NPC 生成/回收、粒子相关的临时数据对象、UI 频繁开关的子控件等)。对这类场景,标准做法是用对象池------让对象复用,而不是反复创建-销毁,从源头上减少 GC 的工作量。
一个典型的 UObject 对象池实现大致长这样:
cpp
template<typename ObjectType, uint32 MAX = MAX_uint32>
class TSimpleObjectPool : public FGCObject // ★ 继承 FGCObject,让 GC 看见池里的对象
{
public:
ObjectType* GetFromPool(); // 取一个,池里有就复用,没有就 NewObject
void ReturnToPool(ObjectType* Object); // 还一个,放回池里待下次复用
virtual void AddReferencedObjects(FReferenceCollector& Collector) override
{
Collector.AddReferencedObjects(ObjectPool); // ★ 告诉 GC:"池里这些对象我还在用"
}
private:
TArray<ObjectType*> ObjectPool;
mutable FCriticalSection CriticalSection;
};
几个关键设计点:
- 池类本身不是 UObject :池子是纯 C++ 类(不需要反射、不需要蓝图),但它持有的是裸 UObject 指针------靠继承
FGCObject并在AddReferencedObjects里把池里的对象都报告给 GC,让它们不被误回收。这是FGCObject最经典的用法之一。 - 复用而非销毁 :
ReturnToPool里不调Destroy,只是把对象放回数组。下一次GetFromPool直接拿出来重置状态即可。 - 用
Rename切换 Outer :进阶做法是在入池时调用UObject::Rename把对象的 Outer 改到一个中立位置(比如nullptr或专用的"池 Outer"),避免原持有者销毁时把池里的对象也拖死。 - 线程安全 :如果池可能被多线程访问,需要加锁;但要注意
NewObject本身受 UE 反射系统限制,一般要求在游戏线程调用。
七、GC 相关的常用工具
理解 GC 原理是为了不出问题 ,熟悉工具则是为了出了问题能快速定位 。本章不深入任何单个工具的实现细节,只做一个**"地图级别"的介绍------让你知道做什么事该找什么工具**,真正要用时再翻对应的文档或代码。
7.1 控制台命令(~ 打开控制台输入)
| 命令 | 大概作用 |
|---|---|
stat memory |
看当前各模块内存占用总览 |
stat memoryplatform |
看平台相关的物理内存/虚拟内存状态 |
obj list class=XXX |
列出指定类型当前活着的对象数量和实例 |
obj gc |
打印 GC 统计信息 |
gc.Flush |
立刻执行一次完整 GC |
memreport -full |
导出一份完整内存快照到 Saved/Profiling/MemReports/ |
什么时候用:快速验证问题最顺手的工具。想知道"某类对象到底有多少""GC 到底收没收下去""刚才那波操作后内存变化了多少",直接敲命令就行。
7.2 Unreal Insights
UE 4.26+ 提供的图形化性能/内存分析器,能记录一段时间内的:
- 每帧 CPU / GPU 耗时(按线程、按函数)
- 内存分配情况(谁分配、分配到哪)
- GC 的触发时机和耗时
- 资源加载时间线
什么时候用 :做深度性能/内存诊断时的首选 。比起控制台命令,它能看到时间维度的完整轨迹------适合回答"这个卡顿是 GC 造成的还是加载造成的"这类问题。
7.3 PerfDog

7.4 工具选型小结
如果把上面的工具按"问题类型"对应起来:
| 问题 | 首选工具 |
|---|---|
| 想知道当前总内存占用 | stat memory |
| 想知道哪类资源最占内存 | memreport -full / Session Frontend |
| 想定位时间轴上的性能/内存波动 | Unreal Insights |
| 某个对象明明该死却没死 | 自建的引用链追踪工具 |
| 对象数量逼近 UE 硬上限 | 自建的对象数量守卫 |
| OOM 崩溃归因 | 自建 OOM 原因翻译 + crash 上报 |
| 想在对象被 GC 时做配套动作 | AddUObjectDeleteListener |
| 脚本层想主动 GC | 通过 BlueprintCallable / Lua 绑定暴露的接口 |
| 临时验证 "GC 有没有生效" | 控制台 gc.Flush + obj gc |