C/C++ / Note / UE · 2026年9月29日 0

深入理解 UE 的 FGCObject:非 UObject 类如何安全持有 UObject 引用

在 Unreal Engine 开发中,一个常见的坑是:自定义的普通 C++ 类(非 UObject 派生类)持有了 UObject 指针,但 GC 根本不知道这件事。结果就是,你以为还“持有”着的对象,可能在某个 GC 周期后被悄悄回收,留下一个悬空指针。

UE 为这个问题准备的答案就是 FGCObject。本文将从原理出发,解释它如何工作,以及如何在你的项目中正确使用它。

一、问题背景:GC 只认识 UObject

UE 的垃圾回收器采用标记-清扫(Mark-and-Sweep) 算法。它的工作流程大致是:

  1. 从“根对象”(Root Set)出发,递归遍历所有可达的 UObject 引用
  2. 标记所有能找到的对象为“存活”
  3. 清扫阶段释放所有未被标记的对象

关键在于:GC 只追踪 UObject 之间的引用链。当你有一个普通的 C++ 结构体或类,里面存了 UObject* 指针时,GC 对此一无所知。这个指针对 GC 而言是“不存在”的。

传统解决方案是手动调用 AddToRoot() 和 RemoveFromRoot()。但正如你在开发中可能已经体会到的,这种方式有两个致命问题:

  • 时机不可控:如果程序退出时析构顺序晚于 UObject 系统清理,RemoveFromRoot() 会访问已失效的指针,直接崩溃
  • 容易遗漏:每个 Add 都要对应一个 Remove,手动管理容易出错

二、FGCObject 的设计思路

FGCObject 是 UE 为“非 UObject 类需要安全持有 UObject 引用”场景提供的基类。它的核心机制可以概括为一句话:

FGCObject 把自己注册到一个全局的 UObject(UGCObjectReferencer)上,让这个 UObject 成为“中间人”,在 GC 运行时替非 UObject 类申报引用

具体来说,当你继承 FGCObject 并构造实例时:

  1. FGCObject 的构造函数会调用 StaticInit(),确保全局唯一的 UGCObjectReferencer 存在,并将其加入 Root Set
  2. 你的 FGCObject 实例把自己注册到 UGCObjectReferencer 的内部列表中
  3. UGCObjectReferencer 本身是 Root Set 中的 UObject,所以 GC 每次都会扫描它的引用
  4. GC 扫描到 UGCObjectReferencer 时,会调用它的 AddReferencedObjects,进而遍历所有已注册的 FGCObject,调用它们各自的 AddReferencedObjects

这样,你的非 UObject 类就“借道” UGCObjectReferencer 进入了 GC 的视野。

三、正确使用方式

3.1 继承并实现纯虚函数

FGCObject 是一个抽象基类,必须实现 AddReferencedObjects:

cpp

#include "UObject/GCObject.h"

class FMyNonUObjectClass : public FGCObject
{
public:
    UObject* MyObject;
    
    FMyNonUObjectClass(UObject* InObject) : MyObject(InObject) {}
    
    virtual void AddReferencedObjects(FReferenceCollector& Collector) override
    {
        Collector.AddReferencedObject(MyObject);
    }
    
    virtual FString GetReferencerName() const override
    {
        return TEXT("FMyNonUObjectClass");
    }
};

FReferenceCollector 是 GC 提供的收集器,你通过它“申报”持有的 UObject 引用。GetReferencerName() 不是必须的,但强烈建议实现——调试 GC 问题时,这个名字会出现在引用链中,帮你快速定位是谁在持有对象。

3.2 处理容器中的 UObject

如果你的非 UObject 类持有的是 TMap、TArray 等 UE 容器,FReferenceCollector 提供了专门的重载:

cpp

virtual void AddReferencedObjects(FReferenceCollector& Collector) override
{
    Collector.AddReferencedObjects(MyMap, this, nullptr);
}

第二个参数 this 是 ReferencingObject(用于调试),第三个参数在非 UObject 场景传 nullptr。这个重载会遍历容器内所有 UObject 指针并登记。

3.3 关键注意事项

不要用值语义存储 FGCObject。官方文档明确指出:FGCObject 不是 trivially relocatable 的类型,不能按值放入 TArray、TMap 等容器中。原因在于它的注册机制依赖于对象地址的稳定性。作为类的成员变量或独立对象使用是安全的。

AddReferencedObjects 是“申报”而非“管理”。你不需要在析构时手动清理引用。FGCObject 的析构函数会自动从 UGCObjectReferencer 中注销自己。下次 GC 运行时,如果你的对象已经没了,它自然就不会再出现在申报列表里,那些 UObject 如果没有其他引用,就会被正常回收。这从根本上避免了析构时机导致的崩溃问题。

四、GC 何时调用 AddReferencedObjects?

这是一个常见的误解点:AddReferencedObjects 不是定时器回调,也不是轮询。

它的调用时机完全由 GC 的标记阶段决定。每当引擎执行一次垃圾回收(可能由关卡加载、内存压力、手动触发等事件引起),在“可达性分析”阶段,GC 会从 Root Set 开始扫描。当扫描到 UGCObjectReferencer 时,就会遍历所有注册的 FGCObject,逐个调用 AddReferencedObjects。

所以准确的说法是:每次 GC 运行时,你的 AddReferencedObjects 会被调用一次。调用频率取决于 GC 跑得多勤,而不是墙上的时钟。

这也意味着:你随时可以往自己的容器里增删 UObject 指针,GC 不关心你中间做了什么。它只关心在它下次来问的时候,你申报的当前名单是否准确。申报了的就保住,没申报的就可以回收。

五、与 AddToRoot/RemoveFromRoot 的对比

维度AddToRoot/RemoveFromRootFGCObject
管理粒度单个 UObject类级别,所有引用统一申报
析构安全性差,时机不可控易崩溃好,注销与申报分离
引用状态永久 Root(直到手动移除)动态,每次 GC 时重新评估
内存效率较低,对象一直 Root 直到手动清理较高,引用消失后下轮 GC 即可回收
适用场景简单、临时的场景任何需要长期稳定持有 UObject 引用的非 UObject 类

FGCObject 的核心优势在于把“申报引用”和“生命周期管理”解耦了。你不需要在析构时做任何 UObject 操作,也就不存在“析构晚于 GC 系统销毁”的问题。

六、总结

FGCObject 是 UE 为 C++ 开发者提供的一个桥梁,让非 UObject 类能够以符合 GC 规则的方式持有 UObject 引用。它的工作流程可以概括为:

  1. 注册:FGCObject 构造时把自己挂到全局的 UGCObjectReferencer 上
  2. 申报:每次 GC 标记阶段,UGCObjectReferencer 会遍历所有 FGCObject,调用其 AddReferencedObjects
  3. 注销:FGCObject 析构时自动从 UGCObjectReferencer 移除,下次 GC 不再申报

如果你在开发中遇到过“自定义类持有的 UObject 莫名被回收”或者“程序退出时 RemoveFromRoot 崩溃”的问题,FGCObject 就是正解。