UE垃圾回收的全方面讲解(通俗易懂)【底层实现、触发方式、引用保持、优化、工具】

UE GC(垃圾回收)与内存管理完整解析


目录

  1. [GC 总览:它是什么,为什么要关心它](#GC 总览:它是什么,为什么要关心它)
  2. [创建 UObject 的三种方法](#创建 UObject 的三种方法)
  3. [GC 核心原理详解](#GC 核心原理详解)
  4. [引用保持------如何防止对象被 GC 回收](#引用保持——如何防止对象被 GC 回收)
  5. [GC 的触发](#GC 的触发)
  6. [GC 的优化](#GC 的优化)
  7. [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:

  • 你不需要手动 delete UObject
  • 引擎通过可达性分析自动检测哪些对象"不再被任何人需要"
  • 自动回收这些对象占用的内存

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 个对象------每个对象都要依次调用 BeginDestroyFinishDestroy~UObjectGMalloc::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;
};

几个关键设计点:

  1. 池类本身不是 UObject :池子是纯 C++ 类(不需要反射、不需要蓝图),但它持有的是裸 UObject 指针------靠继承 FGCObject 并在 AddReferencedObjects 里把池里的对象都报告给 GC,让它们不被误回收。这是 FGCObject 最经典的用法之一。
  2. 复用而非销毁ReturnToPool 里不调 Destroy,只是把对象放回数组。下一次 GetFromPool 直接拿出来重置状态即可。
  3. Rename 切换 Outer :进阶做法是在入池时调用 UObject::Rename 把对象的 Outer 改到一个中立位置(比如 nullptr 或专用的"池 Outer"),避免原持有者销毁时把池里的对象也拖死。
  4. 线程安全 :如果池可能被多线程访问,需要加锁;但要注意 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(垃圾回收)与内存管理完整解析


目录

  1. [GC 总览:它是什么,为什么要关心它](#GC 总览:它是什么,为什么要关心它)
  2. [创建 UObject 的三种方法](#创建 UObject 的三种方法)
  3. [GC 核心原理详解](#GC 核心原理详解)
  4. [引用保持------如何防止对象被 GC 回收](#引用保持——如何防止对象被 GC 回收)
  5. [GC 的触发](#GC 的触发)
  6. [GC 的优化](#GC 的优化)
  7. [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:

  • 你不需要手动 delete UObject
  • 引擎通过可达性分析自动检测哪些对象"不再被任何人需要"
  • 自动回收这些对象占用的内存

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 个对象------每个对象都要依次调用 BeginDestroyFinishDestroy~UObjectGMalloc::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;
};

几个关键设计点:

  1. 池类本身不是 UObject :池子是纯 C++ 类(不需要反射、不需要蓝图),但它持有的是裸 UObject 指针------靠继承 FGCObject 并在 AddReferencedObjects 里把池里的对象都报告给 GC,让它们不被误回收。这是 FGCObject 最经典的用法之一。
  2. 复用而非销毁ReturnToPool 里不调 Destroy,只是把对象放回数组。下一次 GetFromPool 直接拿出来重置状态即可。
  3. Rename 切换 Outer :进阶做法是在入池时调用 UObject::Rename 把对象的 Outer 改到一个中立位置(比如 nullptr 或专用的"池 Outer"),避免原持有者销毁时把池里的对象也拖死。
  4. 线程安全 :如果池可能被多线程访问,需要加锁;但要注意 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
相关推荐
邪修king3 小时前
UE5:C++ 实现 游戏逻辑 ↔ UI 双向联动
c++·游戏·ue5
相信神话202113 小时前
3.2《酒魂》规则设计文档
游戏引擎·godot·2d游戏编程·godot4·2d游戏开发
Avalon7121 天前
Unity3D响应式渲染UI框架UniVue
游戏·ui·unity·c#·游戏引擎
风酥糖1 天前
Godot游戏练习01-第33节-新增会爆炸的敌人
游戏·游戏引擎·godot
HAPPY酷1 天前
从Public到Private:UE5 C++类创建路径差异全解析
java·c++·ue5
郑寿昌2 天前
UE5与UE6在Lumen和Nanite的差异解析
游戏引擎·图形渲染·着色器
郑寿昌2 天前
UE6 AI加速Lumen光线追踪降噪技术解析
人工智能·游戏引擎
晴夏。2 天前
GAS下的网络同步的全面分析【超级全面】
游戏引擎·ue·gas·网络同步
田鸡_2 天前
Unity新输入系统(Input System)教学篇
unity·游戏引擎·游戏程序