WECHAT

扫码添加微信

YoungFan 微信二维码
← 返回 UE5 笔记
从 Unity 到 Unreal :GC、对象生命周期与内存管理 封面
技术研究已归档

从 Unity 到 Unreal :GC、对象生命周期与内存管理

Unity程序员转Unreal Engine 5指导手册

UE5C++UnityC#

前言

本文章面向已经熟悉 Unity、正在转向Unreal Engine的开发者,目标是帮助读者建立UE对象生命周期和内存管理的基本心智模型。

Unity开发者日常工作中通常使用C#以及额外的解释型脚本语言(例如:Lua),这些语言大多都内置成熟的垃圾回收系统,大大降低了开发者管理内存的心智负担。

但转到Unreal Engine领域,尤其还要以C++作为主要开发语言时,首要任务就要搞清楚两者在内存管理上的差异。这也是面试官对Unity转UE的开发者最大的“担忧”之处。

本文不讲解C++语法、C++对象模型,也不深入探讨GC内部算法与底层实现细节。

Unity与UE对象体系、GC系统差异

UE中的双轨制对象体系(UObject对象 vs 普通C++对象)

Unity所使用的C#中,除了接口、指针等少数类型,几乎所有类型都派生自System.Object;其中引用类型的实例分配在托管堆上,生命周期由GC追踪并管理。值类型(如Vector3)则分配在线程栈上或内联宿主对象内部,随宿主或栈帧一起消失,不受GC影响。

而 Unreal Engine 中将数据结构严格分为两大阵营:

  • 派生自UObject的UObject体系类。
  • 普通C++类或结构体。

两者内存管理机制完全不同:

对比维度 UObject体系类(UCLASS) 普通C++类/结构体(Non-UObject)
典型代表 AActor、UActorComponent 纯C++类、数据结构、网络解包数据、SWidget(Slate UI)
内存管理机制 UE引擎垃圾回收(Garbage Collection, GC) 引用计数/智能指针
指针定义方式 使用UPROPERTY宏标记 例如:
UPROPERTY() TObjectPtr<UMyObject> MyObj;
使用虚幻智能指针库 例如:
TSharedPtr<FMyClass> MyClassPtr;
生命周期控制 由引擎后台自动定期回收无引用的对象 由最后一个持有的TSharedPtr离开作用域时自动delete释放

严格来说,Unity其实也是“双轨”制。UnityEngine.Object体系由托管包装器(C#)与原生对象(C++)两层构成——包装器受GC管理,原生对象却依赖引擎引用计数和显式地调用Destroy()。只是这种差异被引擎封装得很好,多数时候开发者感知不到。

UE 与 Unity GC系统差异

UE与Unity GC系统特征差异:

分类 Unreal Engine Unity(Mono/IL2CPP + Boehm)
引用计数/追踪式 追踪式 追踪式
精确扫描/保守扫描 精确式扫描,且必须提供反射信息(例如:UPROPERTY()),才能识别引用。 扫描堆上的对象是精确式扫描;扫描线程栈、寄存器时为保守扫描(逐字扫描内存,每个“看起来”像指针的字都当指针/引用处理)。
GC过程中是否移动对象内存 否 否
GC过程中是否会主动暂停 可达性分析阶段默认情况下(不开启增量GC)需要暂停游戏线程和所有走FGCScopeGuard的UObject操作;清理销毁阶段按每帧2ms分摊到多帧中,不需要暂停。 标记阶段切成约3ms的步进分摊到每帧,每步扫描线程栈会短暂地冻住所有托管线程(包括主线程)。 清扫阶段(Unity没有UE那种“销毁”阶段)不需要暂停。

UE GC的系统中,UObject对象之间的引用关系必须明确声明:通过反射信息(UPROPERTY()宏标记)、手动添加到根集(AddToRoots())。普通C++对象若持有UObject对象,也需要手动声明(通过继承FGCObject)。

Unity的GC过程大致分为:标记(Mark)和清扫(Sweep)两个阶段。同样的,UE的GC系统也大体上分为两个阶段—— 可达性分析(Reachability analysis) 阶段和清理销毁阶段。

之所以说“可达性”,是因为UE构建了一个引用图表以确定哪些UObject仍在使用,哪些是孤立的。该图表的根部是一组指定为“根集”的UObject对象。引擎从根集开始,搜索已知UObject引用树来追踪所有被引用的UObject对象。任何未被引用的UObject对象(未在引用树中搜索到这些对象)将被认为是不再需要的,因此被删除。

UE GC系统的清理销毁步骤主要有以下步骤(并且这些步骤都按照每帧2ms的预算逐步执行,并非一次性执行完):

  • 将对象从全局哈希表中摘除,FindObject()从此找不到该对象,名字也将被复用。
  • 调用Object->ConditionalBeginDestroy() -> 虚函数BeginDestroy(),打上RF_BeginDestroyed标志位。主要是为了清理外部登记、释放外部资源、解开引用。
  • 等到Object->IsReadyForFinishDestroy()结果为true时,才继续调用Object->ConditionalFinishDestroy() -> FinishDestroy()。主要是为了应对带渲染资源(Graphic Resource)的对象异步释放资源时,需要等待渲染线程执行到“release fence”的情况。
  • 释放对象索引。对象从全局对象数组中消失,索引槽位进入复用池。
  • 执行析构函数以及释放内存空间:
    UObject* Object = UnreachableObject.Object;

    Object->~UObject();
    GUObjectAllocator.FreeUObject(Object);
    UnreachableObject.Object = nullptr;

UE 增量GC

UE的GC系统默认情况下,可达性分析阶段是在单帧内全量进行(有做并行优化),需要暂停所有UObject处理(尤其Gameplay)。要扫描的对象越多,暂停时间越长,可能会产生明显的卡顿。程序员可以采用多种方法避免卡顿:

  • 保持紧张的UObject预算。
  • 使用UObject池。
  • 在正常Gameplay期间禁用GC。

但以上方法往往会增加代码复杂度和维护成本,因此UE提供了增量可行性分析的改进,具体可参考官方文档。

需要建立的核心认识

  • UObject虽然是C++对象,但通常不由开发者直接释放内存。
  • 普通C++对象不会自动进入GC。
  • “对象会不会被GC回收”与“它占用的资源是否还驻留内存”是两个独立的问题:
    • “对象会不会被GC回收”问的是资格——对象还有没有指向它的强引用。如果没有引用,它就获得了“被回收的资格”,将来某次GC会回收它,但不是当下、立刻将它回收并释放其占用的资源。
    • “资源是否驻留内存”问的是现状——此刻对象占用的资源,在物理上还在不在。例如:纹理流送。UTexture2D对象被引用、当然不会被回收,但它的GPU mip内存可以被流送系统踢出驻留内存——对象还在,资源不在。
  • 转向UE后,内存管理的重点不是“记得调用释放函数”,而是学会表达对象之间所有权和引用关系。
  • UE GC系统中,对象之间的引用关系需要明确声明!对象之间的引用关系需要明确声明!对象之间的引用关系需要明确声明!

UE GC如何判断对象是否存活

可达性

UE GC的基本思路是从必然可达对象(通常是那些设定为根集的对象,例如GEngine、UGameplayTagsManager)开始,沿着强引用链条扫描整个对象关系图。

Root Set
 └─ PlayerController
     └─ HUD
         └─ InteractionWidget

UnreferencedObject
 └─ 不再可达,可以被 GC 回收

如果一个UObject从根对象或其他可达对象沿着强引用访问到,那么它被认为是可达的。如果UObject对象不可达,则GC系统将考虑回收它。

GC必须能够看见引用

并不是保存一个UObject对象的内存地址,GC系统就“感知”到这个对象被引用了。只有GC能识别的引用关系或者明确地“告诉”GC对象的引用关系(UPROPERTY()宏),才具有保活的作用。

因此需要区分:

  • 程序是否还能找到这个地址。
  • GC是否知道某个存活对象的正在持有它。
  • 这个引用是否应该阻止对象被回收。比如:弱引用不会阻止对象被回收。

回收通常不是立即发生

UObject对象失去最后一个强引用时,通常不会在同一时刻立即回收。对象会在后续GC周期中被发现并回收。

因此:

  • 指针非空并不代表对象有效。
  • 对象进入销毁流程后,内存可能尚未立即释放。
  • 不应依赖“看起来有地址”来判断UObject对象是否有效。

UObject对象的创建、持有与销毁

使用与类型匹配的创建方式

UObject不能被当作普通C++数据随意创建,因为创建过程中还需要完成对象注册、类型初始化、Outer设置和GC登记。

不同对象因使用与其类型匹配的创建流程:

  • 普通UObject使用UObject创建机制,例如:NewObject()。
  • AActor应通过UWorld->SpawnActor()生成。
  • UActorComponent需要正确建立所属关系并注册。
  • 默认组件需要在宿主对象的构造阶段创建。
  • 资源对象通常由资源加载系统取得。
  • UUserWidget结合所属玩家和UI生命周期创建。

创建对象后,应立即明确谁负责保证其存活。

持有UObject成员变量,必须用UPROPERTY宏保证其存活

Unity中,当一个对象的类类型成员变量创建完成后,成员变量就处于“被引用”的状态:

class MyClassA
{
    public MyClassB MemberB = new MyClassB();//GC认为MemberB被MyClassA对象引用,这对Unity程序员来说稀松平常。
}

但UE中,只是“New”一个UObject成员变量,并不代表与其宿主对象建立了“能被GC认可的引用关系”。例如:

UCLASS()
class UMyClassA : public UObject
{
    GENERATE_BODY()

public:
    UMyClassA();

    UMyClassB* MemberB;
}

void UMyClassA::Process()
{
    //MemberB能被GC发现,但对GC来说MemberB不可达,未来会被GC回收。
    MemberB = NewObject<UMyClassB>(this);
}

因为UE需要通过反射信息建立引用关系,所以你需要使用UPROPERTY()宏标记成员对象:

UCLASS()
class UMyClassA : public UObject
{
    GENERATE_BODY()

public:
    UMyClassA();

    UPROPERTY() //使用宏标记,建立反射信息,使成员对象进入引用图。
    TObjectPtr<UMyClassB> MemberB;//TObjectPtr是UE5推荐写法,UMyClassB*也能生成反射信息。
}

局部UObject变量需手动保证存活

Unity的GC(Boehm)是保守式的,会扫描线程栈(保守扫描)和寄存器——局部变量在出作用域前,该变量能一直存活(至少Debug构建是这样)。例如:

void Func()
{
    var data = new MyData();
    DoSomething(data);//使用期间,data绝对存活
    //之后不再使用data。在Release构建下,JIT认为后续不再使用data,在出函数作用域前可能会将其回收。
    //Debug构建下,data将一直存活,直至函数作用域结束。
    LongRunning();
}

UE的GC是精确式的,只认反射信息(通过宏标记显式声明)和显式注册(添加到根集、派生自FGCObject)——完全不扫描C++调用栈。栈上的裸UObject*对GC来说不可达,未来会择机回收掉。例如:

void AMyActor::Func()
{
    UMyData* Data = NewObject<UMyData>();// 无UPROPERTY引用。
    BigAllocation();//这次分配可能触发GC——Data随时可能变成悬挂指针。
    Data->Use();//如果Data已被回收,对于Data的操作将触发崩溃。但在C#中,这里不可能出问题。
}

解决方案有三种:

  • 用UPROPERTY标记的宿主对象的成员变量持有。前提是宿主对象也必须能被GC“发现”,如果宿主是普通C++对象,则该方案无效,应使用第3个方案。
  • 显式地添加到根集(AddToRoot()),但也必须显式地从根集移除(RemoveFromRoot())。
  • 使用TStrongObjectPtr。

例如:

void AMyActor::Func()
{
    //方案1:为AMyActor声明一个用UPROPERTY标记的成员变量,用来持有局部变量
    //假如AMyActor声明了:UPROPERTY() TObjectPtr<UMyData> MemberData;
    MemberData = NewObject<UMyData>();// 无UPROPERTY引用。
    BigAllocation();
    MemberData->Use();
    //但如果宿主对象是普通C++对象,UPROPERTY的方法无效。


    //方案2:显式地添加到根集
    UMyData* Data = NewObject<UMyData>();
    Data->AddToRoot();
    BigAllocation();
    Data->RemoveFromRoot();//用完还要显式地从根集移除,否则无法被回收。


    //方案3:使用TStrongObjectPtr
    UMyData* Data = NewObject<UMyData>();
    TStrongObjectPtr<UMyData> KeepAlive(Data);//从这里开始,GC将能“发现”它。
    BigAllocation();
}//函数作用域结束后,KeepAlive析构——保护自动解除;Data无引用,未来将被GC回收。

C#是“持有即保护”(保护到不再需要为止),UE是“不看指针,只看是否显式地声明引用”。

不同对象有不同的销毁方式

  • 普通UObject应断开强引用,等待GC回收。
  • Actor应通过UWorld进入销毁流程。
  • ActorComponent应使用对应的注销和销毁流程。
  • 资源能否卸载取决于是否仍然存在硬引用、加载句柄或其他资源依赖。
  • UUserWidget从父节点删除只表示不再显示,不代表已经没有其他强引用,

Outer不等于通用的强所有权

Outer的作用主要在于表达对象的命名层级、上下问文和所属关系,不能简单理解为C#中的成员引用。

指定Outer不代表在所有情况下Outer都会自动保证目标对象存活。如果需要对象长期存活,应建立GC能识别的强引用。

强引用、弱引用与软引用

引用类型表达的不只是访问方式,更重要的是对象之间的生命周期关系。

强引用

强引用表示:只要持有者仍然存活,被引用的UObject就应该继续存活。

适合使用强引用的场景包括:

  • PlayerController持有HUD。
  • HUD持有需要常驻的界面。
  • 角色持有自己的运行时组件。
  • 管理器持有仍在使用的运行时数据对象。

需要注意:

  • UObject成员引用必须以GC能识别的方式等级。
  • 数组、集合和映射中的UObject引用同样必须被GC识别。
  • 普通地址或未登记的对象成员不一定能够阻止GC。

弱引用

弱引用表示:可以观察和访问对象,但不负责保证对象存活。

适合弱引用的场景包括:

  • 当前交互目标。
  • 当前锁定的敌人。
  • 临时缓存的Actor或Widget。
  • 可能在其他系统中被销毁的对象。

使用弱引用时,应当在每次访问前检查目标是否有效。

弱引用不是一种单纯的性能优化,而是明确地表达“不拥有这个对象”。

软引用

软引用主要用于表示一个暂时不需要加载的资源或者类。

软引用的应用场景包括:

  • 角色和宠物资源
  • UI图标。
  • 可选皮肤、音频和动画。
  • DataAsset。
  • Ability类。
  • 大型关卡资源。

软引用通常不要求目标资源立即加载。真正使用资源前,应通过同步或异步的方式加载;加载完成后,还需要明确由谁保证资源继续驻留。

TSubclassOf与TSoftClassPtr

TSubclassOf表达的是类引用和类型的约束,是硬引用(需要通过UPROPERTY宏登记)。TSubclassOf引用的是UClass对象,保活的也是UClass对象,不保活任何类实例。

TSoftClassPtr是TSubclassOf的软引用版本,在使用前需要先通过同步或异步方式加载UClass。

指针引用类型对照表

声明(UPROPERTY内) 引用类型 存什么 GC保活 序列化/打包行为
UStaticMesh*/
TObjectPtr<T>/
TSubclassOf<T>
硬引用 直接指针 是 直接对象引用(进入导入表)-> 打包依赖、保持加载
TWeakObjectPtr<T> 弱引用 弱句柄 否 不参与序列化(运行时专用)
TSoftObjectPtr<T>/
TSoftClassPtr<T>
软引用 FSoftObjectPath(路径字符串) 否 只存路径;可解析、可异步加载

普通C++对象内存管理

在UE中,通常使用虚幻智能指针库提供的智能指针追踪和管理普通C++对象的内存。

虚幻智能指针库

虚幻智能指针库为C++11智能指针的自定义实现,旨在减轻普通C++对象内存分配和追踪的负担。

虚幻智能指针类型分类:

智能指针类型 适用情形
共享指针(TSharedPtr) 共享所有权的强引用智能指针。共享指针拥有其引用的对象,防止该对象被删除,并在无共享指针引用时释放对象。共享指针允许为空,意味其不引用任何对象。
共享引用(TSharedRef) 与共享指针类似,其拥有自身引用的对象,且属于强引用。不同是,共享引用不允许为空,即必须固定引用非空对象。 如果一个函数参数要求传入绝对有效的普通C++对象,使用共享引用可以从编译期杜绝空指针检查。
弱指针(TWeakPtr) 弱指针与共享指针行为类似,但不拥有其引用的对象,因此不影响其生命周期。弱指针主要用于中断引用循环,但也意味着弱指针可在无预警的情况下随时变为空。弱指针可生成指向其引用的对象的共享指针,确保程序员能对该对象进行安全的临时访问。
唯一指针(TUniquePtr) 独占所有权的智能指针,无法共享所有权,仅有一个唯一指向给定的对象。唯一指针可以转移所有权(Move),但禁止拷贝,任何拷贝操作将导致编译错误。唯一指针超出其作用域时,将自动释放所引用的对象。

与C++11标准库智能指针的对应关系:

标准 C++ 库 (std::)          UE 智能指针
┌──────────────────┐        ┌──────────────────────────┐
│ std::shared_ptr  │ ──►──► │ TSharedPtr               │
│ std::weak_ptr    │ ──►──► │ TWeakPtr                 │
│ std::unique_ptr  │ ──►──► │ TUniquePtr               │
│      (无)        │ ──►──► │ TSharedRef (UE 独创强引用)│
└──────────────────┘        └──────────────────────────┘

虚幻智能指针库官方文档

绝对禁止给UObject使用智能指针

在UE开发中,千万不要尝试用智能指针(例如:TSharedPtr<UObject>)包裹UObject及其子类。

  • 原因:UObject的创建和销毁受UE引擎底层的 UObject哈希表 和GC强行接管。
  • 后果:如果强行用TSharedPtr包裹UObject,相当于让它同时被两套所有权系统管理,并且二者无法感知到对方。两套系统谁先销毁对象,对另一方都是背刺:
    • GC先回收,对象被引擎销毁,TSharedPtr内部的裸指针变成 悬挂指针 。当TSharedPtr的引用计数归零,再delete一次,导致 双重释放(Double Free) 的错误。
    • TSharedPtr计数先归零,直接delete对象,绕过引擎的UObject哈希表记账。GC之后扫描到已释放内存里的残留条目,是为—— 内存破坏(Memory Corruption) 。

FGCObject

普通C++对象无法使用UPROPERTY宏,如果在普通C++对象中定义UObject成员变量,需要让普通C++对象继承自FGCObject,并重载AddReferencedObjects()函数,然后在该函数中显式地将UObject成员变量加入GC的引用图表中。例如:

class FMyHelper : public FGCObject
{
public:
    TArray<TObjectPtr<UObject>> Objects;
    TMap<FName, TObjectPtr<UObject>> Lookup;

    virtual void AddReferencedObjects(FReferenceCollector& Collector) override   // 纯虚,必须实现
    {
        Collector.AddReferencedObjects(Objects);
        Collector.AddReferencedObjects(Lookup);
    }

    virtual FString GetReferencerName() const override   // 纯虚,必须实现;显示在引用链诊断里
    {
        return TEXT("FMyHelper");
    }
};

从“释放内存”转向“表达生命周期”

从Unity C# 转向 Unreal C++后,最重要的是学会表达对象之间的生命周期关系。

可以将全文归纳为六个原则:

  • 先判断对象是否属于UObject系统。
  • 为每个对象确定创建者、持有者和生命周期结束方式。
  • 需要保活时,使用GC能够识别的强引用。
  • 不拥有对象时,使用弱引用并在访问前检查有效性。
  • 不希望立即加载时,使用软引用而不是硬引用。
  • “最终能够被GC回收”只是底线,对象数量、资源依赖和回收峰值才决定内存是否真正可控。

开发能够常见的内存问题

UObject被提前回收

现象: 对象刚创建时可以使用,经过一段时间或一次GC后失效。

常见原因: 虽然保存了对象的地址,但这条引用没有被GC识别。

处理原则: 确认负责对象生命周期的持有者建立了正确的强引用。

UObject永远不被回收

现象: 离开界面或关卡后,对象仍然长期存在。

常见原因: 对象仍被GameInstance、Subsystem、全局容器、静态变量或Root Set持有。

处理原则: 从长生命周期对象开始检查引用,确认容器和缓存是否及时清理。

Actor从数组移除后仍在场景中

原因: World仍然管理该Actor,从自己的数组移除引用,不等于销毁Actor。

处理原则: 确认负责对象生命周期的持有者建立了正确的强引用。

Widget从屏幕移除后仍然占用内存

原因: 从界面层级移除只是改变显式状态,HUD、委托、Timer或其他对象可能仍然持有Widget。

处理原则: 区分“停止显示”和“允许回收”,并检查所有长期引用和回调关系。

异步回调访问已经失效的对象

原因: 异步加载、Timer回调获取延迟回调执行时,最初发起操作的对象可能已经被销毁。

处理原则: 回调不应盲目相信目标仍然存活;非用有对象应该使用弱引用,并在使用前检查有效性。

引用资源时应该考虑使用软引用

原因: 硬引用会形成资源依赖链。一个常驻管理对象可能通过角色类继续引用骨骼、动画、材质和纹理。

GameInstance
 └─ Manager
     └─ CharacterClass
         ├─ SkeletalMesh
         ├─ AnimBlueprint
         ├─ Materials
         └─ Textures

处理原则: 大型、可选或按需使用的资源应评估是否改用软引用或异步加载。

Discussion

留言讨论

无需注册账号
正在加载留言区…

发表留言时,评论服务会记录必要的 IP 与 User-Agent 信息,用于安全审核和防止滥用。