0%

UEGC详解

UE标记清除GC算法特点

UEGC分析

UE标记清除GC算法特点

UE4采用“追踪式、精确式、非搬迁式、非实时、非渐进式”的标记清扫(Mark-Sweep)GC算法。该算法分为两个阶段:标记阶段(GC Mark)和清扫阶段(GC Sweep)

  • 跟踪式:达到GC条件时,通过扫描系统中是否有对象引用的方式来判断是否回收

    • 引用计数:额外分配空间用于存储引用计数,引用为0时回收对象本身
  • 精确式:需要额外的数据来判断,以识别每个对象的引用(比如标识符UPROPERTY)

    • 保守式:不需要额外的数据来支持对象的判断
  • 非搬迁式:不需要移动GC对象的内存位置

    • 搬迁式:例如标记复制法,将成功标记的对象复制到另一个堆中,剩下的对象就可以被GC了
  • 非实时:需要停止用户程序的执行

    • 实时:不需要停止用户执行
  • 非渐进式:对象被GC后立即回收占用的内存资源

    • 渐进式:在GC达成一定条件的时候进行回收操作

 

被UPROPERTY宏修饰,或者在AddReferencedObjects手动添加引用的UObject*成员变量,才能被GC识别。

首先说一下UE中GC的大致流程:

  • 启动GC时加锁,防止GC时引用关系发生变化
  • 将所有对象设置为“不可达”(特殊对象、根对象除外)
  • 从根对象开始遍历,引用到的对象去除“不可达”标记
  • 将所有“不可达”删除

 

基础操作

使用:在大多数情况下,继承自UObject类的变量,前面加一个UPROPERTY宏,那么这个变量自然就被纳入UE的垃圾回收系统之中了。

如果变量继承自非UObject类的对象变量,那么这个这个承载这个变量需要继承自FGCObject类,并且该类需要重写实现FGCObject提供的AddReferencedObjects接口,在接口中调用AddReferencedObject方法手动添加该对象至Collector实现。

也就是非UObject引用了UObject类对象

//.h
class FTestClass : public FGCObject
{
public:

FTestClass();

class UTestObject* MyObject;

virtual void AddReferencedObjects(FReferenceCollector& Collector) override;

};

//.cpp
FTestClass::FTestClass()
{
MyObject = NewObject<UTestObject>(GetTransientPackage(), TEXT("TestGCPlgTestObject"));
}

void FTestClass::AddReferencedObjects(FReferenceCollector& Collector)
{
Collector.AddReferencedObject(MyObject);
}

至于其他类型的对象,则使用智能指针或者其他方法进行GC管理

Q:如果一个对象在拥有UPROPERTY标记的同时又被AddReferenceObjects手动添加进GC系统会发生什么?

 

其他操作方法

AddToRoot()将UObject对象添加到根节点Root上,这样就不会被GC回收了,对应的方法为RemoveFromRoot()。是通过将对象对应GUObjectArray中的FUObjectItem的Flags会加上EInternalObjectFlags::RootSet标记实现的。

 

IsValid()这个函数检查对象是否有效,对应C++中的“!=null”操作,但是这个函数本身可以对对象进行更多的检查。

  • 检查UObject指针指向是否为空——为空则返回false
  • 检查对象内部的InternalFlags,如果被标记为待销毁,则返回false
  • 检查对象是否正在被垃圾回收,如果是,则返回false
#include "UObject/UObjectGlobals.h"

bool IsValid(const UObject* Object)
{
if (Object == nullptr)
{
return false;
}

if (Object-&gt;IsPendingKill())
{
    return false;
}

if (Object-&gt;HasAnyFlags(RF_BeginDestroyed) || Object-&gt;HasAnyFlags(RF_FinishDestroyed))
{
    return false;
}

return true;

}

 

MarkPendingKill()手动将对象设置为等待回收的对象,意思就是跳过GC一系列的算法,直接在下一轮将对象回收。是通过将对象对应GUObjectArray中的FUObjectItem的Flags加上EInternalObjectFlags::PendingKill标记来实现的。

 

防止被GC

  • 使用AddToRoot函数
  • 直接或间接被Root对象引用
  • 直接或间接被FGCObject对象引用

 

主动GC

CollectGarbage()在当前帧内进行一次垃圾回收

ForceCollectGarbage()在下一帧内进行一次垃圾回收

 

判断UObject对象有效性

IsValid()——判断指针是否为空,判断是否为PendingKill

 

基础定义

类EInternalObjectFlags

FUObjectItem用于保存UObject信息,存有一个EInernalObjectFlags的枚举变量,这个变量代表着当前对象的情况,比如PendingKill代表对象就要被回收了。

在类型系统中的其他Flags:

  • EObjectFlags:对象本身的标志。
  • EInternalObjectFlags:对象存储的标志,GC的时候用来检查可达性。
  • EObjectMark:用来额外标记对象特征的标志,用在序列化过程中标识状态。
  • EClassFlags:类的标志,定义了一个类的特征。
  • EClassCastFlags:类之间的转换,可以快速的测试一个类是否可以转换成某种类型。
  • EStructFlags:结构的特征标志。
  • EFunctionFlags:函数的特征标志。
  • EPropertyFlags:属性的特征标志。

来自大钊的《InsideUE4》

ReachableInCluster = 1 << 23, ///< 集群中存在对对象的外部引用

ClusterRoot = 1 << 24, ///< 集群的根 Native = 1 << 25, ///< 本地的(仅UClass)。 Async = 1 << 26, ///< 对象只存在于与游戏线程不同的线程上。 AsyncLoading = 1 << 27, ///< 对象正在异步加载。 Unreachable = 1 << 28, ///< 对象在对象图(Graph)上不可达。 PendingKill = 1 << 29, ///< 等待销毁的对象(在GamePlay中无效,但暂时还依然有效的对象) RootSet = 1 << 30, ///< 对象将不会被垃圾回收,即使未引用。

如何看待FUObjectItem?这个结构在某UObject被纳入UE的GC管理后被分配,为一对一的关系,用于存储该对象的一些GC相关的元数据。也并不一定为一对一的关系,比如某些UObject不被纳入此UE的正常GC之中则不会被分配。

由于FUObjectItem本身只是结构体,而且在GUObjectArray中排列紧密,所以在GC的扫描中能很方便得直接进行For循环遍历,而且此For循环也可以使用多线程版本的For循环。

//UObjectArray.h
struct FUObjectItem
{
   class UObjectBase* Object; //对象,使用newObject时返回的对象指针
   int32 Flags;               //EInternalObjectFlags标识
   int32 ClusterRootIndex;    //当前所属簇索引
   int32 SerialNumber;        //对象序列码(WeakObjectPtr实现用到它)
}

 

GC流程

首先从CollectGarbage()开始,这个函数做了三件事:

  • 获取GC锁
  • 执行CollectGarbageInternal
  • 释放GC锁

 

UE引擎中的GC是多线程的,所以要设置GC锁。比如防止一个对象加载后,其引用还没被添加,就被当成垃圾被GC掉,所以这个锁的目的是防止其他线程对这个UObject进行相关操作。

重点是CollectGarbageInternal,这个函数之内执行标记和清扫操作。

标记

PerformReachabilityAnalysis()方法:

  • 在ObjectsToSerialize中添加FGCObject::GGCObjectReferencer,用于在非UObject对象上调用AddReferencedObjects方法。
  • 调用MarkObjectsAsUnreachable,将不带KeepFlags标记的对象标记为不可达。

GUObjectArray,这个全局的变量保存了所有的UObject(由FUObjectItem封装)。可以通过下标来找到UObject,此下标就存储在UObjectBase::InternalIndex属性之中。

此外,这个UObject列表的前部有一些不被纳入GC的obj,会在gc扫描时特地排除。

  • PerformReachabilityAnalysisOnObjects判断可达。

ReferenceToken是一组token流,用于描述类中对象的引用情况,使用它的原因是代替UProperty来扫描对象引用关系,达到更高效率。

每一个UObject都有一个对应的UClass来保存这个对象中每一个属性的相关信息,可以通过这个对象的实例化地址来遍历所有的属性,这样也能得到所有的引用对象。但是时则以的效率太慢,因为大部分情况下对象的属性都不是UObject类型。

token流的意思就是将一系列数据转化为uint32值,这个值的实际意义是这个对象引用的其他对象的偏移位置,使之更方便传输与缓存。

token在UE中由FGCReferenceInfo这个类来描述,具体如下:

/** Mapping to exactly one uint32 */
union
{
 /** Mapping to exactly one uint32 */
 struct
 {
     /** Return depth, e.g. 1 for last entry in an array, 2 for last entry in an array of structs of arrays, ... */
     uint32 ReturnCount	: 8;
     /** Type of reference */
     uint32 Type			: 4;
     /** Offset into struct/ object */
     uint32 Offset		: 20;
 };
 /** uint32 value of reference info, used for easy conversion to/ from uint32 for token array */
 uint32 Value;
};
  • ReturnCount——嵌套深度
  • Type——引用的类型(EGCRefenceType)
  • Offset——引用对应属性在类中的地址偏移

可以通过解析token流来得到所引用的属性,然后这个属性其实就是这个类的成员变量,换句话说也就是“这个对象所引用的对象”了;其次,两个token(32位)还可以用于存储指针(64位),比如使用AddReferencedObjects添加对象时,它的函数指针。

ProcessObjectArray就是实质性的遍历Uobject的token流并寻找引用关系的方法,在多线程的情况下,UObject的列表被分配给各个线程,并调用ProcessObjectArray。此方法中遍历的就是ObjectsToSerialize中的UObject来寻找引用关系并判断可达性的。

所有一整个标记的流程大概为:UObject的反射信息UClass中含有一个token流属性,这个属性为一个用int值保存属性信息的共同体——>CollectGarbage()中的标记核心函数触发——>PerformReachabilityAnalysis()获取到GUObjectArray中的所有对象,根据标签更改标记,包括继承了FGCObject的非UObject对象,如果对象可达,则放入ObjectsToSerialize——>PerformReachabilityAnalysisOnObjectsInternal()通过解析对象的token流,判断属性是否为UObject类型,如果是,则将引用对象去除不可达标记,并将其加入ObjectsToSerialize中

ObjectsToSerialize这个列表是动态的,因为遍历的过程中肯定会遍历到引用的新对象,于是这个新对象也会被纳入到这个ObjectsToSerialize中,并一轮又一轮得递归遍历。

 

清除

整个标记流程结束后,GObjectArray再次被遍历,所有的不可达对象放入GUnreachableObjects中。

清除的流程分为UnhashUnreachableObjects和IncrementalDestroyGarbage两部分,前者调用所有不可达对象的BeginDestroy,后者调用所有不可达对象的FinishDestroy。

BeginDestroy意思是通知对象即将被销毁,该对象需要做好在其他线程上的清理工作。

FinishDestroy调用之前会判断对象是否做好清理工作,之后会将走到这一流程的所有对象在下一帧调用析构函数,被彻底清理。

 

Cluster

//todo

 

QA

可以看https://zhuanlan.zhihu.com/p/401956734的最后段,我觉得很有用