ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unreal内存管理:为什么不再需要手动delete?

Unreal内存管理:为什么不再需要手动delete? 从纯 C 转到 Unreal 的那段时间我最大的冲击不是蓝图也不是 GameplayAbilitySystem而是内存管理。我翻遍整个项目很少有 delete 出现更诡异的是一个 AActor 从关卡里消失后没有任何人手动释放过它对应的内存。直到我理解了 Unreal 的垃圾回收机制才明白这里的 C 已经不是我熟悉的那种 C 了。这个系列想聊的就是 Unreal 对 C 到底做了哪些改造本章聚焦一个最容易被新手误解的问题为什么在这个引擎里你不再需要也不能随意写 delete。1. 从“手动释放”到“托管对象图”Unreal 对 C 内存模型的第一次重构先说说传统 C 里的内存管理逻辑。写 C 的人都知道new 和 delete 要成对出现谁申请谁释放责任清晰。到了稍微大一点的工程里这条规则开始变得非常痛苦一个对象可能被好几个系统持有引用你很难判断它到底该在什么时候死。于是大家发明了各种模式引用计数、工厂类、智能指针、所有权转移本质上都是在解决同一个问题——谁能决定一个对象的生命周期。Unreal 的思路完全不一样。它不要求程序员去逐对匹配 delete而是建立了一张全局的“对象关系图”。所有 UObject 的实例、它们之间的引用关系、嵌套的 Outer 父子关系都被运行时统一记录下来。之后引擎定期从一组被称为“根集”的对象出发沿着这些引用关系做遍历把所有还能到达的对象标记为存活把剩下的对象当作垃圾统一回收。这套模型的学名是循环可达性垃圾回收也就是平时说的 GC英文全称 Garbage Collection。为什么 Unreal 一定要这么干看过它的数据模型就明白了。一个典型的关卡里UWorld 持有 ULevelULevel 持有 AActorAActor 持有 UActorComponent组件里可能又引用着 UMaterial、UTexture、UStaticMesh。这些对象不是父子关系就是引用关系而且在编辑器里还会被加载、卸载、Undo、Redo、复制粘贴对象的创建和销毁频率极高。如果用传统 C 手动管理光是把“哪些资产还被引用着”算清楚就够人崩溃的了。GC 的价值在于你可以只关心“我还需要你”不需要关心“你先把我释放掉”。引用声明对了引擎会帮你完成剩余所有清理工作。这里强调一点Unreal 的 GC 不是灵异事件也不是对 C 的背叛。它只是在一个更高的层面接管了对象生命周期。普通 C 的局部变量、栈对象、纯 C 类仍然遵循标准规则但一旦对象继承自 UObject它的生命周期就被 UObject 子系统接管了。你不再 delete 它不是不能清理而是清理动作被统一到 GC 流程里由引擎按全局图的状态来决定。2. 撑起 GC 的三根支柱UPROPERTY、Outer、反射系统想要理解 Unreal 的 GC绕不开三个基础设计UPROPERTY 标记、Outer 归属、反射元数据。这三样东西合在一起组成了 GC 赖以工作的信息基础。2.1 UPROPERTYGC 唯一能看到的“声明”GC 判断一个 UObject 是否存活靠的是从根集遍历引用。但引用是从哪些地方找出来的答案是各类对象属性。UCLASS() class AMyActor : public AActor { GENERATED_BODY() UPROPERTY() UObject* TrackedObject; };上面这段代码里TrackedObject 被 UPROPERTY 标记。引擎在执行 GC 时会通过反射系统读取 AMyActor 的实例数据发现这个字段持有某个 UObject 的指针于是认为 AMyActor 到 TrackedObject 之间存在一条强引用。只要 AMyActor 自己是存活的TrackedObject 一般也会被标记为存活。反过来说如果字段没有 UPROPERTY 修饰UObject* TrackedObject; // 没有标记GC 遍历时根本不会看这个字段。它不会导致编译报错也不会在运行时立刻崩但它意味着你声明了一条“GC 看不见”的引用。如果你的逻辑里依赖这个引用维持对象生命对象很可能在某个 GC 循环之后突然被回收留下一根悬空指针。这是我在不少项目里见过最典型的 Crash 来源之一。2.2 Outer决定对象归属的父子树Unreal 里创建 UObject 最常用的接口是 NewObject ()。它有一个经常被忽略但极其重要的参数Outer即外层对象。UUserWidget* Widget NewObjectUUserWidget(this);这个 this 就是 Outer。它表示新对象属于当前对象。Outer 在 GC 层面有一条潜规则父对象的生命周期通常决定子对象的生命周期。当一个对象因 GC 被回收时以它为 Outer 的子对象也会进入回收队列。Outer 构成的是一棵树UWorld 通常作为 ULevel 的 OuterULevel 又是其中 AActor 的 OuterAActor 是它组件列表的 Outer。这套树状结构同时承担了对象归属、垃圾回收级联、以及引擎资源管理中的包边界。你不需要在代码里手动清理子对象只要把最外层父对象从根集断开整棵子树都会在 GC 流程中被处理。2.3 反射系统让引擎能“看懂”UObject 内部的指针UPROPERTY 为什么能生效因为 Unreal 在编译阶段运行的 UnrealHeaderTool 会解析这些宏生成对应的反射数据。运行时引擎可以通过 UClass 的元数据遍历一个对象的所有属性知道哪些字段是对象引用、哪些是结构体、哪些是数组还能读取它们的实际值。这里有个关键点标准 C 里类型信息在编译后基本消失了程序在运行期不可能自动知道一个类有哪些成员、成员是谁的指针。Unreal 通过 UCLASS、UPROPERTY、GENERATED_BODY 这些宏把“成员清单”以二进制反射数据的形式保留下来。GC 才能像遍历数据库一样扫描所有对象属性。这就是为什么 Unreal 的 C 看起来“不太一样”的根源之一它的宏不是装饰而是给编译器之外的工具链提供信息的入口。3. GC 一帧一帧怎么工作从根集出发标记、清扫、回收理解了引用声明的机制之后我们来看 Unreal 的 GC 具体是怎么跑完一圈的。3.1 根集永远不会被回收的起点GC 不能只靠“没有任何人引用”就回收对象因为从空集出发所有对象都不可达。引擎需要先定义一组根对象它们是存活状态的绝对起点。常见的根集成员包括引擎核心单例比如 GEngine、GWorld 这类全局对象标记了根集标志RF_RootSet的对象通过 AddReferencedObjects 或 FGCObject 注册的对象重要的 CDO类默认对象和包资源。GC 从这些根对象开始沿着 UPROPERTY 声明的引用链向外扩展。能从这个起点到达的就标记为存活到达不了的在下一次清扫时进入回收队列。这里有个实际启发如果你通过某种方式把对象挂到了某个根对象上比如存进静态容器且该容器没有被额外处理这个对象就可能长期无法被 GC形成内存泄漏。你以为是 GC 在帮你管内存结果因为“从根可达”而管不住。很多项目里的“物体越来越多”问题往往不是 GC 没工作而是某个静态持有把它们全部变成了根集成员。3.2 可达性标记与增量式回收标记阶段是 GC 最核心的工作。引擎从根集出发对每个对象执行属性扫描找到所有 UPROPERTY 指向的 UObject把它们加入待扫描列表然后继续递归直到所有可达对象都被扫描完。这个过程中Unreal 引入了增量式回收。传统的 GC 是暂停游戏线程一停到底把所有标记和清理一次做完Unreal 为了不让玩家感受到明显卡顿会把标记工作分成多个 slice每帧利用一部分时间分段推进。它还有各种多线程优化和条件控制目的都是让 GC 的停顿尽量短而且尽量少阻塞主线程。对于游戏开发来说这意味着一个关键感受你觉着“‘这个对象应该被释放了’但内存可能在之后某一帧才真正被飞走。 GC 是异步的不是你调用某个接口立即生效。如果你在业务逻辑里强行假设“对象已经销毁了”反而可能出错。3.3 对象的死亡时序析构前还要经历什么当 GC 判断某个对象已经不可达时它不会像普通 C 那样直接调用析构函数。UObject 有一套独立的生命周期结束流程通常对象在进入回收阶段后先被触发 BeginDestroy再进入 PendingKill 或类似状态等待 GC 真正执行清理。有些场合还会涉及 FinishDestroy。这套状态机的好处是引擎能在多种子系统之间协调收尾顺序比如先通知网络、再通知渲染、再通知物理。新手最容易踩的坑是在某个组件被 GC 清理的函数里去手动 delete 另一个 UObject。因为对象在这个阶段可能仍处于某种被部分处理的中间状态手动 delete 等于从引擎手里抢控制权之后引擎再碰它结果就是二次释放、野指针、或者更隐蔽的崩溃。4. 不再 delete 之后重新组织你的“指针”既然不能随便 delete UObject那原来写 C 时的各种指针策略得怎么调整我按使用场景拆开讲。4.1 对非 UObject 对象标准 C 智能指针仍然有效UE 项目里有大量类并不继承 UObject比如自定义的普通数据类、接口实现类、线程工作对象。对它们你用 std::unique_ptr、TUniquePtr、TSharedPtr 都是完全正常且推荐的。它们不参与 GC逻辑和标准 C 一致谁持有谁释放。TUniquePtrFMyCustomData Data MakeUniqueFMyCustomData();这种代码在 Unreal 里没问题因为 FMyCustomData 和 GC 没有任何关系。真正的混乱往往发生在“把 UObject 当作普通 C 对象管理”的混搭场景。4.2 对 UObject 的非拥有引用用 TWeakObjectPtr 代替裸指针很多时候你只想记录一下某个 Actor 的地址以便后续查询但并不想因此让它“永远活着”。如果你直接存裸指针 UObject*对象可能被 GC 回收裸指针不会自动清空后续访问直接崩溃。正确做法是使用 TWeakObjectPtrTWeakObjectPtrAActor Tracker; void AMyActor::Remember(AActor* InActor) { Tracker InActor; } void AMyActor::CheckIt() { if (Tracker.IsValid()) { AActor* Raw Tracker.Get(); // 只有在 IsValid 通过后才能安全使用 } }这个类型在内部注册到对象的弱引用系统GC 收集对象时弱引用会被自动置为无效访问时返回 null。它解决的就是“存了个指针但不知道对方是否已经死掉”的问题比裸指针安全得多。4.3 对 UObject 的强引用UPROPERTY 优先TStrongObjectPtr 补位在 UObject 里持有另一个 UObject标准姿势是 UPROPERTY 裸指针GC 会自动把这个引用当作强引用视觉。只要宿主对象存活被引用对象就跟随存活。但有一种场景让很多人纠结写一个纯 C 的 Manager不是 UObject还想长期保留一个 UObjject 资产避免它被 GC 回收。这时候 UPROPERTY 用不了你就可以用 TStrongObjectPtrclass FMyManager { TStrongObjectPtrUDataAsset HeldAsset; };TStrongObjectPtr 本质上是往 GC 的根集里注册了一条引用。持有期间这个对象不会被回收当 TStrongObjectPtr 被销毁或重置注册的引用被移除对象重新恢复“可以由 GC 决定生死”的状态。另一种做法是继承 FGCObject重写 AddReferencedObjects把自己关心的 UObject 引用显式注册给 GC 收集器。这种方式在大型非 UObject 系统中很常见但需要你自己维护引用列表。4.4 容器里的对象引用数组、Map、Set 也要正确声明GC 不仅扫描普通成员变量还会扫描各类容器属性但前提是容器也被 UPROPERTY 标记。比如UPROPERTY() TArrayUObject* ObjectList; UPROPERTY() TMapFName, AActor* ActorMap;引擎在遍历属性时会到容器内部找出所有 UObject 指针。这里要特别注意如果你用 TArrayUObject* 存了对象但没有 UPROPERTY 标记GC 完全忽略它对象随时可能被回收而数组里的指针继续指向已释放的内存。类似情况还包括 TSet、TMap 的 key 和 value、嵌套结构体里的引用字段等。5. 现实中仍然会写 delete 的几个角落如果你觉得 Unreal 里完全不存在 delete 了那也不对。它只是从 UObject 的世界里被赶了出去纯 C 的角落里仍然需要你手动释放。非 UObject 的纯 C 类。比如你自己定义的消息结构、数学工具、编辑器工具类。它们没有 UCLASS 声明不参与反射也不受 GC 保护。new 出来的对象如果不用了依然要 delete或者用 TUniquePtr 来托管。跨第三方库的集成代码。接入外部 SDK 时对方提供的接口往往要求你传入指针并自行释放。这种情况下你当然要按第三方库的规则手动管理不能依赖 Unreal GC。编辑器脚本以及独立工具模式。在一些没有完整游戏世界上下文的后台工具里UObject 可能不会被主动 GC遗留对象容易占内存这时候你需要显式触发收集或者干脆在工具代码里使用标准 C 内存管理。在 UObject 生命周期之外原生分配的资源。比如 FMemory::Malloc 分配的缓冲区或者通过 C 接口拿到的句柄终究需要手动释放。一个重要边界UObject 类的成员如果是普通 C 对象你依然可以在析构函数里 delete 它们。GC 不管这些。真正不该做的是在 UObject 的析构流程里去 delete 其他 UObject。6. 垃圾回收调试与避坑手记这部分我结合自己踩过的坑整理一份实际可用的检查思路。6.1 漏了 UPROPERTY 的症状是什么最典型的场景一个对象明明只有一个地方引用但引用字段漏标记。运行一段时间后GC 把它回收了其他地方再访问裸指针可能返回垃圾数据或直接崩溃。这类问题定位起来特别费劲因为不是稳定复现而是“运行几分钟后偶尔崩”。排查手段很简单先检查所有被人为保存的 UObject* 字段逐一确认有没有 UPROPERTY()。如果你已经用 TWeakObjectPtr 或 TStrongObjectPtr那可以跳过如果用的是裸指针必须保证它要么被 UPROPERTY 覆盖要么放在专门管理的容器中。6.2 在 UObject 析构函数里 delete 其他 UObject 是高危操作我曾经在一个项目的组件析构函数里写了类似这样的逻辑删除一个关联 Actor。看起来是在“收尾”但实际上这个 Actor 也可能正在被 GC 处理。GC 的清理流程是并行协调的手动 delete 等于多次干预对象的销毁状态结果经常是二次释放或者触发退出时崩溃。正确思路是把这类清理交给 GC。只需要维持好的引用关系如果当前对象应该保持另一个 Actor 存活用 UPROPERTY如果不需要跟随存活用 TWeakObjectPtr 监听。不要在销毁流程里搞“手动引爆”。6.3 Lambda、异步任务与引用失效异步任务里捕获 UObject 裸指针是另一个高发问题。比如一个异步加载回调结束后想去操作某个 ActorFWeakObjectPtr WeakActor TargetActor; FAsyncTask::DoSomething([WeakActor]() { AActor* Actor WeakActor.Get(); if (Actor) { // 安全操作 } });正确用法是捕获 TWeakObjectPtr执行时校验有效性。如果你直接捕获裸指针异步执行时机不确定其间 GC 可能已经把对象回收了回调里访问就是悬空指针。6.4 用控制台和工具观察 GCUnreal 提供了一些调试指令平时干排查很有用。比如在编辑器 PIE 运行时打开控制台输入 obj list 可以列出当前所有 UObjectobj refs class某个类名可以查看某个类对象的引用关系memreport -full 可以导出详细内存报告帮助你观察对象是否异常增多。我自己排查内存问题时一看引用二看数量。某个类对象数量持续上升先看它是否被根集或静态容器持有如果没有再看是不是有漏标记引用让 GC 认为它不可达。对象释放不掉和对象过早死亡往往来自同一类错误——引用关系没有正确表达。6.5 不要尝试手动触发 GC 来“解决一切”引擎里确实有 ForceGarbageCollection 之类的接口但它适用范围有限频繁调用会导致明显的卡顿。你真正应该做的是让引用图保持干净。GC 不是用来急救的它照顾的是常规生命周期管理如果你的工程逻辑里处处依赖“手动强制回收”那说明引用关系设计已经出问题了。分享一个我后来的工作习惯写任何持有 UObject 的字段之前先问自己三个问题——它必须让对象存活吗如果必须我是否已经用 UPROPERTY 或 TStrongObjectPtr 声明了强引用如果不需要我是否用了 TWeakObjectPtr 避免野指针这套判断做完Unreal 的内存管理就从“玄学”变成了普通日常工作。GC 不是不用动脑的自动挡它只是把精力从“何时 delete”转移到了“如何声明引用”上而这正是 Unreal 对 C 做的最大改造。
返回列表