虚幻4垃圾回收剖析

虚幻4垃圾回收剖析

前言

这是原来写过的一篇文章,看到知乎的读者比较多,故搬运到此处。

我们已经对虚幻4中的反射实现原理进行了一个简单得讲解,反射的用途非常多,其中一个就是用来做垃圾回收用的,我们这个系列就对虚幻4中的垃圾回收机制做一个讲解。注:本系列文章对应的虚幻4版本是4.14.1

垃圾回收

在计算机科学中,垃圾回收(garbage collection, 缩写GC)是一种自动的内存管理机制。当一个电脑上的动态内存不需要时,就应该予以释放,这种自动内存的资源管理,称为垃圾回收。垃圾回收可以减少程序员的负担,也能减少程序员犯错的机会。最早起源于LISP语言。目前许多语言如Smalltalk、Java、C#、python和D语言等都支持垃圾回收。

下面我们简单的介绍下垃圾回收常见的分类以及实现算法,我们并不会特别细致的去讲,如果读者有兴趣可以自行查找相关的书籍和文献。推荐大家看下参考文献中2的文章。

算法分类

引用计数GC和追踪式GC

引用计数式GC通过额外的计数来实时计算对单个对象的引用次数,当引用次数为0时回收对象。引用计数的GC是实时的。像微软COM对象的加减引用值以及C++中的智能指针都是通过引用计数来实现GC的。

追踪式GC算法在达到GC条件时(强制GC或者内存不够用、到达GC间隔时间)通过扫描系统中是否有对象的引用来判断对象是否存活,然后回收无用对象。

保守式GC和精确式GC

精确式GC是指在回收过程中能准确得识别和回收每一个无用对象的GC方式,为了准确识别每一个对象的引用,通过需要一些额外的数据(比如虚幻中的属性UProperty)。

保守式GC并不能准备识别每一个无用的对象(比如在32位程序中的一个4字节的值,它是不能判断出它是一个对象指针或者是一个数字的),但是能保证在不会错误的回收存活的对象的情况下回收一部分无用对象。保守式GC不需要额外的数据来支持查找对象的引用,它将所有的内存数据假定为指针,通过一些条件来判定这个指针是否是一个合法的对象。

搬迁式和非搬迁式

搬迁式GC在GC过程中需要移动对象在内存中的位置,当然移动对象位置后需要将所有引用到这个对象的地方更新到新位置(有的通过句柄来实现、而有的可能需要修改所有引用内存的指针)。

非搬迁式GC跟搬迁式GC正好相关,在GC过程中不需要移动对象的内存位置。

实时和非实现GC

实时GC是指不需要停止用户执行的GC方式。而非实时GC则需要停止用户程序的执行(stop the world)。

渐进式和非渐进式GC

和实时GC一样不需要中断用户程序运行,不同的地方在于渐进式GC不会在对象抛弃时立即回收占用的内存资源,而在GC达成一定条件时进行回收操作。

回收算法

引用计数式

引用计数算法是即时的,渐近的,对于交互式和实时系统比较友好,因为它几乎不会对用户程序造成明显的停顿

优点:

  • 引用计数方法是渐进式的,它能及时释放无用的对象,将内存管理的的开销实时的分布在用户程序运行过程中。

缺点:

  • 引用计数方法要求修改一个对象引用时必须调整旧对象的引用计数和新对象的引用计数,这些操作增加了指针复制的成本,在总体开销上而言通常比追踪式GC要大。
  • 引用计数要求额外的空间来保存计数值,这通常要求框架和编译器支持。
  • 实际应用中很多对象的生命周期很短,频繁的分配和释放导致内存碎片化严重。内存碎片意味着可用内存在总数量上足够但由于不连续因而实际上不可用,同时增加内存分配的时间。
  • 引用计数算法最严重的问题是环形引用问题(当然可以通过弱指针来解决)。

追踪式GC

追踪式GC算法通过递归的检查对象的可达性来判断对象是否存活,进而回收无用内存。

追踪式的GC算法的关键在于准确并快速的找到所有可达对象,不可达的对象对于用户程序来说是不可见的,因此清扫阶段通常可以和用户程序并行执行。下面主要讨论了算法的标记阶段的实现。

标记清扫(Mark-Sweep)

标记清扫式GC算法是后面介绍的追踪式GC算法的基础,它通过搜索整个系统中对对象的引用来检查对象的可达性,以确定对象是否需要回收。

分类:追踪式,非实时,保守(非搬迁式)或者精确式(搬迁式) ,非渐进

优点:

  • 相对于引用计数算法,完全不必考虑环形引用问题。
  • 操纵指针时没有额外的开销。
  • 与用户程序完全分离。

缺点:

  • 标记清扫算法是非实时的,它要求在垃圾收集器运行时暂停用户程序运行,这对于实时和交互式系统的影响非常大。
  • 基本的标记清扫算法通常在回收内存时会同时合并相邻空闲内存块,然而在系统运行一段时间后仍然难免会生成大量内存碎片,内存碎片意味着可用内存的总数量上足够但实际上不可用,同时还会增加分配内存的时间,降低内存访问的效率。
  • 保守式的标记清扫算法可能会将某些无用对象当做存活对象,导致内存泄露。

用户程序初始化时向系统预申请一块内存,新的对象申请在此区域内分配, 用户程序不需要主动释放己分配的空间,当达到回收条件,或者用户程序主动请求时开始收集内存。

标记清扫式GC算法(mark-sweep)分为两个阶段: 标记阶段 和 清扫阶段。

标记阶段

从根结点集合开始递归的标记所有可达对象。

根结点集合通常包括所有的全局变量,静态变量以及栈区(注2)。这些数据可以被用户程序直接或者间接引用到。

标记前:



标记后:



清扫阶段

遍历所有对象,将没有标记为可达的对象回收,并清理标记位。

保守式的标记清扫算法:

保守式的标记清扫算法缺少对象引用的内存信息(事实上它本身就为了这些Uncooperative Environment设计的),它假定所有根结点集合为指针,递归的将这些指针指向的内存堆区标记为可达,并将所有可达区域的内存数据假定为指针,重复上一步,最终识别出不可达的内存区域,并将这些区域回收。

保守式的GC算法可能导致内存泄漏。由于保守式GC算法没有必需的GC信息,因此必须假设所有内存数据是一个指针,这很可能将一个非指针数据当作指针,比如将一个整型值当作一个指针,并且这个值碰巧在已经分配的堆区域地址范围内,这将会导致这部分内存被标记为可达,进而不能被回收。

保守式的GC不能确定一个内存上数据是否是一个指针,因此不能移动对象的位置。

实际应用:

保守式标记清扫GC算法: Boehm-Demers-Weiser 算法

精确式标记清扫算法:UE3, UE4等

由于我们并不是主要介绍GC算法的,所以接下来我们不打算对其它的GC算法进行细讲,读者可以参考参考文献中第2篇文章或者看垃圾回收的算法与实现这本书,内容比较全面。

标记缩并

有些情况下内存管理的性能瓶颈在分配阶段,内存碎片增加了查找可用内存区域的开销,标记缩并算法就是为了处理内存碎片问题而产生的。

分类:追踪式,非实时,精确式,搬迁式,非渐进

节点复制

节点复制GC通过将所有存活对象从一个区移动到另一个区来过滤非存活对象。

分类:追踪式,非实时,精确式,搬迁式,非渐进

分代式GC(Generational Garbage Collection)

在程序运行过程中,许多对象的生命周期是短暂的,分配不久即被抛弃。因此将内存回收的工作焦点集中在这些最有可能是垃圾的对象上,可以提高内存回收的效率,降低回收过程的开销,进而减少对用户程序的中断。

分代式GC每次回收时通过扫描整个堆中的一部分而是不是全部来降低内存回收过程的开销。

分类:追踪式,非实时,精确式,搬迁式,非渐进

实际应用:Java, Ruby

渐进式GC

渐进式GC要解决的问题是如何缩短自动内存管理引起的中断,以降低对实时系统的影响。

渐进式GC算法基于分代式GC算法,它的核心在于在用户程序运行过程中维护年轻分代的根结点集合。

分类:追踪式,非实时,精确式,搬迁式,渐进式

实际应用:Java, Ruby

虚幻4 中的GC

通过上面我们简单的对GC分类和算法的讲解,再结合虚幻4 的代码,我们可以确定它的GC是追踪式、非实时、精确式,非渐近、增量回收(时间片)。下面我们就从它的UML图、执行流程以及部分代码讲起。

虚幻4中GC的入口是CollectGarbage(),让我们来看一下它的函数原型以及定义

/**
* Deletes all unreferenced objects, keeping objects that have any of the passed in KeepFlags set. Will wait for other threads to unlock GC.
*
* @param    KeepFlags            objects with those flags will be kept regardless of being referenced or not
* @param    bPerformFullPurge    if true, perform a full purge after the mark pass
*/
COREUOBJECT_API void CollectGarbage(EObjectFlags KeepFlags, bool bPerformFullPurge = true);

void CollectGarbage(EObjectFlags KeepFlags, bool bPerformFullPurge)
{
    // No other thread may be performing UOBject operations while we're running
    GGarbageCollectionGuardCritical.GCLock();
    // Perform actual garbage collection
    CollectGarbageInternal(KeepFlags, bPerformFullPurge);
    // Other threads are free to use UObjects
    GGarbageCollectionGuardCritical.GCUnlock();
}

从这段代码中我们可以得到如下信息,它是增量式的(bPerformFullPurge),非实时的(gc 锁),最后调用了CollectGarbageInternal来执行真正的垃圾回收操作。

CollectGarbageInternal流程

通过看下面的流程图,我们便可以知道虚幻4垃圾回收就是像我们上面所说的那样,分为标记删除阶段,只不过它多了一个簇(cluster)和增量回收的,而增量回收是为了避免垃圾回收时导致的卡顿的,提出簇的概念是为了提高回收的效率的。




标记阶段(Mark)

虚幻4 中垃圾标记阶段是通过FRealtimeGC类中的PerformReachabilityAnalysis()函数来完成标记的。

/**
 * Performs reachability analysis.
 *
 * @param KeepFlags        Objects with these flags will be kept regardless of being referenced or not
*/

void PerformReachabilityAnalysis(EObjectFlags KeepFlags, bool bForceSingleThreaded = false)
{        
    /** Growing array of objects that require serialization */
    TArray<UObject*>& ObjectsToSerialize = *FGCArrayPool::Get().GetArrayFromPool();

    // Reset object count.
    GObjectCountDuringLastMarkPhase = 0;


    // Presize array and add a bit of extra slack for prefetching.
    ObjectsToSerialize.Reset( GUObjectArray.GetObjectArrayNumMinusPermanent() + 3 );
    // Make sure GC referencer object is checked for references to other objects even if it resides in permanent object pool
    if (FPlatformProperties::RequiresCookedData() && FGCObject::GGCObjectReferencer && GUObjectArray.IsDisregardForGC(FGCObject::GGCObjectReferencer))
    {
        ObjectsToSerialize.Add(FGCObject::GGCObjectReferencer);
    }

    MarkObjectsAsUnreachable(ObjectsToSerialize, KeepFlags);

    {
        FGCReferenceProcessor ReferenceProcessor;
        TFastReferenceCollector<FGCReferenceProcessor, FGCCollector, FGCArrayPool> ReferenceCollector(ReferenceProcessor, FGCArrayPool::Get());
        ReferenceCollector.CollectReferences(ObjectsToSerialize, bForceSingleThreaded);
    }

    FGCArrayPool::Get().ReturnToPool(&ObjectsToSerialize);
}

我们可以看到,它首先会调用MarkObjectsAsUnreachable()来把所有不带KeepFlags标记和EinternalObjectFlags::GarbageCollectionKeepFlags标记的对象全部标记为不可达,并把它们添加到ObjectsToSerialize中去。这个函数会判断当前的FUObjectItem::GetOwnerIdnex()是否为0,如果为0那么它就是一个普通物体也就意味着不在簇(cluster)中。把所有符合条件的对象标记为不可达后,然后会调用下面的代码来进行可达性标记。

{
    FGCReferenceProcessor ReferenceProcessor;
    TFastReferenceCollector<FGCReferenceProcessor, FGCCollector, FGCArrayPool> ReferenceCollector(ReferenceProcessor, FGCArrayPool::Get());
    ReferenceCollector.CollectReferences(ObjectsToSerialize, bForceSingleThreaded);
}

下面我们来详细讲解标记的过程,这里会牵扯到我们前面提到的UProperty和UClass也就是我们需要利用反射信息来进行可达性的标记。看上面的调用,有几个比较重要的对象TFastReferenceCollector、FGCReferenceProcessor、以及FGCCollector,下面我们来分别介绍下下面几个类。

TFastReferenceCollector

CollectReferences用于可达性分析,如果是单线程的话就调用ProcessObjectArray()遍历UObject的记号流(token stream )来查找存在的引用,否则会创建几个FCollectorTask来处理,最终调用的还是ProcessObjectArray()函数来处理。下面我们来仔细来讲解一下这个函数。

它会遍历InObjectsToSerializeArray中的UObject对象,然后根据这个类的UClass拿到它的FGCReferenceTokenStream,如果是单线程且bAutoGenerateTokenSteram为true,且没有产生token stream,那么会调用AssembleReferenceTokenStream()来生成,代码如下所示:

// Make sure that token stream has been assembled at this point as the below code relies on it.
if (bAutoGenerateTokenStream && !ReferenceProcessor.IsRunningMultithreaded())
{
    UClass* ObjectClass = CurrentObject->GetClass();
    if (!ObjectClass->HasAnyClassFlags(CLASS_TokenStreamAssembled))
    {
        ObjectClass->AssembleReferenceTokenStream();
    }
}
 

然后它会根据当前的ReferenceTokenSteramIndex来获取FGCReferenceInfo,然后根据它的类型来做相应的操作,代码如下所示:

TokenStreamIndex++;
FGCReferenceInfo ReferenceInfo = TokenStream->AccessReferenceInfo(ReferenceTokenStreamIndex);
if (ReferenceInfo.Type == GCRT_Object)
{
    // We're dealing with an object reference.
    UObject**    ObjectPtr = (UObject**)(StackEntryData + ReferenceInfo.Offset);
    UObject*&    Object = *ObjectPtr;
    TokenReturnCount = ReferenceInfo.ReturnCount;
    ReferenceProcessor.HandleTokenStreamObjectReference(NewObjectsToSerialize, CurrentObject, Object, ReferenceTokenStreamIndex, true);
}
else if (ReferenceInfo.Type == GCRT_ArrayObject)
{
    // We're dealing with an array of object references.
    TArray<UObject*>& ObjectArray = *((TArray<UObject*>*)(StackEntryData + ReferenceInfo.Offset));
    TokenReturnCount = ReferenceInfo.ReturnCount;
    for (int32 ObjectIndex = 0, ObjectNum = ObjectArray.Num(); ObjectIndex < ObjectNum; ++ObjectIndex)
    {
        ReferenceProcessor.HandleTokenStreamObjectReference(NewObjectsToSerialize, CurrentObject, ObjectArray[ObjectIndex], ReferenceTokenStreamIndex, true);
    }
}
...

在这个处理的过程中,如果新加入的对象数据大于一定数量(MinDesiredObjectsPerSubTask)且是多线程处理,那么就会按需创建一些新的TGraphTask<FCollectorTask>来并行处理引用问题,那么这就是一个递归的过程了。具体的代码读者可以自行去阅读,这里就不展开去讲了。

还记得我们前面一起提到的就是,反射信息用于GC吗?UClass::AssembleReferenceTokenStream()函数就是用生成记号流(token steam,其实就是记录了什么地方有UObject引用),它有一个CLASS_TokenStreamAssembled来保存只需要初始化一次。

这里我们只留一部分的代码,读者可以自行查看AssembleReferenceTokenStream()的定义:

void UClass::AssembleReferenceTokenStream(bool bForce)
{
    if (!HasAnyClassFlags(CLASS_TokenStreamAssembled) || bForce)
    {
        if (bForce)
        {
            ReferenceTokenStream.Empty();
            ClassFlags &= ~CLASS_TokenStreamAssembled;
        }
        TArray<const UStructProperty*> EncounteredStructProps;
        // Iterate over properties defined in this class
        for( TFieldIterator<UProperty> It(this,EFieldIteratorFlags::ExcludeSuper); It; ++It)
        {
            UProperty* Property = *It;
            Property->EmitReferenceInfo(*this, 0, EncounteredStructProps);
        }
        if (GetSuperClass())
        {
            GetSuperClass()->AssembleReferenceTokenStream();
            if (!GetSuperClass()->ReferenceTokenStream.IsEmpty())
                PrependStreamWithSuperClass(*GetSuperClass());
        }
        else
            UObjectBase::EmitBaseReferences(this);
        static const FName EOSDebugName("EOS");
        EmitObjectReference(0, EOSDebugName, GCRT_EndOfStream);
        ReferenceTokenStream.Shrink();
        ClassFlags |= CLASS_TokenStreamAssembled;
    }
}

注意,我这里省去了一些代码,其它的大致的逻辑就是如果没有创建token stream或者要强制创建(需要清空ReferenceTokenSteam),那么就会遍历自身的所有属性,然后对每个UProperty调用EmitReferenceInfo()函数,它是一个虚函数,不同的UProperty会实现它,如果它有父类(GetSuperClass()),那么就会调用父类的AssembleReferenceTokenStream()并把父类的添加到数组的前面,同时会处理GCRT_EndofStream的特殊情况,最后加上GCRT_EndOfStream到记号流里面去,并设置CLASS_TokenStreamAssembled标记。

下面我们来看一个UObjectProperty::EmitReferenceInfo的实现,其它的UArrayProperty、UStructProperty等读者可自行查看。

/**
* Emits tokens used by realtime garbage collection code to passed in OwnerClass' ReferenceTokenStream. The offset emitted is relative
* to the passed in BaseOffset which is used by e.g. arrays of structs.
*/
void UObjectProperty::EmitReferenceInfo(UClass& OwnerClass, int32 BaseOffset, TArray<const UStructProperty*>& EncounteredStructProps)
{
    FGCReferenceFixedArrayTokenHelper FixedArrayHelper(OwnerClass, BaseOffset + GetOffset_ForGC(), ArrayDim, sizeof(UObject*), *this);
    OwnerClass.EmitObjectReference(BaseOffset + GetOffset_ForGC(), GetFName(), GCRT_Object);
}

FGCReferenceProcessor

处理由TFastReferenceCollector查找到的UObject引用。



上面的流程图中提到了簇的概念,那么它是用来干什么的呢,我们上面说过它是为了提高GC性能的。我们接下来就来看下簇的概念。

下面我们来看一下UObject的继承关系,其中跟Cluster相关的几个函数在UObjectBaseUtility中,如下图所示:


可以看到,它们都是虚函数,可以被重载,目前来看可以作为簇根(CanBeClusterRoot)的只有UMaterial和UParticleSystem这两个类,而基本上所有的类都可以在簇中(CanBeInCluster),而创建簇是通过CreateCluster来完成的,当然创建簇需要一定的条件,比如我们的CreateClusterFromPackage的函数定义为:

/** Looks through objects loaded with a package and creates clusters from them */
void CreateClustersFromPackage(FLinkerLoad* PackageLinker)
{    
    if (FPlatformProperties::RequiresCookedData() && !GIsInitialLoad && GCreateGCClusters && !GUObjectArray.IsOpenForDisregardForGC())
    {
        check(PackageLinker);

        for (FObjectExport& Export : PackageLinker->ExportMap)
        {
            if (Export.Object && Export.Object->CanBeClusterRoot())
            {
                Export.Object->CreateCluster();
            }
        }
    }
}

FPlatformProperties::RequiresCookedData()代表需要cook好的数据,所以编辑器模式下不会使用簇来GC。

接下来我们看一下CreateCluster()函数的定义:

void UObjectBaseUtility::CreateCluster()
{
    FUObjectItem* RootItem = GUObjectArray.IndexToObject(InternalIndex);
    if (RootItem->GetOwnerIndex() != 0 || RootItem->HasAnyFlags(EInternalObjectFlags::ClusterRoot))
    {
        return;
    }
    // If we haven't finished loading, we can't be sure we know all the references
    check(!HasAnyFlags(RF_NeedLoad | RF_NeedPostLoad));
    // Create a new cluster, reserve an arbitrary amount of memory for it.
    FUObjectCluster* Cluster = new FUObjectCluster;
    Cluster->Objects.Reserve(64);
 
    // Collect all objects referenced by cluster root and by all objects it's referencing
    FClusterReferenceProcessor Processor(InternalIndex, *Cluster);
    TFastReferenceCollector<FClusterReferenceProcessor, TClusterCollector<FClusterReferenceProcessor>, FClusterArrayPool, true> ReferenceCollector(Processor, FClusterArrayPool::Get());
    TArray<UObject*> ObjectsToProcess;
    ObjectsToProcess.Add(static_cast<UObject*>(this));
    ReferenceCollector.CollectReferences(ObjectsToProcess, true);
    if (Cluster->Objects.Num())
    {
        // Add new cluster to the global cluster map.
        GUObjectClusters.Add(InternalIndex, Cluster);
        check(RootItem->GetOwnerIndex() == 0);
        RootItem->SetFlags(EInternalObjectFlags::ClusterRoot);
    }
    else
    {
        delete Cluster;
    }
}

可以看到它也使用了TFastReferenceCollector,只不过这次的模板参数为FClusterReferenceProssor和TCusterCollector,这里我们就不展开去讲了,读者可以自行阅读代码。

FGCCollector

这个类如下图所示是继承自FReferenceCollector,HandleObjectReference()和HandleObjectReferences()都调用了FGCReferenceProcessor的HandleObjectReference()方法来进行UObject的可达性分析。

FGCCollector的UML继承关系图如下所示:



清扫阶段(Sweep)

前面,我们经过了标记过程,那些标记了不可达标记的物体可以进行删除了,为了减少卡顿,虚幻4加入了增量清除的概念(IncrementalPurgeGarbage()函数),就是一次删除只占用固定的时间片,当然,如果是编译器状态或者是强制完全清除(比如下一次GC了,但是上一次增量清除还没有完成,那么就会强制清除)。

IncrementalPurgeGarbage()函数的大体流程如下图所示:



还记得我们前面说的虚幻4使用簇来提高GC的效率吗,下面是CollectGargageInternal函数中的一段,这个时候已经完成了可达性分析,代码如下所示:

for (FRawObjectIterator It(true); It; ++It)
{
    FUObjectItem* ObjectItem = *It;
    if (ObjectItem->IsUnreachable())
    {
        if ((ObjectItem->GetFlags() & EInternalObjectFlags::ClusterRoot) == EInternalObjectFlags::ClusterRoot)// Nuke the entire cluster
        {
            ObjectItem->ClearFlags(EInternalObjectFlags::ClusterRoot | EInternalObjectFlags::NoStrongReference);
            const int32 ClusterRootIndex = It.GetIndex();
            FUObjectCluster* Cluster = GUObjectClusters.FindChecked(ClusterRootIndex);
            for (int32 ClusterObjectIndex : Cluster->Objects)
            {
                FUObjectItem* ClusterObjectItem = GUObjectArray.IndexToObjectUnsafeForGC(ClusterObjectIndex);
                ClusterObjectItem->ClearFlags(EInternalObjectFlags::NoStrongReference);
                ClusterObjectItem->SetOwnerIndex(0);
                if (!ClusterObjectItem->HasAnyFlags(EInternalObjectFlags::ReachableInCluster))
                {
                    ClusterObjectItem->SetFlags(EInternalObjectFlags::Unreachable);
                    if (ClusterObjectIndex < ClusterRootIndex)
                    {
                        UObject* ClusterObject = (UObject*)ClusterObjectItem->Object;
                        ClusterObject->ConditionalBeginDestroy();
                    }
                }
            }
            delete Cluster;
            GUObjectClusters.Remove(ClusterRootIndex);
        }
    }
}

可以看到,如果UObject带有EInternalObjectFlags::ClusterRoot且不可达,那么它会直接把上面的UObject(Cluster->Objects)符合条件的进行销毁,并且把当前簇删除掉。

总结

到此为止,我们对虚幻4中的垃圾回收进行了大概的讲解,知道了它的GC是追踪式、非实时、精确式,非渐近、增量回收(时间片),先标记后回收的过程,为了提高效率和减少回收过程中的卡顿,可以做到并行标记和增量回收以及通过簇来提高回收的效率等。这篇文章只能给你一个大概的了解,如果想要清楚其中的细节,看代码是免不了的。另外中间有错误的地方,如果读者发现也请指正。欢迎大家留言讨论。

参考文献

  1. zh.wikipedia.org/wiki/%
  2. cnblogs.com/superjt/p/5
发布于 2019-01-05 16:01