相比原生的cpp,UE框架下的upp有反射、内存碎片整理、自动GC等等功能,UE框架是如何做到这些功能的,是通过类似于托管语言的虚拟机吗?

UE并不是通过虚拟机(如 JVM 或 CLR)来运行的,它本质上依然是原生的 C++ 代码。

UE的强大之处在于它在原生 C++ 之上构建了一套精密的“对象系统层”(UObject System)。这套系统通过预编译器和一套严密的运行库,模拟了许多托管语言(如 C# 或 Java)才有的特性。

UHT(Unreal Header Tool)提供反射

官方文档的定义:(补充:UBT是通过C#处理依赖的)

虚幻头文件分析工具(UHT) 是虚幻引擎的一种自定义解析和代码生成工具。UHT可为虚幻引擎(UE)的 UObject 系统解析并生成代码。虚幻引擎中的代码编译分两个阶段进行:

  1. 虚幻编译工具(Unreal Build Tool (UBT)) 会调用UHT,后者将解析C++头文件,获取与虚幻引擎相关的类元数据,并生成自定义代码,以实现各种与UObject相关的功能。

  2. UBT会调用配置的C++编译器来编译结果。

人话:主要的作用是生成.generated.h.gen.cpp,以提供访问元数据的方法,这些元数据可以提供给蓝图,GC系统、序列化、网络复制等

在cpp正式编译之前,需要先进行一遍UHT扫描,主要作用流程:

  1. 扫描宏: UHT 扫描头文件,寻找 UCLASSUPROPERTYUFUNCTION 等宏。

  2. 生成影子代码: 看到 GENERATED_BODY() 后,UHT 会生成对应的 .generated.h.gen.cpp 文件。

  3. 创建“元数据”: 即使程序还没运行,UHT 已经把你的类里有哪些变量、哪些函数的信息写进了生成的 C++ 代码里。

GENERATED_BODY()的作用

前面提到,UHT扫描到GENERATED_BODY() 后,会生成对应的 .generated.h.gen.cpp 文件。

例如一个头文件

#pragma once
#include ...
#include "ChangeMapManager.generated.h"

UCLASS(Blueprintable)
class XXX_API UChangeMapManager : public UObject
{
    GENERATED_BODY()
public:
    UFUNCTION(BlueprintCallable,Category="Change Map",meta=(DisplayName="切换地图"))
    static void ChangeMap(FName MapName,UObject* WorldContextObject);
};

在扫描之后变成:

class XXX_API UChangeMapManager : public UObject
{
    //GENERATED_BODY 展开出来的一堆东西
    static void StaticRegisterNativesUChangeMapManager();
    friend struct Z_Construct_UClass_UChangeMapManager_Statics;
    DECLARE_CLASS(...)
    DECLARE_SERIALIZER(UChangeMapManager)
public:
    UFUNCTION(BlueprintCallable,Category="Change Map",meta=(DisplayName="切换地图"))
    static void ChangeMap(FName MapName,UObject* WorldContextObject);
};

GENERATED_BODY 会展开成一系列编译器需要的样板代码,包括:

  • StaticClass() 函数:让引擎知道这个类的类型信息。

  • 类型转换支持:实现 Cast<UChangeMapManager>(Obj) 的底层逻辑。

  • 序列化逻辑:如何把这个类的变量保存到硬盘或从硬盘读取。

  • 友元声明:允许引擎的反射系统访问你的 private 成员

.generated.h文件:(简化版)(XXX是项目名

#include "UObject/ObjectMacros.h"
#include "UObject/ScriptMacros.h"

PRAGMA_DISABLE_DEPRECATION_WARNINGS
class UObject;
#define XXX_Source_XXX_Manager_ChangeMapManager_h_10_RPC_WRAPPERS \
 \
    DECLARE_FUNCTION(execChangeMap);  //声明成员的反射信息
#define XXX_Source_XXX_Manager_ChangeMapManager_h_10_INCLASS_NO_PURE_DECLS \
private: \
    static void StaticRegisterNativesUChangeMapManager(); \  //注册
    friend struct Z_Construct_UClass_UChangeMapManager_Statics; \  //友元声明
public: \
    DECLARE_CLASS(UChangeMapManager, UObject, COMPILED_IN_FLAGS(0), CASTCLASS_None, TEXT("/Script/XXX"), NO_API) \
    DECLARE_SERIALIZER(UChangeMapManager) //序列化

对比可以发现,在 .generated.h 文件当中,同样也有这个友元声明的结构体friend struct Z_Construct_UClass_UChangeMapManager_Statics,这个结构体是定义在.gen.cpp中,是一个“仅用于构造反射元数据的私有结构体”,在模块加载 / 程序启动时被调用。

实际调用链

  1. UHT开始扫描,生成.generated.h.gen.cpp,提供了获取反射数据的方法

  2. 编译器编译(包括源文件和新生成的两个文件)

  3. 开始运行(注意,从这里开始时运行时,即真正的元数据是运行时生成的,但是在编译之前就被定义的)

  4. 触发Z_Construct_UClass_AMyActor()、Z_Construct_UClass_AMyActor_Statics等等一系列构造函数,创建出了UCLASS对象(这个对象是“描述类的类”,静态信息,在代码里调用的 AMyActor::StaticClass() 返回的就是这个唯一的 UClass 对象),提供以下反射信息

    1. 父类信息

    2. 依赖模块

    3. 属性数组

    4. 函数数组

    5. MetaData(Category / Blueprint / EditAnywhere)

    6. TokenStream (下面会讲到,用于GC的

  5. 注册进反射系统

提一下:

在第4个点提到的UCLASS对象,是描述类的类,这个对象的引用是记录在GENERATED_BODY 展开出来的一堆东西当中,这里的UDialogueManager::StaticClass()方法也是GENERATED_BODY展开的,

所以当你使用NewObject<T>()创建一个对象时,该对象的 ClassPrivate 指针指向那个记录元数据的 UClass 对象,所以使用原生的new来创建对象是无法获取元数据也无法被自动GC的。

CDO类默认对象(ClassDefaultObject)

经过UHT扫描、编译、注册UClass()的类到反射系统之后,下一步就是UObject系统根据注册的UClass()类生成一个类默认对象(CDO),而在上述提到的UCLASS对象(描述类的类)当中,就持有这个CDO的指针。

简单来说,如果你定义了 AMyActor,引擎在启动阶段会且只会为你创建一个特殊的 AMyActor 实例,这个实例就是 CDO。

CDO的作用

  • 性能优化:当你调用 NewObject<T>() 时,引擎并不会从零开始执行一遍复杂的初始化逻辑,而是把 CDO 所在的内存块直接拷贝到新分配的内存空间里,这比逐个变量赋值要快得多

  • 提供编辑器的反射信息:在编辑器的“细节面板”里看到的那些初始数值,其实都是在查看 CDO 对象

  • 序列化优化:UE 在保存关卡(.umap)或资源(.uasset)时,为了节省空间,采用了 Delta Serialization(差量序列化),引擎会拿你的实例去跟 CDO 对比。如果你的实例里一个属性 MyHealth 和 CDO 一样,那保存文件时根本不会记录这个变量;只有当你把它改成 80 时,文件里才会存下一行:“MyHealth 改成了 80”,极大地减小了存档和资源文件的大小

注意

因为CDO是在引擎启动阶段创建的,所以不要在构造函数里写任何依赖“运行环境”的逻辑

当然,如果在构造函数中使用 CreateDefaultSubobject 是安全的,如果你在构造函数里用 CreateDefaultSubobject 创建了一个组件(比如一个 UActorComponent),这个组件的信息会被永久记录在 CDO 里。当你以后在关卡里生成这个类的实例时,引擎不会重新执行复杂的创建逻辑,而是直接从 CDO 这个“模板”里把这些子对象拷贝出来

GC与碎片管理

标记-清除算法

UE 的 UObject 系统主要并不是靠引用计数来管理的,而是依靠可达性分析

虽然 UE 的 TSharedPtr 这种原生 C++ 智能指针用的是引用计数,但对于 UObject 体系,它采用的是典型的 Mark-and-Sweep(标记-清除) 算法。

GC锁

GC lock就是字面意思,需要在程序的标记阶段加上同步锁,避免新的对象产生或者销毁,影响GC,导致对象丢失之类、野指针或者内存泄漏的问题。 在GC lock 期间,引擎会进入STW(即 Stop-the-World )状态,主线程挂起。当然为了避免卡顿问题,UE使用了多个并发线程进行标记,虽然是并行的,但游戏线程在标记的关键时刻依然是挂起的。这样可以确保在整个扫描过程中,对象的引用关系图(Object Graph)是静止不动的。既然图不动,也就不存在所谓的同步冲突,更不需要为每个指针加锁。

但是如果对象很多,或者对象的依赖关系复杂,游戏依旧会卡顿,即著名的 GC Hitch

标记阶段

在标记阶段,会从“根集合”(Root Set)开始,利用反射系统提供的属性信息(即TokenStream),递归地遍历所有被引用的 UObject,然后把这些能够被遍历到的对象标记为可达

(这一部分可以去简单看看源码,这里只做简单的解释,会去掉一些包装)

具体解释:

Root Set:

  • 引擎核心对象(UGameEngineUGameInstance)。

  • 已经加载到关卡中的 Actor。

  • 手动调用了 AddToRoot() 的对象。

  • 永久存在的资源包(Packages)。

TokenStream(参考令牌流):

来看看源码的注释:A token stream wraps up a raw string, providing accessors into it for consuming tokens

TokenStream如果简化来看其实是类似于这个东西:

TOKEN_ObjectReference offset=0x30
TOKEN_ObjectReference offset=0x38
TOKEN_End

它是记录在UCLASS对象(就是上述提到的在初始化阶段生成的“描述类的类”,也就是记录反射信息的类)当中的,

其中记录了这个对象当中需要被追踪的(即用UPROPERTY()标记的字段)的对象指针的偏移量

用人话说就是:它记录了:“在这个对象的内存偏移 X 字节处,有一个 UObject 指针”

所以GC功能是依赖于UPROPERTY()标记的反射信息的,有这样一种情况:如果一个 UObject 没有用 UPROPERTY() 标记它,但你把它存在一个 static UObject* 里,它仍然会被 GC 回收

GUObjectArray 和 FUObjectItem

GUObjectArray 可以理解为一个全局的固定大小数组。每一个通过 NewObject 创建出来的 UObject 都会在这个数组里领到一个Index(包括root集合里面的对象)

FUObjectItem作为单个UObject的管理包装,也是GUObjectArray 里面实际记录的数据,其属性部分的源码如下:

/**
* Single item in the UObject array.
*/
struct FUObjectItem
{
    // Pointer to the allocated object 
    class UObjectBase* Object;//指向具体对象的
    // Internal flags 
    int32 Flags; //这个就是记录这个对象是否可达的标志
    // UObject Owner Cluster Index
    int32 ClusterRootIndex;    //这个是记录簇的根
    // Weak Object Pointer Serial number associated with the object
    int32 SerialNumber;
 }
  • FUObjectItem的好处是:GC 系统在扫描和清理时,只需要频繁操作紧凑排布的 FUObjectItem 数组,而不需要频繁跳跃到分布在内存各处的 UObject 实例中去修改数据(减少CPU的Cache Miss)

  • 分簇 Cluster:即把一些生命周期高度重合的对象分成一个簇,比如一个有50个组件的Actor,在标记阶段,如果“簇根”是可达的,GC 会直接认为整个簇的所有对象都是可达的,可以极大地减轻标记压力

下面是标记Flags常量的定义:

/** Objects flags for internal use (GC, low level UObject code) */
enum class EInternalObjectFlags : int32
{
    None = 0,
    //~ All the other bits are reserved, DO NOT ADD NEW FLAGS HERE!

    ReachableInCluster = 1 << 23, ///< External reference to object in cluster exists
    ClusterRoot = 1 << 24, ///< Root of a cluster
    Native = 1 << 25, ///< Native (UClass only). 
    Async = 1 << 26, ///< Object exists only on a different thread than the game thread.
    AsyncLoading = 1 << 27, ///< Object is being asynchronously loaded.
    Unreachable = 1 << 28, ///< Object is not reachable on the object graph.
    PendingKill = 1 << 29, ///< Objects that are pending destruction (invalid for gameplay but valid objects)
    RootSet = 1 << 30, ///< Object will not be garbage collected, even if unreferenced.
    PendingConstruction = 1 << 31, ///< Object didn't have its class constructor called yet (only the UObjectBase one to initialize its most basic members)

    GarbageCollectionKeepFlags = Native | Async | AsyncLoading,

    //~ Make sure this is up to date!
    AllFlags = ReachableInCluster | ClusterRoot | Native | Async | AsyncLoading | Unreachable | PendingKill | RootSet | PendingConstruction
};

具体流程

综上,扫描阶段的流程是:

  1. 启动GC锁,进入STW状态

  2. 重置标志:遍历 GUObjectArray,将所有非 RootSet 对象的可达性标记清除

  3. 从根集合开始遍历

  4. 根据TokenStream 提供的信息,依次递归地寻找每一个可达的对象

  5. 如果对象可达,根据对象本身记录的InternalIndex,找到对应的FUObjectItem,并标记为可达

  6. 继续递归遍历直到没找到TokenStream

清扫阶段

在标记阶段我们已经在GUObjectArray 当中得到了被标记为“不可达”的对象,那么在清扫阶段需要遍历这个GUObjectArray 数组得到每一个需要被析构的对象吗?这个过程会不会发生一些同步问题?

其实在清扫时也可以分为两个阶段:收集阶段、增量清除阶段

收集阶段

这个阶段依旧是处于STW阶段,为了避免卡顿使用并发收集,并把这些“死掉”的对象指针塞进一个全局数组 GUnreachableObjects

部分GC源码(已简化):

/**
 * Gathers unreachable objects for IncrementalPurgeGarbage.
 *
 * @param bForceSingleThreaded true to force the process to just one thread
 */
void GatherUnreachableObjects(bool bForceSingleThreaded)
{
    GUnreachableObjects.Reset();
    int32 NumberOfObjectsPerThread = (MaxNumberOfObjects / NumThreads) + 1;
    TArray<FUObjectItem*> ClusterItemsToDestroy;

    // Iterate over all objects. Note that we iterate over the UObjectArray and usually check only internal flags which
    // are part of the array so we don't suffer from cache misses as much as we would if we were to check ObjectFlags.
    //开始并发处理
    ParallelFor(NumThreads, [&ClusterItemsToDestroy, NumberOfObjectsPerThread, NumThreads, MaxNumberOfObjects](int32 ThreadIndex)
    {
        //从各个线程处理的FirstObjectIndex开始遍历并收集
       for (int32 ObjectIndex = FirstObjectIndex; ObjectIndex <= LastObjectIndex; ++ObjectIndex)
       {
          FUObjectItem* ObjectItem = &GUObjectArray.GetObjectItemArrayUnsafe()[ObjectIndex];
          if (ObjectItem->IsUnreachable()) //判断是否为不可达
          {
             ThisThreadUnreachableObjects.Add(ObjectItem);
             if (ObjectItem->HasAnyFlags(EInternalObjectFlags::ClusterRoot))
             {
                // We can't mark cluster objects as unreachable here as they may be currently being processed on another thread
                ThisThreadClusterItemsToDestroy.Add(ObjectItem);
             }
          }
       }
    }, bForceSingleThreaded);
    //处理簇的情况
    {
       // @todo: if GUObjectClusters.FreeCluster() was thread safe we could do this in parallel too
       for (FUObjectItem* ClusterRootItem : ClusterItemsToDestroy)//遍历需要回收的簇根的子对象
       {
          ClusterRootItem->ClearFlags(EInternalObjectFlags::ClusterRoot);     
          for (int32 ClusterObjectIndex : Cluster.Objects)
          {
              //将整个簇下的对象也标记为不可达
             if (!ClusterObjectItem->HasAnyFlags(EInternalObjectFlags::ReachableInCluster))
             {
                ClusterObjectItem->SetFlags(EInternalObjectFlags::Unreachable);
             }
          }
          GUObjectClusters.FreeCluster(ClusterIndex);
       }
    }
}

增量式清扫

即分帧进行清扫,一旦标记完成,GC 已经非常确定哪些对象是“垃圾”了,所以这个过程是在主线程中完成的,不存在同步问题,锁进入增量清扫阶段时,已经没有GC lock ,这个过程是在主线程中完成的。

引擎会维护一个索引,记录上一次扫到了哪里。在每一帧的末尾,引擎会抽出几个毫秒来继续遍历 GUnreachableObjects

析构过程

在增量式清扫阶段,会依次调用:

  1. ConditionalBeginDestroy():对象被标记为“准备销毁”。它会通知渲染线程等异步线程:“我要走了,别再引用我了。”

  2. IsReadyForFinishDestroy():引擎会反复询问这个函数。直到渲染线程和物理线程都回信说“处理完了”,才会进入下一步。

  3. FinishDestroy():这是 UObject 释放内部资源的最后机会(比如手动释放申请的非 U 内存)。

  4. 调用 C++ 析构函数:最后执行真正的 ~UObject()

部分GC源码:(简化版)

bool IncrementalDestroyGarbage(bool bUseTimeLimit, float TimeLimit)
{
    bool bCompleted = false;
    bool bTimeLimitReached = false;  //记录每次清扫是否超出时间

    // Keep track of time it took to destroy objects for stats 
    //记录开始时间
    double IncrementalDestroyGarbageStartTime = FPlatformTime::Seconds();

    // Set 'I'm garbage collecting' flag - might be checked inside UObject::Destroy etc.
    //这里依旧有一个GClock,预防标记或收集期间摧毁对象
    FGCScopeLock GCLock;

    if( !GObjFinishDestroyHasBeenRoutedToAllObjects && !bTimeLimitReached )
    {
        //开始遍历并回收
       while (GObjCurrentPurgeObjectIndex < GUnreachableObjects.Num())
       {
          check(ObjectItem->IsUnreachable());
          {
             UObject* Object = static_cast<UObject*>(ObjectItem->Object);//此处是获取FUObjectItem指向的UObject对象
             // Object should always have had BeginDestroy called on it and never already be destroyed
             check( Object->HasAnyFlags( RF_BeginDestroyed ) && !Object->HasAnyFlags( RF_FinishDestroyed ) );

             // Only proceed with destroying the object if the asynchronous cleanup started by BeginDestroy has finished.
             if(Object->IsReadyForFinishDestroy()) //不断检查渲染和物理线程是否移除了这个对象引用
             {
                // Send FinishDestroy message.
                Object->ConditionalFinishDestroy(); 
             }
             else
             {
                // The object isn't ready for FinishDestroy to be called yet.  This is common in the
                // case of a graphics resource that is waiting for the render thread "release fence"
                // to complete.  Just calling IsReadyForFinishDestroy may begin the process of releasing
                // a resource, so we don't want to block iteration while waiting on the render thread.
                //此处的意思是如果IsReadyForFinishDestroy返回false,大概率是显存还在用这个模型的顶点数据
                //需要等待渲染线程释放一个Fence围栏逻辑锁
                // Add the object index to our list of objects to revisit after we process everything else
                GGCObjectsPendingDestruction.Add(Object);
                GGCObjectsPendingDestructionCount++; //暂时无法处理的加入挂起pending物体
             }
          }

          // We've processed the object so increment our global iterator.  It's important to do this before
          // we test for the time limit so that we don't process the same object again next tick!
          ++GObjCurrentPurgeObjectIndex;
          
        //省略一堆处理超时和挂起的待回收对象代码
          
          // Have all objects been destroyed now?
          if( GGCObjectsPendingDestructionCount == 0 )
          {
             // Destroy has been routed to all objects so it's safe to delete objects now.
             GObjFinishDestroyHasBeenRoutedToAllObjects = true;
             GObjCurrentPurgeObjectIndexNeedsReset = true;
          }
       }
    }     
    return bCompleted;
}

桶式分配器

既然回收了内存,那么会不会出现内存碎片问题?需不需要重新移动和整理内存?

答案是UE 不会进行物理意义上的内存碎片整理(即移动对象地址),而是使用桶式分配器

原因是虚幻是原生 C++。如果你移动了 UObject 的内存地址,那么所有指向它的原生 C++ 指针全都会失效,导致直接崩溃。这和 Java/C# 这种可以通过虚拟机重写所有指针引用的环境完全不同。

FMallocBinned(桶式分配器):它预先在大块内存里划好了“桶”。16 字节的对象永远在 16 字节的区域分配。

这种方式虽然不能完全消除碎片,但能确保碎片被“局部化”,不会出现那种“大对象插在小对象中间导致无法分配大块内存”的情况。

参考链接:

宝宝都能看懂UE的GC(垃圾回收)的原理_ue gc-CSDN博客

深入研究虚幻4反射系统实现原理(一)

Unreal Property System (Reflection)

深入浅出垃圾回收(二)Mark-Sweep 详析及其优化

UObjects & garbage collection -youtube