DartVM GC 深度剖析|得物技術

架構師修行手冊發表於2024-02-06


來源:得物技術

目錄

一、前言

二、Dart 物件

    1. 物件記憶體分配

    2. 物件記憶體佈局

    3. 物件指標

三、DartVM GC

    1. Scavenge

    2. Mark-Sweep

        2.1 物件標記

        2.2 Sweep

    3. Mark-Compact

    4. 併發標記

    5. 寫入屏障

    6. 寫入屏障消除

四、Safepoints

五、GC 問題定位

六、總結&感悟

前言

GC 全稱 Garbage Collection,垃圾收集,是一種自動管理堆記憶體的機制,負責管理堆記憶體上物件的釋放。在沒有 GC 時,需要開發者手動管理記憶體,想要保證完全正確的管理記憶體需要開發者花費相當大的精力。所以為了讓程式設計師把更多的精力集中在實際問題上,GC 誕生了。Dart 作為 Flutter 的主要程式語言,在記憶體管理上也使用了 GC。

而在 Pink(倉儲作業系統)的線上穩定性問題中,有一個和 GC 相關的疑難雜症,問題堆疊發生在 GC 標記過程,但是導致問題的根源並不在這裡,因為 GC 流程相當複雜,無法確定問題到底出在哪個環節。於是,就對 DartVM 的 GC 流程進行了一次完整的梳理,從 GC 整個流程逐步排查。

Dart 物件

要想完整的瞭解 GC,就要先了解 Dart 物件在 DartVM 的記憶體管理是怎樣呈現的。這裡,我們先從 Dart 物件的記憶體分配來展開介紹。

物件記憶體分配

在 Flutter 中,Dart 程式碼會先編譯成 Kernel dill 檔案,再透過 gen_snapshot 將 dill 檔案生成 Snapshot。而 dill 檔案在生成 Snapshot 的中間過程,會將 dill 檔案中 AST 翻譯成 FlowGraph,然後再 FlowGraph 中的 il 指令編譯成 AOT 機器指令。那建立物件的程式碼最終會編譯成什麼指令呢?接下來,我們先看一下 AST 中物件構造方法呼叫的表示式最終翻譯成 FlowGraph 是什麼樣的。

編譯前:





void _syncAll() {  final obj = TestB();  obj.hello("arg");}

編譯後的 FlowGraph:
@"==== package:flutter_demo/main.dart_::__syncAll@1288309603 (RegularFunction)\r\n"@"B0[graph]:0\r\n"@"B1[function entry]:2\r\n"@"    CheckStackOverflow:8(stack=0, loop=0)\r\n"@"    t0 <- AllocateObject:10(cls=TestB)\r\n"@"    t1 <- LoadLocal(:t0 @-2)\r\n"@"    StaticCall:12( TestB.<0> t1)\r\n"@"    StoreLocal(obj @-1, t0)\r\n"@"    t0 <- LoadLocal(obj @-1)\r\n"@"    t1 <- Constant(#arg)\r\n"@"    StaticCall:14( hello<0> t0, t1, using unchecked entrypoint, result_type = T{??})\r\n"@"    t0 <- Constant(#null)\r\n"@"    Return:16(t0)\r\n"@"*** END CFG\r\n"

可以看到,一個構造方法呼叫,最終會轉換為 AllocateObject 和 StaticCall 兩條指令,其中 AllocateObject 指令用來分配物件記憶體,而 StaticCall 則是真正呼叫構造方法。

那 AllocateObject IL 最終轉化成的機器指令又是怎樣的呢?

DartVM GC 深度剖析|得物技術

在將 AllocateObject 指令轉換為 AOT 指令前,會先透過 GenerateNecessaryAllocationStubs() 方法為 FlowGraph 中的每一個 AllocateObject 指令所對應 Class 生成一個 StubCode,StubCode::GetAllocationStubForClass() 會先檢查 對應 Class 是否已經存在 allocation StubCode,如果已經存在,則直接返回;如果不存在,則會執行下面程式碼,為 Class 生成 allocation StubCode。

DartVM GC 深度剖析|得物技術

可以看出,生成的 allocation StubCode 其實主要是使用了 object_store->allocate_object_stub(),而 object_store->allocate_object_stub() 最終指向的則是 DartVM 中的 Object::Allocate()。

生成 allocation StubCode 之後,我們來看一下 AllocateObject 指令轉成 AOT 機器指令是怎樣的。

DartVM GC 深度剖析|得物技術

可以看出,最終生成的機器指令主要就是對 StubCode 的呼叫,而呼叫的 StubCode 就是上文中透過 GenerateNecessaryAllocationStubs() 生成的 allocation StubCode。所以,Dart 物件的記憶體分配最終是透過 DartVM 的 Object::Allocate() 來實現的。接下來,我們簡單看一下 Object::Allocate() 的實現。

DartVM GC 深度剖析|得物技術

可以看到,Object::Allocate() 主要是透過 DartVM 中的 heap 進行記憶體分配的,而 heap->Allocate() 的返回值就是記憶體分配的地址。接下來,透過判斷 address == 0 來判斷,記憶體是否分配成功,如果分配失敗,說明 heap 上已經不能再分配更多記憶體了,就會丟擲 OOM。反之,則透過 NoSafepointScope 來建立非安全點作用域,然後,透過 InitializeObject() 為物件中屬性賦初始值,完成物件初始化。到這裡,Dart 物件在記憶體中的分配流程就結束了,接下來就是呼叫建構函式,完成物件的真正構造。那麼,Dart 物件在記憶體中的儲存形式是怎樣的呢?接下來,我們就來介紹一下 Dart 物件的記憶體佈局。

物件記憶體佈局

在 Dart 中,每個物件都是一個類的例項,其內部是由一系列資料成員(類的欄位)和一些額外資訊組成的。而 Dart 物件在記憶體中是怎麼儲存的呢?這裡,就不得不先介紹一下 DartVM 中 raw_object。

Dart 中的大部分物件都是 UntaggedObject 的形式儲存在記憶體中,而物件之間的依賴則是透過 ObjectPtr 來維繫,ObjectPtr 是指向 UntaggedObject 的指標,所以物件之間的訪問都是透過 ObjectPtr。

先看一下 UntaggedObject 的實現:

DartVM GC 深度剖析|得物技術

DartVM GC 深度剖析|得物技術

程式碼比較長,這裡直接看一下虛擬碼:






class UntaggedObject {  // 表示物件型別的 tag  var tag;
}

UntaggedObject 是 Dart VM 中一種比較基礎的物件結構,所以 Dart 中的大部分物件都是由 UntaggedObject 來承載的。由於 UntaggedObject 可以儲存不同型別的資料,因此需要使用 tag 欄位來標識當前物件的型別。具體的實現方式是,使用 tag 欄位的低位來記錄物件的型別,另外的高位用來儲存一些額外的資訊,例如物件是否已經被垃圾回收等。所以,UntaggedObject 可以看做是 Dart 物件的 header。

一個 Dart 物件其實是由兩部分組成,一個 header,一個是 fields,而 header 就是上文中的 UntaggedObject。

         +-------------------+         |    header word    |         +-------------------+         | instance variables|         |     (fields)      |         +-------------------+
  • Header word:包含了物件的型別資訊、標記位、長度等一些重要元資訊。具體資訊將根據物件的型別與具體實現而不同。

  • Instance variables(fields):是一個陣列,用於儲存類的例項變數。每個欄位可以儲存不同的資料型別,如布林值、數字、字串、列表等。

接下來,我們看一下,一個 Dart 物件是如何遍歷它的所有屬性:

DartVM GC 深度剖析|得物技術

可以看出,先透過 HeapSize() 獲取物件在 heap 中的實際大小,然後根據物件起始地址 + UntaggedObject 的大小計算得出 fileds 中儲存第一個 ObjectPtr 的地址,然後根據物件起始地址 + 物件時機大小 - ObjectPtr 的大小計算得出 fileds 中儲存的最後一個 ObjectPrt 的地址,這樣就可以透過第一個 ObjectPtr 遍歷到最後一個 ObjectPrt,訪問到 Dart 物件中的所有屬性。

物件指標

物件指標就是上文中所提到的 ObjectPtr,它指向的是直接物件或者 heap 上的物件,可以透過指標的低位來進行判斷。在 DartVM 上只有一種直接物件,那就是 Smi(小整形),他的指標標記為 0,而 heap 上的物件的指標標記則為 1。Smi 指標的高位就是小整形對應的數值,而對於 heap 物件指標,指標本身就是隻是指向 UntaggedObject 的地址,但是需要地址稍作轉換,將最低位的標記位設定為 0,因為每個 heap 物件都是大於 2 位元組的方式對齊的,所以它的低位永遠都為 0,所以可以使用低位來儲存一些其他資訊,區分是否為直接物件還是 heap 物件。

標記為 0 可以使 Smi 可以直接執行很多操作,而無需取消標記和重新標記。

標記為 1 的 heap 物件指標 在訪問物件時需要先取消標記,程式碼實現如下。

DartVM GC 深度剖析|得物技術

DartVM GC 深度剖析|得物技術

Heap 中的物件總是以雙位元組增量分配的。所以 老年代中的物件是保持雙位元組對齊的(address % double-word == 0),而新生代中的物件則保持雙位元組對齊偏移(address % double-word == word)。這樣的話,我們僅需要透過物件地址的對齊方式就能判斷出物件是老年代 還是 新生代,也方便了在 GC 過程快速分辨出物件是新生代 還是 老年代,從而在遍歷過程中直接跳過。

DartVM GC 深度剖析|得物技術

DartVM GC

在介紹 DartVM GC 之前,我們先來看一下 DartVM 的記憶體模型。

DartVM GC 深度剖析|得物技術

可以看到,DartVM 中可以執行多個 isolate group,而一個 ioslate group 中又執行著多個 isolate,對於 Flutter 應用來說,通常只有 一個 isolate group,執行 main() 方法的 Root Isolate 和其他 isolate,其他 isolate 也是透過 Root Isolate 孵化而來,所以都隸屬同一個 isolate group。每個 isolate group 都有一個單獨的 Heap,Heap 又分為新生代和老年代,所以 DartVM 採用的 GC 方式是分代 GC。新生代使用 Scavenge 進行垃圾回收,老年代則是使用 Mark-Sweep 和 Mark-Compact 進行垃圾回收。Scavenge 採用的 GC 演算法是 Copying GC,而 Copying GC 演算法的思路是把記憶體分為兩個空間,這裡可以稱之為 from-space 和 to-space。接下來,我們來看一下 Scavenge GC 的具體實現。

Scavenge

為了提高 CPU 的使用率,Scavenge 是多執行緒並行進行垃圾回收的,執行緒數量透過 FLAG_scavenger_tasks 來決定(預設為 2),每個工作執行緒處理 root object 集合中的一部分。

DartVM GC 深度剖析|得物技術

可以看到,Scavenge 會在 主執行緒和 多個 helper thread 上併發執行 ParallelScavengerTask,接下來看一下 ParallelScavengerTask 的實現。

DartVM GC 深度剖析|得物技術

ParallelScavengerTask 中會透過 ProcessRoots() 來遍歷整個 heap 上的所有根物件 以及 RememberedSet 中的物件,而 RememberedSet 中的物件不一定是根物件,也可能是普通的老年代物件,但是它的屬性中儲存了新生代物件的指標,因為新生代物件在移動之後,也要更新老年代物件中的物件指標,所以ProcessRoots() 會把這類物件也看做根物件進行遍歷。

DartVM GC 深度剖析|得物技術

然後再透過 ParallelScavengerVisitor 訪問所有的根物件,如果根物件是:

DartVM GC 深度剖析|得物技術

ScavengePointer() 會透過 ScavengeObject() 將當前屬性物件轉移至新生代的 to-space 或者老年代分頁上,如果是轉移至老年代分頁上,則會將當前屬性物件記錄到 promoted_list_ 佇列中;之後便將新物件的地址賦值到根物件的屬性,完成物件引用更新。

DartVM GC 深度剖析|得物技術

ParallelScavengerTask 中執行完 ProcessRoots() 之後,便開始執行 ProcessToSpace() 遍歷 to-space 區域中的物件,而此時 to-space 區域中存放的正是剛剛複製過來的根物件,然後透過 ProcessCopied() 遍歷根物件中的所有屬性。

DartVM GC 深度剖析|得物技術

遍歷到的屬性物件,如果是新生代物件,則繼續移動到 to-space 區域或者老年代記憶體分頁中,然後用移動後的新地址更新物件屬性的物件指標。

移動後的物件因為放入到了 to-space 區域,此時新加入到 to-space 的物件也將被遍歷,這樣根物件的屬性遍歷結束後會緊接著遍歷屬性物件中的屬性,然後新的屬性物件又被移動到 to-space 區域。這樣週而復始,就達到了廣度優先遍歷的效果。所有被根物件直接引用或者間接引用到的物件都會被遍歷到,物件從 from-space 轉移到 to-space 或者老年代分頁上,並完成物件引用更新。

ScavengeObject() 移動物件的過程中,本來在 from-space 區域的物件不一定是移動到 to-space 區域,也有可能移動到老年代分頁記憶體上,那這些物件所關聯的屬性該怎麼更新呢?這就要介紹一下 promoted_list_,在 ScavengeObject() 過程中,移動到老年代的物件,將會被放入 promoted_list_ 集合中,當 ProcessToSpace() 結束之後,則會呼叫 ProcessPromotedList() 方法遍歷 promoted_list_ 集合中的物件,從而對移動到老年代的物件的所有屬性進行遍歷,並將其所關聯的物件指標進行更新。

DartVM GC 深度剖析|得物技術

接下來,我們來看一下 ScavengeObject() 的實現,也就是物件移動到 to-space 的具體細節。

DartVM GC 深度剖析|得物技術

DartVM GC 深度剖析|得物技術

DartVM GC 深度剖析|得物技術

程式碼較長,這裡就只截出了部分細節,可以看到,ScavengeObject() 會先透過 ReadHeaderRelaxed() 獲取到物件頭,透過物件頭來判斷當前物件是否已經被轉移,如果已經轉移,也直接透過 header 獲取新地址的物件,然後將新物件進行返回。如果未轉移,則透過 NewPage::of() 獲取到物件所在的新生代記憶體分頁,透過該分頁中的 survivor_end_ 來判定該物件是否是上次 GC 中的存活物件,如果不是上次 GC 的存活物件,說明是新物件,就直接透過 TryAllocateCopy() 在 to-space 上申請記憶體空間得到 new_addr。接下來,就判斷 new_addr 是否為 0,如果為 0,就存在兩種情況,一個是該物件是上次 GC 的存活物件,一個是 TryAllocateCopy() 分配記憶體失敗,這兩種情況下就會透過 page_space_ 在老年代記憶體上分配記憶體,從而使物件從新生代轉移到老年代。接下來,就是 objcpy() 將物件資料複製到新地址中。複製完成後,就會透過 ForwardingHearder 來建立一個 forwarding_header 物件,並透過 InstallForwardingPointer 將其寫入到原來物件的物件頭中,這樣在遍歷物件過程中,快速判斷出物件是否已經轉移,並透過物件頭快速獲取到轉移後的新地址。至此,ScavengeObject() 的流程就結束了,然後將新物件返回出去,然後上層呼叫點 ScavengePointer() 就會透過這個新物件來更新物件指標。可以看出,Scavenge 在移動物件的同時,將物件指標也進行更新了,這樣就只需遍歷一次新生代記憶體上的物件,即可完成 GC 的主流程。所以,新生代的 GC 演算法相對於其他 GC 演算法要高效很多。

而 Scavenge 之所以採用 Copying GC 演算法,正是因為它優秀的吞吐量,吞吐量意思就是單位時間內 GC 的處理能力,可以簡單理解為效率更好的演算法吞吐量更優秀。對比一下,Mark-Sweep 演算法的消耗是根搜尋和遍歷整個 heap 花費的時間之和,Copying GC 演算法則是根搜尋和複製存活物件。一般來說 Copying GC 演算法的吞吐量會更優秀,堆越大差距越明顯。眾所周知,在演算法上,時間維度和空間維度是成反比的,既然有了這麼優秀吞吐量,那必然要犧牲一部分空間,所以 Copying GC 在記憶體使用效率上相對於其他 GC 演算法是比較低的。Copying GC 演算法總是有一個區域無法使用,對比其他使用整堆的演算法,堆的使用效率低。這是 Copying GC 演算法的一個重大缺陷。

Mark-Sweep

老年代 GC 主要分為兩種方式,一個是 Mark-Sweep,一個是 Mark-Compact,而 Mark-Sweep 相對於 Mark-Compact 更加輕量。

DartVM GC 深度剖析|得物技術

觸發 GC 的方式有兩種,一個是系統處於空閒狀態時,一個物件分配記憶體時記憶體不足,上方程式碼則是 Heap::NotifyIdle() 中的一段邏輯。Heap::NotifyIdle() 是 Dart VM 中的一個函式,用於通知垃圾回收器當前系統處於空閒狀態,垃圾回收器可以利用這段空閒時間進行垃圾回收。具體來說,Heap::NotifyIdle函式會向垃圾回收器傳送一個通知,告訴垃圾回收器當前系統處於空閒狀態,可以進行垃圾回收。垃圾回收器在收到通知後,會開始啟動垃圾回收器,對堆中的垃圾物件進行回收。這個函式可以在應用程式中的任何時間點呼叫,例如當應用程式處於空閒狀態時,或者當應用程式需要高峰時期的效能時。

Mark-Sweep 主要分為兩個階段一個是標記階段,另一個則是清理階段。這裡我們先看一下標記階段。

物件標記

物件標記是整個老年代 GC 的一個重要流程,不管是 Mark-Sweep,還是 Mark-Compact,都建立在物件標記的基礎上。而物件標記又分為並行標記和併發標記,這裡我們以並行標記為例來介紹一下標記階段。並行標記是利用多個執行緒同時進行標記任務,提高多核 CPU 的使用率,從而減少 GC 流程中物件標記所花費的時間,這裡我們直接看一下 並行標記過程中 MarkObjects() 具體實現。

DartVM GC 深度剖析|得物技術

可以看到,MarkObjects() 中的 FLAG_marker_tasks 和 Scavenge 中的 FLAG_scavenger_tasks 相似,為了充分利用 CPU,提高 GC 的效能,透過 FLAG_marker_tasks 決定執行緒的個數,然後開啟多個執行緒併發標記,FLAG_scavenger_tasks 預設值也是 2。這裡我們假設 FLAG_scavenger_tasks 是 0,以單執行緒標記 來梳理 物件標記的整個流程。因為是單執行緒,所以這裡忽略掉 ResetSlices() 的實現,ResetSlices() 的主要作用是進行分片,為多個執行緒劃分不同的標記任務。接下來,我們可以看到 IterateRoots(),開始遍歷根物件,從根物件開始標記,根物件標記之後,會將根物件新增至 work_list_。緊接著,會呼叫 ProcessDeferredMarking() 從 work_list_ 中取出物件,然後遍歷它的所有屬性,將屬性所關聯的物件進行標記,並將其再次加入 work_list_ 繼續遍歷,週而復始,就會把根物件直接引用或間接引用的物件都進行了標記,從而達到了從根物件開始的廣度優先遍歷。接下來,我們看一下物件標記 MarkObject() 的具體實現。

DartVM GC 深度剖析|得物技術

DartVM GC 深度剖析|得物技術

MarkObject() 截圖中刪除了部分細節,這裡主要看一下關鍵流程。可以看到,MarkObject() 會先判斷物件是否是小整形或者是新生代物件,小整形在上文有介紹到,指標即物件,無需標記,而新生代物件也無需標記,緊接著就是呼叫 TryAcquireMarkBit() 進行標記,標記完成後,就會呼叫 PushMarked() 將物件加入到 work_list_ 中。接下來,我們看一下 DrainMarkingStack() 的實現,也就是遍歷 work_list_ 的實現。

DartVM GC 深度剖析|得物技術

正如上文所述,DrainMarkingStack() 會以一直從 work_list_ 中取出物件,然後透過 VisitPointersNonvirtual() 遍歷物件中的所有屬性,將屬性所關聯的物件進行標記,標記之後再將其加入到 work_list_,致使繼續往下遍歷,直到 work_list_ 中的物件被清空。這樣一來,根物件直接引用和間接引用的物件都將會標記。至此,標記物件的核心流程就大致介紹完了。

接下來,我們看一下 Mark-Sweep 中另外一個重要的環節 Sweep。

Sweep

Sweep 作為 Mark-Sweep 的重要一環,主要作用是將未標記物件的記憶體進行清理,從而釋放出記憶體空間給新物件進行分配。

我們直接看一下 Sweep 流程中的關鍵程式碼:

DartVM GC 深度剖析|得物技術

可以看到,老年代的 OldPage 主要是透過 sweeper 的 SweepPage() 來進行清理的,SweepPage() 清理完成後會返回一個 bool 值,表示當前的 OldPage 是否還存在物件,如果已經沒有物件了,則會呼叫 Deallocate()對當前 OldPage 所佔用的記憶體進行釋放。

接下來,我們看一下 SweepPage() 的主要實現。

DartVM GC 深度剖析|得物技術

程式碼較長,這裡只截出了關鍵部分,在遍歷 OldPage 上的物件時,會先取出物件的 tags 判斷是否被標記,如果物件被標記了,則會清除物件的標記位,然後將物件的大小累加到 used_in_bytes 中;如果沒有標記, 則會建立一個 free_end 變數來記錄可以清理的結束位置,然後透過 while 迴圈來遍歷後續物件,直到遍歷到一個已標記的物件,這樣做的目的是為了一次性計算出可連續清理的記憶體,這樣的話就可以釋放出一個儘可能大的記憶體空間來分配新的物件,可以看到,最終是透過 free_end - current 來計算出可連續釋放的空間,然後將可釋放的起始地址與大小記錄到 freelist 中,這樣後續物件在分配記憶體時 就可以透過 OldPage 的 freelist 來獲取到記憶體。

至此,Mark-Sweep 的流程就結束了。Mark-Sweep 作為老年代 GC 最常用的演算法,也存著一些缺點,例如碎片化問題。為了解決碎片化問題,就引入了 Mark-Compact。接下來,我們就介紹一下老年代的另外一個 GC 演算法 Mark-Compact。

Mark-Compact

Mark-Compact 主要分為兩個部分,分別是標記和壓縮。而標記階段和上文中介紹的 Mark-Sweep 物件標記是保持一致,所以這裡就不再介紹標記階段,主要看一下壓縮階段的實現。

DartVM GC 深度剖析|得物技術

老年代記憶體在申請記憶體分頁之後,會在當前記憶體分頁尾部分配一塊記憶體來存放 ForwardingPage 物件。而這個 ForwardingPage 物件中則是存放了多個 ForwardingBlock,ForwardingBlock 中則是存放當前分頁存活物件即將轉移的新地址。

DartVM GC 深度剖析|得物技術

從上方程式碼可以看出,整個壓縮階段主要分成兩個步驟,一個 PlanPage(),一個是 SlidePage()。這裡先介紹 PlanPage(),它的主要作用是計算所有存活物件需要移動的新地址,並將其記錄到上文中所提到的 ForwardingPage 的 ForwardingBlock 中。由於跟CopyingGC 不同的是在同一塊區域操作,所以可能會出現移動時把存活物件覆蓋掉的情況,所以這一步只做存活物件新地址的計算。

DartVM GC 深度剖析|得物技術

可以看到,PlanPage() 並沒有直接從 object_start() (分頁記憶體中的第一個物件的地址)進行處理,而是呼叫了 PlanBlock() 來進行處理,顧名思義,記憶體分頁會劃分成多個 Block,然後對 Block 分別處理,並將計算出的新地址記錄到 ForwardingBlock 中。接下來我們看一下 PlanBlock() 的具體實現。

DartVM GC 深度剖析|得物技術

可以看到,PlanBlock() 會先透過 first_object 計算得到 Block 中的起始地址,然後透過 kBlockSize 計算的得出 Block 的結束地址,然後透過起始地址遍歷 Block 中的所有物件。如果當前遍歷到的物件被標記了,則會透過 RecordLive() 記錄到 ForwardingBlock 中,而 RecordLive() 內部使用了一些黑魔法,它並沒有直接儲存物件轉移的新地址,而是先計算出物件在 Block 中的偏移量,然後透過這個偏移量對 live_bitvector_ 進行位移計算得到一個 bit 位, 用這個 bit 位來記錄該物件是否存活。當 Block 中的所有物件都遍歷完成後,透過 set_new_address() 整個 Block 中物件轉移的新地址。所以,每個 Block 都只會儲存一個新地址,那 Block 中的所有存活物件,怎麼根據這個新地址進行移動呢?這就要介紹一下 SlidePage() 中的 SlideBlock(),這裡我們就不再關注 SlidePage() 了,因為它的實現和 PlanPage() 差不多,裡面迴圈呼叫了 SlideBlock(),這裡我們直接看一下 SlideBlock() 的實現。

DartVM GC 深度剖析|得物技術

SlideBlock() 程式碼較長,只截了其中最關鍵的一部分,可以看到,在遍歷 Block 中的物件時,會先透過 forwarding_block 獲取到物件的新地址,然後將新地址轉化為 UntaggedObject 的物件指標,然後透過 memmove() 將舊地址中的資料移動到新地址,最後透過 VisitPointers() 將物件中的屬性所引用的物件指標進行更新。

看到這裡,我們對 Compact(記憶體壓縮) 已經有了一個大致的瞭解,CompactTask 先透過 PlanPage() 遍歷老年代分頁記憶體上的所有標記物件,然後計算出他們將要移動的新地址,然後再透過 SlidePage() 再次遍歷老年代記憶體上的物件,將存活的物件移動到新地址,在物件移動的同時去更新物件屬性中的物件指標(也就是物件之間的引用關係)。

接下來看一下 VisitPointers() 的實現,看一下物件屬性中的物件指標是如何更新的。

DartVM GC 深度剖析|得物技術

可以看到,VisitPointers() 會遍歷物件屬性中的所有物件指標,然後呼叫 ForwardPointer() 完成物件指標更新。接下來,我們看一下 ForwardPointer() 的實現。

DartVM GC 深度剖析|得物技術

可以看到,它會先透過 OldPage::of() 找到物件指標所在記憶體分頁,然後獲取到它的 forwarding_page,透過 forwarding_page 查詢出物件的新地址,然後再用新地址更新物件指標。

PlanPage()SlidePage() 執行結束之後,Compact 流程就接近尾聲了,剩下的就是掃尾工作了,其實還是物件引用的更新,SlidePage() 中移動物件同時雖然會更新物件指標,但是這僅僅是處理了老年代記憶體分頁上物件之間的引用,但是像新生代物件,它的物件屬性中可能也存在老年代物件的物件指標,它們之間的引用關係還沒有被更新。所以,接下來就是更新非老年代物件中的物件指標。

DartVM GC 深度剖析|得物技術

透過註釋可以看出,接下來的就主要是對 large_page 與新生代記憶體中的物件進行物件指標更新。至此,Compact 的流程就基本結束了。

透過以上分析,可以發現,相對於 CopyingGC、Mark-Sweep,Mark-Compact 也存在著優缺點。

優點:

可有效利用堆:比起 CopyingGC 演算法,它可利用的堆記憶體空間更大;同時也不存在記憶體碎片,所以比起 Mark-Sweep,可利用空間也是更大。

缺點:

壓縮過程有計算成本。整個標記壓縮流程必須對整個堆進行 3 次遍歷,執行該演算法花費的時間是和堆大小成正比的,吞吐量要劣於其他演算法。

併發標記

在 GC 過程中,會透過“安全點”的方式掛起所有 isolate 執行緒,isolate 掛起就意味著無法立即響應使用者的操作,為了減少 isolate 掛起時間,就引入了併發標記。併發標記會使 GC 執行緒在標記階段時,與 isolate 併發執行。因為 GC 標記是一個比較耗時的過程,如果 isolate 執行緒能夠和 GC 標記 同時執行,就不會導致使用者介面長時間卡頓,從而提高使用者體驗。

但是,併發標記並不是在所有場景下都使用的。當記憶體到達一定閾值,相當吃緊的情況下,還是會採取並行標記的方式,掛起所有 isolate 執行緒,直到整個 GC 流程結束。

接下來,我們來看一下 StartConcurrentMark() 是如何實現併發標記的。

DartVM GC 深度剖析|得物技術

可以看到,StartConcurrentMark() 會先 透過 ResetSlices() 計算分片個數,新生代物件作為 GC 標記的根物件,為了提高標記效率,多個標記執行緒會同時遍歷新生代物件,所以透過分片的方式可以讓多個標記執行緒能夠盡然有序的遍歷新生代分頁記憶體上的物件。接下來,就是透過 thread_pool() 來分配多個執行緒來執行標記任務 ConcurrentMarkTask,num_tasks 就是併發標記的執行緒個數,之所以減 1,是因為當前主執行緒也作為標記任務的一員,但是主執行緒只會呼叫 IterateRoots() 來遍歷根物件,後續 work_list_ 中的物件則是透過 thread_pool() 重新分配一個執行緒來執行 ConcurrentMarkTask,主執行緒的標記任務到此就基本結束了,接下來就是透過 root_slices_monitor_ 同步鎖,等待所有根物件遍歷完成。剩下的都交給了 ConcurrentMarkTask 來完成。接下來,我們就看一下 ConcurrentMarkTask 的實現。

DartVM GC 深度剖析|得物技術

可以看到,ConcurrentMarkTask 在呼叫 IterateRoots() 完成根物件標記之後,就會呼叫 DrainMarkingStack() 來遍歷 work_list_ 中的物件,而 DrainMarkingStack() 的實現在上文的物件標記中已經介紹過了,這裡就不再贅述了。

有了併發標記,GC 標記任務和 isolate 執行緒就可以併發執行,這樣就避免了 GC 標記因掛起 isolate 執行緒帶來的長時間卡頓。

寫入屏障

有了併發標記之後,就會引入另外一個問題。因為併發標記允許 isolate 執行緒與 GC 標記執行緒同時執行,所以就存在標記過程中,isolate 執行緒修改了物件引用。也就是說,兩個物件被標記執行緒遍歷之後,一個未被標記的物件引用 在 isolate 執行緒中被賦值給一個已經標記物件的屬性,此時,未標記物件被標記物件所引用,此時未標記的物件理論上已經被根物件間接引用,應該 GC 過程中不能被清理,但是因為併發標記階段沒有被標記,所以在最終 Sweep 階段將會被清理,這明顯出現了錯誤。為了解決這個問題,就引入了寫入屏障。

在標記過程中,當未標記的物件(TARGET)被賦值給已標記物件(SOURCE)的屬性時,此時 TARGET 物件理應也該被標記,為了防止 TARGET 物件逃逸標記,寫入屏障會對未標記的 TARGET 物件進行檢查。

如果 TARGET 物件與 SOURCE 物件都是老年代物件時,寫入屏障就會對未標記的 TARGET 物件進行標記,並將該物件加入到標記佇列,致使該物件關聯的其他物件也會被標記。

DartVM GC 深度剖析|得物技術

可以看到,SOURCE物件在儲存 TARGET 物件指標建立引用關係時,會判斷 TARGET 物件 是否是 heap 上的物件,如果是 heap 上的物件,則會呼叫 CheckHeapPointerStore() 對其進行檢查。接下來,我們看一下 CheckHeapPointerStore() 的具體實現。

DartVM GC 深度剖析|得物技術

CheckHeapPointerStore() 方法中,會判斷 TARGET 物件是否是新生代物件,如果是新生代物件,則會呼叫 EnsureInRememberedSet() 將 SOURCE 物件加入到 RememberedSet 中(主要作用於上文中介紹的 Scavenge,新生代物件轉移時能夠更新老年代物件中儲存的物件指標),但並未對 TARGET 物件進行特殊處理,這是因為新生代物件在老年代 GC 標記過程中本身就作為根物件,而且在標記結束時,會重新遍歷這些根物件。接下來,就是非新生代物件,非新生代物件只有兩種 Smi 和老年代物件,因為在外層函式 StorePointer() 中有判斷是否是 heap 上的物件,所以這裡不可能是 Smi,只能是老年物件。老年代物件則呼叫 TryAcquireMarkBit() 進行標記,標記成功後,將其加入到標記佇列中(也就是上文中所提到的 work_list_),使其關聯到的物件也被遍歷標記。

有了寫入屏障,就確保了在併發標記時,isolate 修改 heap 物件之間的引用關係時,不會導致物件遺漏標記被 GC 清理。但是寫入屏障也會帶來額外的開銷,為了減少這種開銷,就要介紹到另外一個最佳化:寫入屏障消除。

寫入屏障消除

透過上面對寫入屏障的介紹,我們可以得知,當 TARGET物件賦值給 SOURCE 物件的屬性時,寫入屏障主要作用於以下兩種情況:

  • SOURCE 物件是老年代物件,而 TARGET 物件是新生代物件,且 SOURCE 物件不在 RememberedSet 中。

      此場景下,會將 SOURCE 物件加入到 RememberedSet 中,作用於新生代 GC Scavenge。

  • SOURCE 物件是老年代物件,TARGET 物件也是老年代且沒有被標記,此時 GC 執行緒正在標記階段。

      此場景下,會對 TARGET 物件進行標記,並將 TARGET 物件加入到 work_list_ 中。

而在這兩種情況下,其實也存在著一些場景無需寫入屏障,只要在編譯時能夠判定出是這些場景,就可以消除這類的寫入屏障。我們簡單列舉一些場景:

  • TARGET 物件是一個常量(因為常量必定是老年代物件,即使在賦值給 SOURCE 物件時沒有被標記,也會在 GC 過程中透過常量池被標記)。

  • TARGET 物件是 bool 型別(bool 型別只可能有三種情況:null、false、true,而這三個值都是常量,所以如果是 bool 型別,必定是一個常量)。

  • TARGET 物件是小整形(小整形在上文中也介紹過,指標即物件,所以他不算是 heap 上的物件)。

  • SOURCE 物件和 TARGET 物件是同一個物件(自身屬性持有自己)。

  • SOURCE 物件是新生代物件或者是已經被新增至 RememberedSet 的老年代物件(上文中也介紹過,新生代物件作為根物件,在標記結束時,會重新遍歷這些根物件)。

我們可以知道,當 SOURCE 物件是透過 Object::Allocate() 進行分配的(而不是從 heap 中載入的),它的 Allocate() 和它最近一次的屬性賦值之間如果不存在觸發 GC 的 instruction,那它的屬性賦值也可以消除寫入屏障。這是因為 Object::Allocate() 分配的物件一般情況下是新生代物件,如果是老年代物件,在 Allocate() 時會被直接修改為標記狀態, 預先新增至 RememberedSet 和標記佇列 work_list_ 中。

container <- AllocateObject<intructions that do not trigger GC>StoreInstanceField(container, value, NoBarrier)

在此基礎上, 當 SOURCE 物件的 Allocate() 和它的屬性賦值之間不存在函式呼叫,我們可以進一步來消除屬性賦值帶來的寫入屏障。這是因為在 GC 之後,Thread::RestoreWriteBarrierInvariant() 會將 ExitFrame 下方的棧幀中的所有老年代物件新增至 RememberedSet 和標記佇列 work_list_ 中(ExitFrame 是表示函式呼叫棧退出的特殊幀,當函式執行完畢時,虛擬機器會將 ExitFrame 推入棧頂,以表示函式的退出)。

container <- AllocateObject<instructions that cannot directly call Dart functions>StoreInstanceField(container, value, NoBarrier)

DartVM GC 深度剖析|得物技術

可以看到,Thread::RestoreWriteBarrierInvariant() 遍歷到 ExitFrame時,會開始掃描下一個棧幀,會透過 RestoreWriteBarrierInvariantVisitor 遍歷棧幀中的所有物件,並將其 RememberedSet 和標記佇列 work_list_ 中。所以,這個寫入屏障消除必須保證 AllocateObjectStoreInstanceField 必須在同一個DartFrame 中,如果它們之間存在函式呼叫,就無法確保它們在ExitFrame 下方的同一個 DartFrame 中。

可以看到,寫入屏障消除透過在編譯時和執行時的一些推斷,避免了一些不必要的額外開銷。

Safepoints

任何可以分配、讀寫 Heap 的非 GC 執行緒或任務可以稱為 mutator,因為它可以修改物件之間的引用關係。

GC 的某些階段要求 Heap 不允許被 mutator 使用,我們稱之為 safepoint operations。例如:老年代 GC 併發標記時的根物件標記,以及標記結束後的物件清理。

DartVM GC 深度剖析|得物技術

為了執行這些操作,所有 mutator 都需要暫時停止訪問 Heap,此時 mutator 就到達了“安全點”。已經達到安全點的 mutator 將不能訪問 Heap,直到 safepoint operations 完成。

在 GC 過程中,GcSafepointOperationScope 會致使當前執行緒等待所有 isolate 執行緒到達“安全點”之後才能繼續執行,這樣就保證了後續流程中 isolate 執行緒不會修改 Heap 上的物件。

DartVM GC 深度剖析|得物技術

NotifyThreadsToGetToSafepointLevel() 會通知所有 isolate 執行緒當前需要掛起。

DartVM GC 深度剖析|得物技術

WaitUntilThreadsReachedSafepointLevel() 會等待所有 isolate 執行緒進入安全點。

DartVM GC 深度剖析|得物技術

對應 isolate 在傳送 OOB 訊息時,會處理當前執行緒狀態中的 interrupt 標記位,如果當前執行緒狀態的 interrupt 標記位滿足 kVMInterrupt,則會呼叫 CheckForSafepoint() 檢查當前 isolate 是否被請求進入“安全點”,如果當前 isolate 的 safepoint_state_ 被標記需要進入“安全點”,則會呼叫 BlockForSafepoint() 標記 safepoint_state_ 已進入“安全點”,並掛起當前執行緒,直到“安全點操作”結束。

DartVM GC 深度剖析|得物技術

DartVM GC 深度剖析|得物技術

DartVM GC 深度剖析|得物技術

因此,當 isolate 傳送 OOB 訊息時,就會觸發“安全點”檢查,從而導致執行緒掛起進入“安全點”。那什麼是 OOB 訊息,而 OOB 訊息傳送又是何時被觸發的,這就要簡單介紹一下 isolate 的事件驅動模型。正如大部分的 UI 平臺,isolate 也是透過訊息佇列實現的事件驅動模型。不過,在 isolate 中有兩個訊息佇列,一個佇列是普通訊息佇列,另一個佇列叫 OOB 訊息佇列,OOB 是 "out of band" 縮寫,翻譯為帶外訊息,OOB 訊息用來傳送一些控制類訊息,例如從當前 isolate 生成(spawn)一個新的 isolate。我們可以在當前 isolate 傳送OOB訊息給新 isolate,從而控制新 isolate。比如,暫停(pause),恢復(resume),終止(kill)等。

有了“安全點”,就保證了其他執行緒在 GC 過程中不能隨意訪問、操作 Heap 上的物件,確保 GC 過程中一些重要操作(根物件遍歷、記憶體清理、記憶體壓縮等等) 不受其他執行緒影響。

GC問題定位

先看一下 GC 的報錯堆疊:

DartVM GC 深度剖析|得物技術

可以看到,問題發生在 GC 過程中的物件遍歷標記。起初,猜想會不會是多個 isolate 執行緒都觸發了 GC,多執行緒 GC 導致的,但是看了 Safepoints 實現之後,發現這種情況不可能存在,於是排除了此猜想。

因為 DartVM 中的老年代記憶體分頁是透過 OldPage 進行管理的,在這些 OldPage 中,除了 code pages,其他 OldPage 都是可讀可寫的。

而 DartVM 也提供了相應的 API 來修改 OldPage 的許可權。

  • PageSpace::WriteProtectCode()

  • PageSpace::WriteProtect()

在 GC 標記前,會透過 PageSpace::WriteProtectCode() 將“老年代” 中的  code pages 許可權修改為可讀可寫,以便在標記過程中對 Instructions 物件進行標記,在 GC 結束後,再透過 PageSpace::WriteProtectCode() 將  code pages 的許可權修改為只讀。

因為 code pages 是用來動態分配的可執行記憶體頁,用來生成 JIT 的機器指令,所以 code pages 只讀許可權導致的 SEGV_ACCERR 問題,只會在 Debug 包上才能復現,所以 release 包不會存在此問題。

而 PageSpace::WriteProtect() 也可以修改 OldPage 對應分頁的讀寫許可權,該方法可以將“老年代”上的所有 OldPage 修改為只讀許可權。目前透過搜尋程式碼,發現只有一個呼叫時機,就是 ioslate 退出時,清理 ioslate 時會透過 WritableVMIsolateScope 物件的析構會將 "老年代" 上的 所有 OldPage 改為只讀。OldPage 修改為只讀之後,再對 OldPage 上的物件進行標記時就會出現問題。透過模擬 WritableVMIsolateScope 物件的析構,也復現了和線上完全一模一樣的 crash 堆疊。但是 isolate 正常情況下是不會退出的,所以在前期排除了這種可能。

後來,還是把猜測轉向了寫入屏障消除,會不是寫入屏障消除導致了物件逃逸了 GC 標記,致使所在 OldPage 被清理釋放,再次觸發 GC,遍歷到此物件指標時,物件所在的記憶體已經被釋放,野指標導致的 SEGV_ACCERR 問題。如果是這種情況的話,想到了一個臨時的解決方案,在 GC 標記過程中,對 ObjectPtr 所指向地址做校驗,判斷是否是一個合法地址。因為標記訪問的物件對儲存在 OldPage 上,所以我們只判斷一下該地址在不在 當前"老年代"的 OldPage 的記憶體區域內,如果地址在 OldPage 記憶體區域內,說明 ObjectPtr 所指向的物件所在 OldPage 還存在,沒有被釋放,此塊記憶體區域肯定是可以訪問的。

修復程式碼

DartVM GC 深度剖析|得物技術

透過 PageSpace::ContainsUnsafe(uword addr) 方法,來判斷物件地址是否在 "老年代" 分頁記憶體上,這個方法本來是輕量級的,但是 GC 過程中,需要標記大量物件,每次標記都要進行這個判斷,導致此方法的總開銷較大,整個 GC 時間被拉長。實測下來,每次 GC,都會導致介面 3~5s 的卡頓。所以,此方法還需要最佳化。在上文中,也介紹過併發標記,理論上 GC 標記 和 isolate 是併發執行的,不會影響到使用者互動。但是,GC 標記並不是整個流程都和 isolate 併發執行的,上文中也提到過 GcSafepointOperationScope,在 GC 標記之前,會透過 GcSafepointOperationScope 掛起除當前執行緒的所有 isolate 執行緒,直到當前 GC 方法執行結束,如果併發標記階段,則是標記方法執行結束,上文中也提到過,GC 標記的主執行緒會等待所有根物件標記結束,所以根物件標記結束後,才會進入真正的併發標記階段。因為大部分問題都是發生在 work_list_ 中的物件標記,我們是不是可以直接忽略根物件的標記,在根物件標記之後,才開啟物件指標校驗(這樣就只能保證 work_list_ 中的物件標記,根物件標記還是存在問題,但是至少能減少問題出現的頻次)。

於是透過 GCMarker 中 root_slices_finished_ 變數來判斷根物件是否標記結束,結束之後,才開啟物件指標校驗。修改之後,確實不存在卡頓了,於是就開啟了上線灰度。

DartVM GC 深度剖析|得物技術

但是上線後,並不是很理想,GC 問題還是存在。既然猜測是寫入屏障消除導致的,乾脆就大膽一點,直接把寫入屏障消除這一最佳化給移除掉。寫入屏障消除這一最佳化移除灰度上線之後,發現 GC 問題還是存在。此時,思緒萬千,難道真的是 PageSpace::WriteProtect() 導致的,為了驗證這一猜測,於是就在物件標記之前加入了 RELEASE_ASSERT,判斷老年代分頁記憶體是否真的被修改為了只讀許可權。

上線之後,果不其然,GC 問題的堆疊資訊發生了改變,錯誤正是新加入的斷言。這就說明,老年代分頁記憶體確實被修改為了只讀許可權,此時去修改物件的標記位肯定是有問題。

DartVM GC 深度剖析|得物技術

當我們準備更近一步時,卻因為高頻 GC 問題的幾臺裝置不再使用,失去了可用於灰度的裝置,導致無法進一步去驗證問題。也因為這幾臺高頻 GC 問題裝置的下線,GC 問題在 crash 佔比中顯得不那麼重要,問題就這樣淡出了我們的視線。不過還是希望後續能夠找到根因,徹底解決此問題。

總結&感悟

透過對 DartVM GC 整個流程的梳理,才真正理解了什麼是分代 GC,新生代和老年代在 GC 上是相互隔離的,使用著不同的 GC 演算法,而老年代自身也存在兩種 GC 演算法 Mark-Sweep 和 Mark-Compact。透過對 GC 問題的定位,也讓我們更加意識到日誌的重要性,在不能復現問題的前提下,日誌才是排查問題的重要線索。DartVM GC 流程中的埋點日誌不僅能幫助我們來排查問題,也能反映出 Dart 程式碼中是否存在記憶體洩漏問題,例如對 GC 過程中 heap 的使用情況進行日誌輸出。後續,也希望能夠將 GC 的日誌進行持久化,便於回撈,更好地分析應用的記憶體使用情況和 GC 頻率,為今後的應用效能最佳化提供思路和方向。


參考文獻:

來自 “ ITPUB部落格 ” ,連結:https://blog.itpub.net/70027824/viewspace-3006450/,如需轉載,請註明出處,否則將追究法律責任。

相關文章