成人免费xxxxx在线视频软件_久久精品久久久_亚洲国产精品久久久_天天色天天色_亚洲人成一区_欧美一级欧美三级在线观看

DartVM GC 深度剖析

開發 前端
通過對 DartVM GC 整個流程的梳理,才真正理解了什么是分代 GC,新生代和老年代在 GC 上是相互隔離的,使用著不同的 GC 算法,而老年代自身也存在兩種 GC 算法 Mark-Sweep 和 Mark-Compact。

一、前言

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 最終轉化成的機器指令又是怎樣的呢?

圖片圖片

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

圖片圖片

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

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

圖片圖片

可以看出,最終生成的機器指令主要就是對 StubCode 的調用,而調用的 StubCode 就是上文中通過 GenerateNecessaryAllocationStubs() 生成的 allocation StubCode。所以,Dart 對象的內存分配最終是通過 DartVM 的 Object::Allocate() 來實現的。接下來,我們簡單看一下 Object::Allocate() 的實現。

圖片圖片

可以看到,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 的實現:

圖片圖片

圖片圖片

代碼比較長,這里直接看一下偽代碼:

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 對象是如何遍歷它的所有屬性:

可以看出,先通過 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 對象指針 在訪問對象時需要先取消標記,代碼實現如下。

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

圖片圖片

三、DartVM GC

在介紹 DartVM GC 之前,我們先來看一下 DartVM 的內存模型。

圖片圖片

可以看到,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 集合中的一部分。

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

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

然后再通過 ParallelScavengerVisitor 訪問所有的根對象,如果根對象是:

圖片圖片

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

圖片圖片

ParallelScavengerTask 中執行完 ProcessRoots() 之后,便開始執行 ProcessToSpace() 遍歷 to-space 區域中的對象,而此時 to-space 區域中存放的正是剛剛復制過來的根對象,然后通過 ProcessCopied() 遍歷根對象中的所有屬性。

圖片圖片

遍歷到的屬性對象,如果是新生代對象,則繼續移動到 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_ 集合中的對象,從而對移動到老年代的對象的所有屬性進行遍歷,并將其所關聯的對象指針進行更新。

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

代碼較長,這里就只截出了部分細節,可以看到,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 更加輕量。

圖片圖片

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

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

對象標記

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

可以看到,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() 的具體實現。

圖片圖片

圖片圖片

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

圖片圖片

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

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

Sweep

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

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

可以看到,老年代的 OldPage 主要是通過 sweeper 的 SweepPage() 來進行清理的,SweepPage() 清理完成后會返回一個 bool 值,表示當前的 OldPage 是否還存在對象,如果已經沒有對象了,則會調用 Deallocate()對當前 OldPage 所占用的內存進行釋放。

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

圖片圖片

代碼較長,這里只截出了關鍵部分,在遍歷 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 對象標記是保持一致,所以這里就不再介紹標記階段,主要看一下壓縮階段的實現。

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

圖片圖片

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

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

圖片圖片

可以看到,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() 的實現。

SlideBlock() 代碼較長,只截了其中最關鍵的一部分,可以看到,在遍歷 Block 中的對象時,會先通過 forwarding_block 獲取到對象的新地址,然后將新地址轉化為 UntaggedObject 的對象指針,然后通過 memmove() 將舊地址中的數據移動到新地址,最后通過 VisitPointers() 將對象中的屬性所引用的對象指針進行更新。

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

接下來看一下 VisitPointers() 的實現,看一下對象屬性中的對象指針是如何更新的。

圖片圖片

可以看到,VisitPointers() 會遍歷對象屬性中的所有對象指針,然后調用 ForwardPointer() 完成對象指針更新。接下來,我們看一下 ForwardPointer() 的實現。

圖片圖片

可以看到,它會先通過 OldPage::of() 找到對象指針所在內存分頁,然后獲取到它的 forwarding_page,通過 forwarding_page 查詢出對象的新地址,然后再用新地址更新對象指針。

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

圖片圖片

通過注釋可以看出,接下來的就主要是對 large_page 與新生代內存中的對象進行對象指針更新。至此,Compact 的流程就基本結束了。

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

優點:

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

缺點:

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

并發標記

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

但是,并發標記并不是在所有場景下都使用的。當內存到達一定閾值,相當吃緊的情況下,還是會采取并行標記的方式,掛起所有 isolate 線程,直到整個 GC 流程結束。

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

圖片圖片

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

圖片圖片

可以看到,ConcurrentMarkTask 在調用 IterateRoots() 完成根對象標記之后,就會調用 DrainMarkingStack() 來遍歷 work_list_ 中的對象,而 DrainMarkingStack() 的實現在上文的對象標記中已經介紹過了,這里就不再贅述了。

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

寫入屏障

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

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

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

圖片圖片

可以看到,SOURCE對象在保存 TARGET 對象指針建立引用關系時,會判斷 TARGET 對象 是否是 heap 上的對象,如果是 heap 上的對象,則會調用 CheckHeapPointerStore() 對其進行檢查。接下來,我們看一下 CheckHeapPointerStore() 的具體實現。

圖片圖片

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

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

寫入屏障消除

通過上面對寫入屏障的介紹,我們可以得知,當 TARGET對象賦值給 SOURCE 對象的屬性時,寫入屏障主要作用于以下兩種情況:
  • SOURCE 對象是老年代對象,而 TARGET 對象是新生代對象,且 SOURCE 對象不在 RememberedSet 中。
  1. 此場景下,會將 SOURCE 對象加入到 RememberedSet 中,作用于新生代 GC Scavenge。

  • SOURCE 對象是老年代對象,TARGET 對象也是老年代且沒有被標記,此時 GC 線程正在標記階段。
  1. 此場景下,會對 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)

圖片圖片

可以看到,Thread::RestoreWriteBarrierInvariant() 遍歷到 ExitFrame時,會開始掃描下一個棧幀,會通過 RestoreWriteBarrierInvariantVisitor 遍歷棧幀中的所有對象,并將其 RememberedSet 和標記隊列 work_list_ 中。所以,這個寫入屏障消除必須保證 AllocateObject 和 StoreInstanceField 必須在同一個DartFrame 中,如果它們之間存在函數調用,就無法確保它們在ExitFrame 下方的同一個 DartFrame 中。

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

四、Safepoints

任何可以分配、讀寫 Heap 的非 GC 線程或任務可以稱為 mutator,因為它可以修改對象之間的引用關系。

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

圖片圖片

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

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

圖片圖片

NotifyThreadsToGetToSafepointLevel() 會通知所有 isolate 線程當前需要掛起。

圖片圖片

而 WaitUntilThreadsReachedSafepointLevel() 會等待所有 isolate 線程進入安全點。

圖片圖片

對應 isolate 在發送 OOB 消息時,會處理當前線程狀態中的 interrupt 標記位,如果當前線程狀態的 interrupt 標記位滿足 kVMInterrupt,則會調用 CheckForSafepoint() 檢查當前 isolate 是否被請求進入“安全點”,如果當前 isolate 的 safepoint_state_ 被標記需要進入“安全點”,則會調用 BlockForSafepoint() 標記 safepoint_state_ 已進入“安全點”,并掛起當前線程,直到“安全點操作”結束。

圖片圖片

圖片圖片

圖片圖片

因此,當 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 的報錯堆棧:

圖片圖片

可以看到,問題發生在 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 還存在,沒有被釋放,此塊內存區域肯定是可以訪問的。

修復代碼

圖片圖片

通過 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_ 變量來判斷根對象是否標記結束,結束之后,才開啟對象指針校驗。修改之后,確實不存在卡頓了,于是就開啟了上線灰度。

圖片圖片

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

上線之后,果不其然,GC 問題的堆棧信息發生了改變,錯誤正是新加入的斷言。這就說明,老年代分頁內存確實被修改為了只讀權限,此時去修改對象的標記位肯定是有問題。

圖片圖片

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

六、總結&感悟

通過對 DartVM GC 整個流程的梳理,才真正理解了什么是分代 GC,新生代和老年代在 GC 上是相互隔離的,使用著不同的 GC 算法,而老年代自身也存在兩種 GC 算法 Mark-Sweep 和 Mark-Compact。通過對 GC 問題的定位,也讓我們更加意識到日志的重要性,在不能復現問題的前提下,日志才是排查問題的重要線索。DartVM GC 流程中的埋點日志不僅能幫助我們來排查問題,也能反映出 Dart 代碼中是否存在內存泄漏問題,例如對 GC 過程中 heap 的使用情況進行日志輸出。后續,也希望能夠將 GC 的日志進行持久化,便于回撈,更好地分析應用的內存使用情況和 GC 頻率,為今后的應用性能優化提供思路和方向。
責任編輯:武曉燕 來源: 得物技術
相關推薦

2019-11-27 14:41:50

Java技術語言

2022-09-27 18:56:28

ArrayList數組源代碼

2025-06-04 08:30:00

seata分布式事務開發

2010-02-01 13:34:59

Python 腳本

2010-02-02 15:25:35

Python語法

2010-02-03 16:56:24

Python包

2010-03-05 16:38:30

2014-10-17 09:30:38

2020-04-01 10:28:12

Apache HBas數據結構算法

2010-02-04 15:38:39

Android 手機

2010-03-01 14:50:06

Python 工具

2009-09-15 14:52:15

linq級聯刪除

2010-03-01 18:33:30

2011-05-23 14:20:59

WordPress

2023-01-10 13:48:50

ContainerdCRI源碼

2010-02-02 13:22:06

Python面向對象

2010-02-03 09:35:20

Python函數編程

2010-02-03 11:26:28

2010-02-07 14:29:10

Android SDK

2010-02-24 16:33:28

Python功能
點贊
收藏

51CTO技術棧公眾號

主站蜘蛛池模板: 中文字幕第二十页 | 91在线一区 | 久久久久久天堂 | 在线观看成年人视频 | 欧美亚洲一区二区三区 | 草久久久 | 精品国产一区二区三区久久狼黑人 | 人人人人爽| 欧美一区二区三区免费电影 | 日韩在线免费视频 | 国产福利二区 | 欧美精品第三页 | 免费观看一级毛片 | 国产美女一区二区 | 黄色大片毛片 | 国产精品久久久久久久久久东京 | 中文字幕一区二区不卡 | yeyeav | 一区二区三区视频在线观看 | 欧美精三区欧美精三区 | 一区视频在线 | 成人在线观看免费视频 | 91精品久久久久久久久 | 日韩激情在线 | 成人a在线观看 | 日本亚洲欧美 | 亚洲福利一区 | 黄色网络在线观看 | 色天天综合 | 久久久久国产 | 草草视频在线观看 | 在线观看涩涩视频 | 99久久婷婷国产综合精品电影 | 久久精品这里 | 亚洲一区二区三区免费在线观看 | 成人网av | www日日日| 中文字幕av网 | 黄色福利 | 欧美一级片在线播放 | 亚洲一区二区三区在线 |