ARTICLE DETAIL

资讯详情

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

理解UObject内存模型:从创建到GC回收的完整指南

理解UObject内存模型:从创建到GC回收的完整指南 这章节我们直接切入正题UObject 的内存模型。前四章我们聊完了引擎整体内存框架、分配器、容器这次终于轮到“对象”本身。在 Unreal 里UObject 是一切资产、组件、蓝图实例、游戏玩法的载体。你可以不懂分配器的细节但只要写过 UE 代码就绕不开 UObject 的创建、引用和销毁。但恰恰是这三个环节藏着无数隐蔽的坑为什么不能直接 new为什么构造函数里访问其他对象会出问题为什么明明没删过对象内存还是持续上涨这些问题的答案其实都在“内存模型”四个字里。本章我会从 UObject 为什么存在讲起然后拆解创建链路、内存布局、生命周期最后给出一份基于实战的排查方法。先把结论放在前面理解了 UObject 的“受管对象”本质比记住任何 API 都重要。1. UObject 为什么存在它不是一层壳而是一套公民身份系统很多从 C 背景转过来的开发者一开始会有一个强烈的直觉UObject 不就是带了一点反射信息的普通类吗我用new一样能建对象为什么要被 NewObject 管着这个想法我当年也有过直到第一次用裸指针存 UObject 存出了野指针崩溃才明白 UObject 的“身份”不是附赠品而是引擎一切机制的地基。1.1 普通 C 对象和 UObject 的本质差异一句话概括普通 C 对象是“散落在内存里的个体”UObject 是“拥有户籍和档案的公民”。前者创建后除了你自己谁也不认识它后者从出生起就被引擎全局登记可以被查找、被序列化、被网络复制、被编辑器感知、被 GC 自动回收。具体到技术能力上差异体现在这些方面反射能力UObject 通过 UClass 知道自己是谁、有哪些属性、哪些函数可以被 Blueprint 调用。序列化能力UObject 可以将状态写入磁盘Asset 保存/加载依赖的是反射驱动的属性枚举而不是手写 Save/Load。网络复制Actor 和其 Subobject 的同步机制全部挂在 UObject 的 Outer 链与属性复制系统上。编辑器集成编辑器里看到的“细节面板”本质上是一套反射驱动的属性检查器。垃圾回收GC 需要遍历所有 UObject 的引用关系这只有通过 UPROPERTY 反射才能做到。你看GC 并不是因为引擎“喜欢”管内存才去扫描 UObject而是因为只有 UObject 有完整的引用图信息引擎才知道哪些对象不该死。1.2 用“居民身份”来理解 UObject 系统拿现实类比你直接new一个对象就像在路边搭了个帐篷谁也不知道里面住着谁哪天被风吹走了也没人管。而 NewObject 出来的 UObject就像去派出所登记了一个户口——姓名Name、归属Outer、档案UClass、状态Flags、编号Index全部录入系统。这个类比能帮你理解后面所有细节Name 不是给人看的是给查找系统用的所以同一个 Outer 下不能重名。Outer 是“户口所在地”决定了对象所有权归谁、跟谁一起被销毁。Flags 是“身份标签”标记你是临时的、还是资产、还是模板。Index 是“身份证号”GC 和弱引用全靠它定位对象。理解了这些NewObject 的每个参数就不再是“填着玩的”而是决定对象“社会关系”的关键字段。1.3 拒绝 UObject 体系的代价有人会问我不用 UObject自己写个普通结构体存数据不香吗香完全可以。纯数据结构比如配置信息、向量集合、临时计算中间量用普通 C 类或结构体完全合理性能更好也没 GC 开销。但如果这个对象需要被蓝图访问、需要跨模块识别类型、需要保存到 Asset、需要被网络复制、需要自动跟随关卡释放内存——那它就离不开 UObject 的身份系统。选择普通结构体不是不行而是你得自己接手手动序列化、手动网络同步、手动生命周期管理。短期开发一时爽项目中期开始填坑后期陷入泥潭。所以我的建议很简单需要被引擎“管”的东西老老实实继承 UObject纯计算和存储在函数栈上完成的东西才用普通 C 类型。用错场合才是 UObject 被人诟病“慢”的真正原因而不是 UObject 本身设计有问题。2. 创建 UObjectNewObject 背后到底发生了什么现在进入正题一个 UObject 是怎么被创建出来的。代码上你可能只写了一句 NewObject但背后是一条完整的“出生流水线”每一步都有明确的目的。2.1 为什么不允许直接 new UObject这是新手第一个会被撞的头。在 Unreal 里UObject 的子类不能直接用new创建即使你能通过某些方式绕过限制也一定会出问题。原因很简单构造函数不是 UObject 生命周期的开始只是漫长流程中的一个节点。我们看一下创建 UObject 的完整链路UE5.x 下简化版UMyObject* Obj NewObjectUMyObject(this); // 等价于调用 // StaticConstructObject_Internal( // UMyObject::StaticClass(), // Outer /* this */, // FName() /* 自动生成 Name */, // RF_NoFlags /* 默认 Flags */);StaticConstructObject_Internal 内部大致做了这些事分配原始内存走引擎的分配器不是系统默认的operator new。在原始内存上构造 UObjectBase 基类部分设置 ClassPrivate、NamePrivate、Flags、InternalIndex。调用实际类的构造函数初始化 UObject 自身及成员变量。将该对象的 FObjectInitializer 传入构造函数支持子对象Subobject的初始化。设置 Outer 指针建立对象与外部对象的所有权关系。把对象注册进 GUObjectArray 全局对象数组获得唯一 Index。执行 PostInitProperties 回调。这里有个关键点构造函数实际上是在内存分配之后、注册完成之前调用的。所以在构造函数内部对象还没有完全“成为”一个合格的 UObject——它还没有注册到全局数组里因此无法被 GC 扫描到如果你在构造函数里缓存了另一个对象的指针而这个指针没有走 UPROPERTYGC 永远不会知道它的存在。这也是为什么官方一直强调不要在构造函数里访问任何外部对象不要做复杂的初始化逻辑。要对所有成员赋值最好在 PostInitProperties 或更晚的 Init 函数里做。2.2 创建后注册到 GUObjectArray拿到“身份证号”new一个普通对象内存地址就是你唯一的身份凭证。但对 UObject 来说内存地址不可靠GC 不会移动对象但对象被销毁后地址会被复用裸指针拿到手时对象可能已经没了。UObject 的“强身份凭证”是 GUObjectArray 里的 Element index。就像你住址会变但身份证号不会变。GC 在扫描、弱引用在判空、编辑器在搜索时都依赖这个编号而不是裸地址。当你执行了 NewObject 之后可以随时用GetUniqueID()查看这个编号也可以从 FUObjectItem 中获取更多状态信息。很多引擎工具如 obj list能列出对象数量和类型本质就是遍历 GUObjectArray。2.3 Outer 参数决定所有权与生命周期NewObject 的第一个参数是 Outer很多人会随便传一个 this但 Outer 是 UObject 生命周期的核心。Outer 有三大作用所有权Outer 被销毁时它的所有子对象会被 GC 一并标记为不可达。也就是说Outer 决定对象的“生存上限”。命名空间对象在同一个 Outer 下名字唯一。不同 Outer 下允许同名对象存在。网络归属Actor 的 Subobject 谁拥有同步范围就归谁管。举个例子你在一个 Actor 里创建了一个 UMyComponent 作为子对象UMyComponent* Comp NewObjectUMyComponent(this, UMyComponent::StaticClass(), TEXT(MyComp));这个组件的 Outer 指向 Actor。Actor 被销毁后如果这个组件没有被单独持有强引用它会在下一次 GC 中自动回收。如果你随便给它指定了一个说不清寿命的 Outer比如传了 nullptr或者一个临时对象轻则对象生命周期异常重则内存泄露或访问野指针。实际开发中我习惯的原则是Outer 永远传“活得时间足够长、语义上归属清晰”的对象。游戏世界中的临时数据挂 GameInstance 或 World资产加载的对象挂 Package编辑器工具创建的辅助对象挂编辑器自身的 Transient 包。2.4 Name 和 Flags给对象起名贴标签NewObject 的 Name 参数不是必须的不传时会自动生成一个唯一名。但如果你需要频繁查找一个对象显式命名的价值就出来了UObject* FindObj FindObjectUMyObject(this, TEXT(MyComp));而 Flags 控制对象的“身份属性”常用几个RF_Transient临时对象不会被保存到磁盘。经常用于运行时动态构建的数据以及编辑器买菜用的中间数据。RF_ArchetypeObject标记对象是类的默认模板CDO。RF_Standalone即使没有被引用也保持存活强引用1。常用于资产根对象。RF_NoFlags默认状态完全受 GC 管理。Flags 用按位或组合。我踩过的坑是给资产对象加了 RF_Transient 导致编辑器保存时数据丢失当时排查了很久才发现是 Flags 的问题。创建时多看一眼标签能省一个下午。3. UObject 的内存布局对象头、UClass 指针与成员排布理解了创建流程再看 UObject 在内存里到底长什么样。这块偏底层但它决定了你调试内存和优化缓存时的直觉。3.1 每个 UObject 都有约 32 字节的“对象头”UObject 继承自 UObjectBaseUtility最终继承自 UObjectBase。在 64 位平台上UObjectBase 包含以下成员UClass* ClassPrivate指向自己所属的 UClass 对象8 字节。EObjectFlags ObjectFlags对象标态4 字节但会按平台对齐填充。int32 InternalIndexGUObjectArray 里的索引4 字节。算上对齐光 UObjectBase 就占 16 字节左右。再往上UObject 自己加了UObject* OuterPrivate8 字节以及一个指向内部对象的FThreadSafeObjectTarget*链式指针。到了你自定义的 UMyObject它的成员变量紧跟在对象头之后按对齐规则排布。所以你在内存剖析器里看到的一个 UObject 实例占用远不止“自己的成员变量大小”而是对象头 自己的成员 可能的内存对齐填充 构造时请求的分配器头。对性能敏感的场景大量小型 UObject 的对象头成本会比较明显这是引擎乐意你用普通结构体做高频数据的原因。3.2 UClass 指针反射与 GC 的枢纽普通 C 类依靠虚表运行时识别类型UObject 则不同它直接持有一个 UClass*通过 UClass 再找到 Property、Function、Native 函数列表等所有元信息。这种“双层设计”的价值在于反射系统需要遍历类的属性虚表做不到——虚表只保存虚函数地址不保存成员变量布局。UClass 本身也是一个 UObject可以拥有自己的 UProperty 列表还能被蓝图继承和修改。GC 的引用遍历本质上就是拿到对象的 UClass遍历其网申标记过的 UProperty逐个访问引用的其他 UObject。举个例子当你声明了一个UPROPERTY() UMyObject* Child;时UE 在编译阶段为这个属性生成了 UProperty 描述符。GC 扫描到这个对象时会通过 UClass 找到 Child 属性读取里面的指针把它加入引用图。这个过程中你不需要写任何“手动标记”的代码全靠反射系统“看到”了你的属性。这就是 UPROPERTY 对 UObject 生命周期如此重要的根本原因没有 UPROPERTYGC 就相当于一个瞎子在找对象——它根本不知道你的成员变量指向了谁。3.3 对象在 GUObjectArray 中的登记形式每个 UObject 被创建后就会在 GUObjectArray 中对应一个 FUObjectItem。这个 item 里保存了UObject* Object真实对象指针。int32 Flags对象状态位比如是否已经标记为不可达、是否处于异步卸载中。int32 ClusterRootIndex所属 GC 聚簇的根索引UE5 使用 Cluster 优化内存管理。uint32 SerialNumber对象序列号配合弱引用检测使用。Cluster 是 UE5 的一个优化点关卡内大量 Actor 和 Component 会被打包成一个 ClusterGC 在可达性分析时先判断 Root 是否可达如果整个 Cluster 不可达就整体回收避免逐个遍历几十万个对象。说到 SerialNumber 就必须提 TWeakObjectPtr。弱引用只存两样东西对象的 Index 和 SerialNumber。对象被 GC 后FUObjectItem 会失效此时弱引用通过 SerialNumber 校验就能立刻判定“原对象已不存在”。这也是为什么 WeakPtr 比裸指针安全它相当于“身份证号 注销标记”而裸指针只是“最后一次看到的地址”。4. 生命周期从出生到回收GC 是如何判定死亡的UObject 的生命周期不是简单的 new/delete它有明确的状态流转。理解这个状态机才能解释很多诡异问题为什么已经在FinishDestroy里做的清理对象仍占内存为什么有些对象在编辑器里关掉关卡后还赖着不走4.1 关键事件时序一个典型的 UObject 生命周期事件顺序如下阶段事件说明创建构造函数对象头与成员变量初始化不能访问外部对象注册PostInitProperties完成对象属性初始化可以读取 CDO 的默认值加载PostLoad从磁盘加载 Asset 时调用用于加载依赖资源使用正常运行读写属性、调用方法标记BeginDestroy对象即将被销毁可做异步清理准备最后清理FinishDestroy释放非 UObject 资源对象不再被引用内存释放内存归还分配器分配的内存交还引擎这个流程里有两个容易被忽视的点UMyObject::BeginDestroy() { // 此刻对象还在 GUObjectArray 中但已经不会被新引用 Super::BeginDestroy(); } UMyObject::FinishDestroy() { // 清理自己持有的非 UObject 资源 delete NativeResource; Super::FinishDestroy(); }BeginDestroy 和 FinishDestroy 之间的时机取决于 GC 执行。当 UObject 被标记为不可达后引擎会先调用 BeginDestroy如果这个对象有异步销毁的需求Destroy 会延迟到下一帧或资源释放完成。这意味着BeginDestroy 后对象的内存仍然存活不要以为“被销毁了就能立刻释放”。还有一点PostInitProperties里做什么、不做什么是新手和老手的分水岭。我见过有人把地图里的资源加载逻辑放在 PostInitProperties 里结果是编辑器每次编译重载也会触发导致奇怪的加载次数。标记一下PostInitProperties 是“属性初始化完毕”的信号不是“这个实例准备开始干活”的信号。4.2 GC 的三色标记原理Unreal 的 GC 核心是“可达性分析 标记清除”。所有仍然存活的对象最终都有一个共同特征能够从 Root Set根集合出发通过引用链访问到。根集合包括全局对象比如引擎单例、GameInstance、World。被标记为 RF_Standalone 的对象通常是资产根。明确被 AddReferencedObjects 添加过的对象。当前执行栈中处于“保护”状态的对象。GC 的过程可以拆解为三步简化描述从根出发把所有直接引用的对象标为“灰”。反复取出灰对象遍历它引用的所有对象将它们标“灰”同时把当前对象标“黑”。直到没有灰对象所有黑对象是存活的其他全是垃圾回收用户。这个算法不用我说你也看出来了它要求“知道每个对象引用了谁”而这条信息只能从 UPROPERTY 反射里拿到。所以再次强调UPROPERTY 不是装饰是 GC 的“眼睛”。如果一个 UObject 被一个全局 Map 以裸指针形式保存而 Map 本身又不在 GC 可达路径上GC 会认为这个对象是垃圾并回收它留下一个悬垂指针。游戏里最典型的崩溃场景就是这样“悄悄死掉”的引用。4.3 手动引用AddReferencedObjects 与 FGCObject那么问题来了我的 UObject 的确被一个不继承 UObject 的普通 C 类持有了比如自定义的 Manager、线程、SDK 回调上下文怎么办这时候你有两个选择在该 UObject 的类里重写AddReferencedObjects把自己的引用注册到 GC 根集。在 GC 不可见的持有方继承FGCObject用AddReferencedObjects声明自己持有的 UObject。工程上更推荐后者因为它语义清晰。举个例子class FMyManager : public FGCObject { virtual void AddReferencedObjects(FReferenceCollector Collector) override { Collector.AddReferencedObject(ManagedObject); } virtual FString GetReferencerName() const override { return TEXT(FMyManager); } UMyObject* ManagedObject nullptr; };这里有个细节GetReferencerName必须实现否则 GC 在调试时无法知道是谁引用了对象排查引用计数问题时无从下手。4.4 强引用、弱引用与裸指针的正确用法UObject 的引用方式有三种很多人混用结果把自己埋了引用方式特点适用场景UPROPERTY() 强引用被 GC 扫描不使对象被回收绝大多数跨 Actor 的对象持有TWeakObjectPtr不阻止 GC能感知对象已销毁缓存、观察、事件回调、编辑器工具裸指针不阻止 GC也无法感知销毁同一生命周期内的短时访问使用原则一句话能 UPROPERTY 就 UPROPERTY能 WeakPtr 就 WeakPtr裸指针只用于“临时拿过来用一下”的场景。我见过大量崩溃的核心原因就是把裸指针存到容器里跨帧使用而对象早就被 GC 收走了。值得提一个容易被忽略的点Lambda 捕获裸指针也很危险。异步任务、定时器回调、网络回调里捕获了 UObject 的裸指针如果在回调执行前对象被 GC回调就变成了访问野指针。正确做法是在 Lambda 里用 TWeakObjectPtr执行时先IsValid()再取值。5. 定位与避坑UObject 内存问题的实战排查到了实战环节。这些坑都是我实际踩过或者帮别人排查过的按频率从高到低排列。5.1 对象数量暴涨、内存只增不减现象长时间挂机后内存持续上涨obj list 里某个类型的对象数量一直增加。排查思路打开控制台命令obj list按类型统计对象数。如果能找到某个类型数量异常嫌疑就圈定了。看看创建它的代码路径。数量只增不减的常见原因是对象被创建后没有任何“死亡触发”的路径比如创建在 World 上但 World 关掉时这个对象仍被一个全局普通容器持有裸指针。GC 认为有人引用它又因为持有方不可见永远回收不了。查找所有裸指针容器TArrayUObject*、TMapint32, UObject* 等审查其存放逻辑是否在对象销毁时同步清理。常见修复方案持有方继承 FGCObject改为强引用登记。将容器里的裸指针改为 TWeakObjectPtr并在读取时校验有效性。确保对象创建时 Outer 传了一个“和它同生共死”的对象比如挂到 World 或 GameInstance 上。5.2 悬垂指针导致的崩溃现象随机崩溃崩溃栈里 UObject 地址看起来“合理”但内容已损坏或者 IsValid 返回 true但访问成员变量时内存越界。这个问题的根源往往不是“指针失效”而是“指针指向了已被回收且被复用的内存”。裸指针是做不到自动失效检测的这是它最危险的地方。解决手段优先使用 TWeakObjectPtr。弱引用的原理我们再回顾一次它保存了对象的 Index 和 SerialNumber当对象被 GC 后FUObjectItem 被标记失效下一次 IsValid 就能返回 false。而裸指针拿到的是一个已经归还给内存池的地址可能被另一个对象占用排查起来非常痛苦。5.3 构造函数初始化了外部资源导致反复加载或崩溃现象从编辑器打开关卡一切正常打包后跑到特定地图闪退日志显示构造了过多对象。原因多是构造函数里加载了 Asset、创建了其他 UObject、访问了网络或文件。构造函数阶段对象尚未注册到 GC此时创建的 UObject 引用关系异常容易造成强引用环或提前回收。正确做法构造函数只做普通成员的初值赋值把“需要访问引擎资源”的逻辑放到 BeginPlay、OnConstruction 或自定义 Init 方法里。5.4 我压箱底的三条 UObject 内存准则第一不要相信任何 UObject 指针能在“不确定的将来”依然有效。跨帧、跨函数、跨模块持有 UObject除了 UPROPERTY 和强引用机制其余一律用 TWeakObjectPtr 或者重新查询。第二Outer 是生命周期的硬件约束。创建对象前先问自己它的所有权归属于谁游戏里常见错误是把动态创建的 UObject 挂在临时 Actor 下这个 Actor 很快就死了对象也跟着被回收——而你的引用还死皮赖脸地存在别处。正确的做法是把它挂到负责它生命的对象上比如 Controller、GameMode、World。第三GC 是权威但你得顺手帮它清理。大规模游戏里对象层出不穷GC 默认策略未必能及时清扫。如果你的对象是可预测的、短时间内大量创建的比如子弹命中特效、临时交互 UI建议在合适时机手动触发条件性 Finalization比如GetWorld()-ForceGarbageCollection(true)保证内存不至于在峰值瞬间被对象撑爆。再给一个调试技巧在DefaultEngine.ini的[Core.System]下开启gc.AgeThreshold1或相关阈值后可以提高 GC 频率来暴露问题。如果开了这个后对象数量显著下降说明你的对象依赖 GC 兜底但延迟回收未达预期需要主动管理生命周期。另一个密集排查场景是编辑器里反复 PIEPlay In Editor内存上涨。这种问题一般跟 PIE 的 World 没有被正确销毁有关。可以用obj list对比进入关卡前后的对象数差异通常你会发现某些对象创建时把 Outer 传成了旧的 PIE World 或者编辑器持久对象导致它们不随新 World 清理。结语写到最后还是想多说两句个人的体会。UObject 是对引擎整个生态的“治理模型”它用一套从出生到死亡都受监管的机制换来了 GC、反射、序列化、网络这些庞大能力的统一。初次接触它的人会觉得这套东西繁琐、规矩多、为什么不能像普通 C 类那么自由。但当你真正面对一个几百万行代码的项目需要查一个对象是不是泄漏、需要判断一个引用何时失效、需要让内存按预期回落时你会感谢这套“宿管制度”。最后分享一个小技巧我写 UObject 子类时习惯在头文件里把所有需要被 GC 扫描的成员都放在一起并统一加 UPROPERTY() 注释。不是为了美观而是为了在代码审查和排查时能一眼看出“这个对象到底持有了谁、谁又持有了它”。内存问题大多数时候不是“不懂 GC”而是“找不到引用关系”。把引用关系理清楚了一半的问题已经自愈。这章讲的是内存模型下一章我们继续往深走看看 UObject 的加载与序列化在内存中是怎么流转的——那又是另一个充满惊喜的世界。
返回列表