相比原生的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 系统解析并生成代码。虚幻引擎中的代码编译分两个阶段进行:
虚幻编译工具(Unreal Build Tool (UBT)) 会调用UHT,后者将解析C++头文件,获取与虚幻引擎相关的类元数据,并生成自定义代码,以实现各种与UObject相关的功能。
UBT会调用配置的C++编译器来编译结果。
人话:主要的作用是生成.generated.h和 .gen.cpp,以提供访问元数据的方法,这些元数据可以提供给蓝图,GC系统、序列化、网络复制等
在cpp正式编译之前,需要先进行一遍UHT扫描,主要作用流程:
扫描宏: UHT 扫描头文件,寻找
UCLASS、UPROPERTY、UFUNCTION等宏。生成影子代码: 看到
GENERATED_BODY()后,UHT 会生成对应的.generated.h和.gen.cpp文件。创建“元数据”: 即使程序还没运行,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中,是一个“仅用于构造反射元数据的私有结构体”,在模块加载 / 程序启动时被调用。
实际调用链
UHT开始扫描,生成
.generated.h和.gen.cpp,提供了获取反射数据的方法编译器编译(包括源文件和新生成的两个文件)
开始运行(注意,从这里开始时运行时,即真正的元数据是运行时生成的,但是在编译之前就被定义的)
触发
Z_Construct_UClass_AMyActor()、Z_Construct_UClass_AMyActor_Statics等等一系列构造函数,创建出了UCLASS对象(这个对象是“描述类的类”,静态信息,在代码里调用的AMyActor::StaticClass()返回的就是这个唯一的UClass对象),提供以下反射信息父类信息
依赖模块
属性数组
函数数组
MetaData(Category / Blueprint / EditAnywhere)
TokenStream (下面会讲到,用于GC的
注册进反射系统
提一下:
在第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:
引擎核心对象(
UGameEngine、UGameInstance)。已经加载到关卡中的 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
};具体流程
综上,扫描阶段的流程是:
启动GC锁,进入STW状态
重置标志:遍历
GUObjectArray,将所有非 RootSet 对象的可达性标记清除从根集合开始遍历
根据
TokenStream提供的信息,依次递归地寻找每一个可达的对象如果对象可达,根据对象本身记录的
InternalIndex,找到对应的FUObjectItem,并标记为可达继续递归遍历直到没找到
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
析构过程
在增量式清扫阶段,会依次调用:
ConditionalBeginDestroy():对象被标记为“准备销毁”。它会通知渲染线程等异步线程:“我要走了,别再引用我了。”IsReadyForFinishDestroy():引擎会反复询问这个函数。直到渲染线程和物理线程都回信说“处理完了”,才会进入下一步。FinishDestroy():这是 UObject 释放内部资源的最后机会(比如手动释放申请的非 U 内存)。调用 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博客
参与讨论
(Participate in the discussion)
参与讨论