標記-清除的GC機制要包括以下四個流程。
接下來介紹UE4中的資訊收集過程。
引擎載入流程中收集資訊
由於記憶體資訊,包括型別資訊在編譯期就已經確定,所以在反射支援的情況下,便能夠在初始化的時候進行收集。之前介紹反射時,有講到引擎載入的流程中會呼叫
ProcessNewlyLoadedUObjects()
函式(多次)。
而
ProcessNewlyLoadedUObjects()
函式中會呼叫
UClass::AssembleReferenceTokenStreams()
,後者是一個靜態函式,直接透過類名呼叫,程式碼也很簡短:
void
UClass
::
AssembleReferenceTokenStreams
()
{
SCOPED_BOOT_TIMING
(
“AssembleReferenceTokenStreams (can be optimized)”
);
// Iterate over all class objects and force the default objects to be created。 Additionally also
// assembles the token reference stream at this point。 This is required for class objects that are
// not taken into account for garbage collection but have instances that are。
for
(
FRawObjectIterator
It
(
false
);
It
;
++
It
)
// GetDefaultObject can create a new class, that need to be handled as well, so we cannot use TObjectIterator
{
if
(
UClass
*
Class
=
Cast
<
UClass
>
((
UObject
*
)(
It
->
Object
)))
{
// Force the default object to be created (except when we‘re in the middle of exit purge -
// this may happen if we exited PreInit early because of error)。
//
// Keep from handling script generated classes here, as those systems handle CDO
// instantiation themselves。
if
(
!
GExitPurge
&&
!
Class
->
HasAnyFlags
(
RF_BeingRegenerated
))
{
Class
->
GetDefaultObject
();
// Force the default object to be constructed if it isn’t already
}
// Assemble reference token stream for garbage collection/ RTGC。
if
(
!
Class
->
HasAnyFlags
(
RF_ClassDefaultObject
)
&&
!
Class
->
HasAnyClassFlags
(
CLASS_TokenStreamAssembled
))
{
Class
->
AssembleReferenceTokenStream
();
}
}
}
}
函式的執行流程如下:
遍歷所有的UClass(每一個用
UCLASS()
宏修飾的類都會生成一個UClass物件)
對每個UClass生成一個預設的物件(指被UClass修飾的類的物件)
對每個UClass,如果沒有收集gc的token stream資訊,則呼叫每個UClass物件的
AssembleReferenceTokenStream()
函式(非靜態)
所以繼續進入
AssembleReferenceTokenStream()
函式(非靜態)。
void
UClass
::
AssembleReferenceTokenStream
(
bool
bForce
)
{
// Lock for non-native classes
FScopeLockIfNotNative
ReferenceTokenStreamLock
(
ReferenceTokenStreamCritical
,
!
(
ClassFlags
&
CLASS_Native
));
UE_CLOG
(
!
IsInGameThread
()
&&
!
IsGarbageCollectionLocked
(),
LogGarbage
,
Fatal
,
TEXT
(
“AssembleReferenceTokenStream for %s called on a non-game thread while GC is not locked。”
),
*
GetFullName
());
if
(
!
HasAnyClassFlags
(
CLASS_TokenStreamAssembled
)
||
bForce
)
{
if
(
bForce
)
{
ReferenceTokenStream
。
Empty
();
ClassFlags
&=
~
CLASS_TokenStreamAssembled
;
}
TArray
<
const
FStructProperty
*>
EncounteredStructProps
;
// Iterate over properties defined in this class
for
(
TFieldIterator
<
FProperty
>
It
(
this
,
EFieldIteratorFlags
::
ExcludeSuper
);
It
;
++
It
)
{
FProperty
*
Property
=
*
It
;
Property
->
EmitReferenceInfo
(
*
this
,
0
,
EncounteredStructProps
);
}
if
(
UClass
*
SuperClass
=
GetSuperClass
())
{
// We also need to lock the super class stream in case something (like PostLoad) wants to reconstruct it on GameThread
FScopeLockIfNotNative
SuperClassReferenceTokenStreamLock
(
SuperClass
->
ReferenceTokenStreamCritical
,
!
(
SuperClass
->
ClassFlags
&
CLASS_Native
));
// Make sure super class has valid token stream。
SuperClass
->
AssembleReferenceTokenStream
();
if
(
!
SuperClass
->
ReferenceTokenStream
。
IsEmpty
())
{
// Prepend super‘s stream。 This automatically handles removing the EOS token。
ReferenceTokenStream
。
PrependStream
(
SuperClass
->
ReferenceTokenStream
);
}
}
else
{
UObjectBase
::
EmitBaseReferences
(
this
);
}
{
check
(
ClassAddReferencedObjects
!=
NULL
);
const
bool
bKeepOuter
=
true
;
//GetFName() != NAME_Package;
const
bool
bKeepClass
=
true
;
//!HasAnyInternalFlags(EInternalObjectFlags::Native) || IsA(UDynamicClass::StaticClass());
ClassAddReferencedObjectsType
AddReferencedObjectsFn
=
nullptr
;
#if !WITH_EDITOR
// In no-editor builds UObject::ARO is empty, thus only classes
// which implement their own ARO function need to have the ARO token generated。
if
(
ClassAddReferencedObjects
!=
&
UObject
::
AddReferencedObjects
)
{
AddReferencedObjectsFn
=
ClassAddReferencedObjects
;
}
#else
AddReferencedObjectsFn
=
ClassAddReferencedObjects
;
#endif
ReferenceTokenStream
。
Fixup
(
AddReferencedObjectsFn
,
bKeepOuter
,
bKeepClass
);
}
if
(
ReferenceTokenStream
。
IsEmpty
())
{
return
;
}
// Emit end of stream token。
static
const
FName
EOSDebugName
(
“EndOfStreamToken”
);
EmitObjectReference
(
0
,
EOSDebugName
,
GCRT_EndOfStream
);
// Shrink reference token stream to proper size。
ReferenceTokenStream
。
Shrink
();
check
(
!
HasAnyClassFlags
(
CLASS_TokenStreamAssembled
));
// recursion here is probably bad
ClassFlags
|=
CLASS_TokenStreamAssembled
;
}
}
程式碼有點長,但總結一下,就是為每個被UClass修飾的類,生成對應的token stream,用於描述GC資訊。同時透過
CLASS_TokenStreamAssembled
標記防止重複生成token stream。
較為詳細的流程如下:
首先加上鎖,防止另外一個執行緒同時進入該函式執行一樣的流程,造成記憶體讀寫衝突
遍歷UClass中的每個Property,呼叫每個Property的
EmitReferenceInfo()
方法,會將UClass物件的指標傳入,主要是為了後續再次呼叫UClass的
EmitObjectReference()
方法,將每個Property的記憶體偏移資訊、型別資訊傳回,並存入到每個UClass物件的
ReferenceTokenStream
成員中。需要說明的是,不同型別的Property的
EmitReferenceInfo()
方法都被覆蓋/重寫(override)了,也就是說每個Property類都有自己的
EmitReferenceInfo()
方法。
如果這個類有父類,則會遞迴地呼叫
AssembleReferenceTokenStream()
方法,收集父類的上述資訊到
ReferenceTokenStream
中,同時將父類的 token stream 新增到自己的 token stream 之前;該步驟會一直持續到
UObjectBase
類,因為這個類沒有父類。
最後完成收集會將
ClassFlags
新增上
CLASS_TokenStreamAssembled
標誌,表示該類的token stream資訊已經收集完畢。
也就是說,沒有被
UProperty
宏修飾的成員就不會加入到GC引用鏈中,每次GC都會被清除掉,同時指標置為
nullptr
,當然也有其他方式避免被清除。
看到這裡的時候我腦子裡出現了三個疑問:
如果是一直遞迴收集父類的記憶體偏移資訊直到
UObjectBase
類,那必然有很多重複收集的資訊,很簡單的例子就是類
UObject
作為整個物件系統的基類,不是會被重複收集很多次嗎?
為什麼需要把父類成員的token stream新增到子類之前呢?
在記憶體中,類成員的偏移資訊是如何記錄的(位元組對齊)?
首先第一個問題,每個
類的token stream是不會被重複記錄
的。經過繼承後,各個類之間的關係,有點類似於一棵多叉樹。
而遞迴呼叫
AssembleReferenceTokenStream()
函式(非靜態)的過程,就是從多叉樹的某個葉子結點,向整棵樹的根結點回溯。每到達一個新結點,首先就會檢查是否該結點是否已經被遍歷過了,也就是看每
UClass::ClassFlags & CLASS_TokenStreamAssembled
是不是為1,即遍歷過就會直接返回,而對每個結點/每個
UClass
處理的過程是加鎖的。所以每個結點都不會被重複遍歷,也就是每個類的 token stream 是不會被重複記錄的。
2、3問題與C++物件記憶體模型和位元組對齊有關,以下介紹。
類成員記憶體資訊收集
token stream與記憶體偏移
很好奇token stream是如何將記憶體偏移資訊以及型別資訊編碼到一個int32的。
看了原始碼和相關的知識以後,結果還是比我想象中的要簡單的,也發現 UE4 將
UProperty
替換成了
FProperty
。
FProperty
沒有再從
UObject
繼承,而是繼承自
FField
,
FField
也沒有繼承自
UObject
。
從函式
EmitReferenceInfo()
開始看,首先發現
FProperty::EmitReferenceInfo()
是一個空的函式。
實際上不同型別的的
EmitReferenceInfo()
是各自實現的:
我看了部分型別的實現,發現基本都是將偏移量以及各自成員的名字傳回到UClass,這裡以
FStructProperty::EmitReferenceInfo()
為例。
void
FStructProperty
::
EmitReferenceInfo
(
UClass
&
OwnerClass
,
int32
BaseOffset
,
TArray
<
const
FStructProperty
*>&
EncounteredStructProps
)
{
check
(
Struct
);
if
(
Struct
->
StructFlags
&
STRUCT_AddStructReferencedObjects
)
{
UScriptStruct
::
ICppStructOps
*
CppStructOps
=
Struct
->
GetCppStructOps
();
check
(
CppStructOps
);
// else should not have STRUCT_AddStructReferencedObjects
FGCReferenceFixedArrayTokenHelper
FixedArrayHelper
(
OwnerClass
,
BaseOffset
+
GetOffset_ForGC
(),
ArrayDim
,
ElementSize
,
*
this
);
OwnerClass
。
EmitObjectReference
(
BaseOffset
+
GetOffset_ForGC
(),
GetFName
(),
GCRT_AddStructReferencedObjects
);
void
*
FunctionPtr
=
(
void
*
)
CppStructOps
->
AddStructReferencedObjects
();
OwnerClass
。
ReferenceTokenStream
。
EmitPointer
(
FunctionPtr
);
}
if
(
ContainsObjectReference
(
EncounteredStructProps
,
EPropertyObjectReferenceType
::
Strong
|
EPropertyObjectReferenceType
::
Weak
))
{
FGCReferenceFixedArrayTokenHelper
FixedArrayHelper
(
OwnerClass
,
BaseOffset
+
GetOffset_ForGC
(),
ArrayDim
,
ElementSize
,
*
this
);
FProperty
*
Property
=
Struct
->
PropertyLink
;
while
(
Property
)
{
Property
->
EmitReferenceInfo
(
OwnerClass
,
BaseOffset
+
GetOffset_ForGC
(),
EncounteredStructProps
);
Property
=
Property
->
PropertyLinkNext
;
}
}
}
名字是在反射資訊收集階段的時候,透過生成檔案寫入的。這裡構造了一個
FixedArrayHelper
物件,但是一直沒被用到,構造函數里也會呼叫
UClass::EmitObjectReference()
傳回記憶體資訊,這樣做的意義什麼呢?。然後對
FProperty::ArrayDim
也不是很清楚,我猜測是一個
FProperty
物件可能對應多個成員,所以需要執行多次
UClass::EmitObjectReference()
,或者是處理迴圈引用 。。。
我猜的
。。。
那記憶體偏移量呢? 這裡呼叫了一個函式是
GetOffset_ForGC()
。
實際也只是返回了一個成員變數而已,也就是說偏移量在對應的
FProperty
物件生成的時候就就已經決定好了。應該是在收集反射資訊的時候,就會填充好這個
Offset_Internal
。回顧一下文章UE4中型別生成檔案分析,發現生成檔案中屬性的部分,有個
STRUCT_OFFSET()
的呼叫:
const
UE4CodeGen_Private
::
FUnsizedIntPropertyParams
Z_Construct_UClass_UHH_Statics
::
NewProp_HHID
=
{
“HHID”
,
nullptr
,
(
EPropertyFlags
)
0x0010000000000000
,
UE4CodeGen_Private
::
EPropertyGenFlags
::
Int
,
RF_Public
|
RF_Transient
|
RF_MarkAsNative
,
1
,
STRUCT_OFFSET
(
UHH
,
HHID
),
METADATA_PARAMS
(
Z_Construct_UClass_UHH_Statics
::
NewProp_HHID_MetaData
,
UE_ARRAY_COUNT
(
Z_Construct_UClass_UHH_Statics
::
NewProp_HHID_MetaData
))
};
const
UE4CodeGen_Private
::
FPropertyParamsBase
*
const
Z_Construct_UClass_UHH_Statics
::
PropPointers
[]
=
{
(
const
UE4CodeGen_Private
::
FPropertyParamsBase
*
)
&
Z_Construct_UClass_UHH_Statics
::
NewProp_HHID
,
};
到這裡就清晰很多了,透過
STRUCT_OFFSET()
的呼叫可以獲知某個成員在 struct/class 內的偏移量,繼續看
STRUCT_OFFSET()
。
看到這裡會繼續呼叫
offsetof()
,依然是一個宏,這個宏目前在各個編譯器上都得到了支援,當然開發者也可以自己實現,也就是。。。
#ifndef offsetof
#define offsetof(STRUCTURE,FIELD) ((int)((char*)&((STRUCTURE*)0)->FIELD))
#endif
這裡很巧妙使用了以地址0作為物件地址,然後取成員地址的方式,這樣得到的成員地址就是對應的偏移量。
起初我以為計算成員的偏移量會是一個很複雜的事情,需要考慮位元組對齊以及開發者自定義的對齊之類的。不過這依然是編譯器提供的功能,成員偏移本身也是編譯器來決定的。
token stream記錄與物件模型
在以上分析的基礎上,token stream的記錄過程就很明瞭。繼續看
UClass::EmitObjectReference()
,構造了一個
FGCReferenceInfo
。
void
UClass
::
EmitObjectReference
(
int32
Offset
,
const
FName
&
DebugName
,
EGCReferenceType
Kind
)
{
FGCReferenceInfo
ObjectReference
(
Kind
,
Offset
);
ReferenceTokenStream
。
EmitReferenceInfo
(
ObjectReference
,
DebugName
);
}
FGCReferenceInfo
會將偏移量和GC型別一起寫入到一個union中,於是成員的資訊就被編碼到了一個uint32中,
FGCReferenceInfo
本身也是4位元組大小。
struct
FGCReferenceInfo
{
FORCEINLINE
FGCReferenceInfo
(
EGCReferenceType
InType
,
uint32
InOffset
)
:
ReturnCount
(
0
)
,
Type
(
InType
)
,
Offset
(
InOffset
)
{
check
(
InType
!=
GCRT_None
);
check
(
(
InOffset
&
~
0x7FFFF
)
==
0
);
}
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
:
5
;
// The number of bits needs to match TFastReferenceCollector::FStackEntry::ContainerHelperType
/** Offset into struct/ object */
uint32
Offset
:
19
;
};
/** uint32 value of reference info, used for easy conversion to/ from uint32 for token array */
uint32
Value
;
};
};
最後,將編碼後的uint32存入到
FGCReferenceTokenStream::Tokens
中。同時為了方便檢查和除錯,也會把成員的名字存入到
FGCReferenceTokenStream::TokenDebugInfo
中,雖然 GC 整個環節實際上用不到。。。
明確token stream記錄的流程後,再看為什麼父類成員的token stream為什麼要在子類成員之前。
估計很多人已經想到了,因為 C++ 繼承時,子類物件的記憶體佈局中就是將父類成員置為子類之前的。
目前已經有相當多的文章和資料分析了C++的物件模型,這裡僅簡單看下成員的情況。
物件記憶體中按低地址到高地址,依次是虛表指標、父類成員、子類成員。所以在
UClass::ReferenceTokenStream
的
FGCReferenceTokenStream::Tokens
中,將父類成員的token stream 放前面。