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
相关推荐
mengzhi啊7 小时前
UE5 把剑绑定到骨骼-给要播放的动画及动画蒙太奇添加武器
ue5
还是大剑师兰特9 小时前
Unity3D(Unity)完整详细介绍
unity·游戏引擎
directx3d_beginner13 小时前
32,每帧更新怪物朝向改为c++
ue5
还是大剑师兰特14 小时前
Unity 从零搭建简易3D场景(新手分步教程)
3d·unity·游戏引擎
神码编程14 小时前
【Unity】TankBattle联机坦克大战(三)创建关卡(生成地块)
unity·游戏引擎·帧同步·坦克大战
吴梓穆1 天前
UE5 播放透明视频
ue5
玖玥拾1 天前
Unity3D RPG 入门项目(四)背包拖拽、物品悬浮提示、项目开发思维
3d·unity·游戏引擎
日月云棠1 天前
UE5源码分析之Editor——AnimationBlueprintEditor模块全面分析
ue5
远离UE41 天前
UE5 不同贴图采样类型的区别
ue5
xcLeigh2 天前
Unity基础:Start与Update方法——Unity脚本生命周期初探
java·unity·游戏引擎·教程