垃圾回收(二)【Windows 系統上的大型物件堆】

風靈使發表於2019-01-07

https://docs.microsoft.com/zh-cn/dotnet/standard/garbage-collection/large-object-heap

.NET 垃圾回收器 (GC) 將物件分為小型和大型物件。 如果是大型物件,它的某些特性將比物件較小時顯得更為重要。 例如,壓縮大型物件(也就是在記憶體中將其複製到堆上的其他地方)的費用相當高。 因此,.NET 垃圾回收器將大型物件放置在大型物件堆 (LOH) 上。 在本主題中,我們將深度探討大型物件堆。 我們將討論符合什麼條件的物件才能稱之為大型物件,如何回收這些大型物件,以及大型物件具備哪些效能意義。

本主題僅討論 .NET Framework 中的大型物件堆和 Windows 系統上執行的 .NET Core。 不包括在其他平臺上的 .NET 實現上執行的 LOH。

物件如何在大型物件堆上結束以及 GC 如何處理它們

如果物件大於或等於 85,000 位元組,將被視為大型物件。 此數字根據效能優化確定。 物件分配請求為 85,000 位元組或更大時,執行時會將其分配到大型物件堆。

若要了解其意義,可檢視 .NET GC 的部分相關基礎知識。

.NET 垃圾回收器是分代回收器。 它包含三代:第 0 代、第 1 代和第 2 代。 包含 3 代的原因是,在優化良好的應用中,大部分物件都在第 0 代就清除了。 例如,在伺服器應用中,與每個請求相關的分配應在請求完成後清除。 仍存在的分配請求將轉到第 1 代,並在那裡進行清除。 從本質上講,第 1 代是新物件區域與生存期較長的物件區域之間的緩衝區。

小型物件始終在第 0 代中進行分配,或者根據它們的生存期,可能會提升為第 1 代或第 2 代。 大型物件始終在第 2 代中進行分配。

大型物件屬於第 2 代,因為只有在第 2 代回收期間才能回收它們。 回收一代時,同時也會回收它前面的所有代。 例如,執行第 1 代 GC 時,將同時回收第 1 代和第 0 代。 執行第 2 代 GC 時,將回收整個堆。 因此,第 2 代 GC 還可稱為“完整 GC”。 本文引用第 2 代 GC 而不是完整 GC,但這兩個術語是可以互換的。

代可提供 GC 堆的邏輯檢視。 實際上,物件存在於託管堆段中。 託管堆段是 GC 通過呼叫 VirtualAlloc 功能代表託管程式碼在作業系統上保留的記憶體塊。 載入 CLR 時,GC 分配兩個初始堆段:一個用於小型物件(小型物件堆或 SOH),一個用於大型物件(大型物件堆)。

然後,通過將託管物件置於這些託管堆段上來滿足分配請求。 如果該物件小於 85,000 位元組,則將它置於 SOH 的段上,否則,將它置於 LOH 段。 隨著分配到各段上的物件越來越多,會以較小塊的形式提交這些段。
對於 SOHGC 未處理的物件將提升為下一代。 第 0 代回收未處理的物件現在視為第 1 代物件,以此類推。 但是,最後一代回收未處理的物件仍會被視為最後一代中的物件。 也就是說,第 2 代垃圾回收未處理的物件仍是第 2 代物件;LOH 未處理的物件仍是 LOH 物件(由第 2 代回收)。

使用者程式碼只能在第 0 代(小型物件)或 LOH(大型物件)中分配。 只有 GC 可以在第 1 代(通過提升第 0 代回收未處理的物件)和第 2 代(通過提升第 1 代和第 2 代回收未處理的物件)中“分配”物件。

觸發垃圾回收後,GC 將尋找存在的物件並將它們壓縮。 但是由於壓縮費用很高,GC 會掃過 LOH,列出沒有被清除的物件列表以供以後重新使用,從而滿足大型物件的分配請求。 相鄰的被清除物件將組成一個自由物件。

.NET Core 和 .NET Framework(從 .NET Framework 4.5.1 開始)包括 System.Runtime.GCSettings.LargeObjectHeapCompactionMode屬性,該屬性可讓使用者指定在下一完整阻止 GC 期間壓縮 LOH。 並且在以後,.NET 可能會自動決定壓縮 LOH。 這就意味著,如果分配了大型物件並希望確保它們不被移動,則應將其固定起來。

圖 1 說明了一種情況,在第一次第 0 代 GC 後 GC 形成了第 1 代,其中 Obj1Obj3 被清除;在第一次第 1 代 GC 後形成了第 2 代,其中 Obj2Obj5 被清除。 請注意此圖和下圖僅用於說明,它們只包含能更好展示堆上的情況的極少幾個物件。 實際上,GC 中通常包含更多的物件。

在這裡插入圖片描述
圖 1:第 0 代和第 1 代 GC。

圖 2 顯示了第 2 代 GC 發現 Obj1Obj2 被清除後,GC 在記憶體中形成了相鄰的可用空間,由 Obj1Obj2 佔用,然後用於滿足 Obj4 的分配要求。 從最後一個物件 Obj3 到此段末尾的空間仍可用於滿足分配請求。

在這裡插入圖片描述
圖 2:第 2 代 GC 之後

如果沒有足夠的可用空間來容納大型物件分配請求,GC 首先嚐試從作業系統獲取更多段。 如果失敗了,它將觸發第 2 代 GC,試圖釋放部分空間。

在第 1 代或第 2 代 GC 期間,垃圾回收器會通過呼叫 VirtualFree 功能將不包含活動物件的段釋放回作業系統。 將退回最後一個活動物件到段末尾的空間(第 0 代/第 1 代存在的短暫段上的空間除外,垃圾回收器會在該段上會儲存部分提交內容,因為應用程式將在其中立即分配)。 而且,儘管已重置可用空間,但仍會提交它們,這意味著作業系統無需將其中的資料重新寫入磁碟。

由於 LOH 僅在第 2 代 GC 期間進行回收,所以 LOH 段僅在此類 GC 期間可用。 圖 3 說明了一種情況,在此情況下,垃圾回收器將某段(段 2)釋放回作業系統並且退回剩餘段上更多的空間。 如果需要使用該段末尾的已退回空間來滿足大型物件分配請求,它會再次提交該記憶體。 (有關提交/退回的解釋說明,請參閱 VirtualAlloc 的文件)。

在這裡插入圖片描述
圖 3:第 2 代 GC 後的 LOH

何時收集大型物件?

通常情況下,出現以下三種情形中的任一情況,都會執行 GC

  • 分配超出第 0 代或大型物件閾值。

    閾值是某代的屬性。 垃圾回收器在其中分配物件時,會為代設定閾值。 超出閾值後,會在該代上觸發 GC。 因此,分配小型或大型物件時,需要分別使用第 0 代和 LOH 的閾值。 當垃圾回收器分配到第 1 代和第 2 代中時,將使用它們的閾值。 執行此程式時,會動態調整這些閾值。

    這是典型情況,大部分 GC 執行都因為託管堆上的分配。

  • 呼叫System.GC.Collect方法。

    如果呼叫無引數System.GC.Collect方法,或另一個過載作為引數傳遞到 System.GC.MaxGeneration,將會一起收集 LOH 和剩餘的託管堆。

  • 系統處於記憶體不足的狀況。

    垃圾回收器收到來自作業系統 的高記憶體通知時,會發生以上情況。 如果垃圾回收器認為執行第 2 代 GC 會有效率,它將觸發第 2 代。

LOH 效能意義

大型物件堆上的分配通過以下幾種方式影響效能。

  • 分配成本。

    CLR 確保清除了它提供的每個新物件的記憶體。 這意味著大型物件的分配成本完全由清理的記憶體(除非觸發了 GC)決定。 如果需要 2 輪才能清除一個位元組,即需要 170,000 輪才能清除最小的大型物件。 清除 2GHz 計算機上 16MB 物件的記憶體大約需要 16ms。 這些成本相當大。

  • 回收成本。

    因為 LOH 和第 2 代一起回收,如果超出了它們之中任何一個的閾值,則觸發第 2 代回收。 如果由於 LOH 觸發第 2 代回收,第 2 代沒有必要在 GC 後變得更小。 如果第 2 代上資料不多,則影響較小。 但是,如果第 2 代很大,則觸發多次第 2 代 GC 可能會產生效能問題。 如果很多大型物件都在非常短暫的基礎上進行分配,並且擁有大型 SOH,則可能會花費太多時間來執行 GC。 除此之外,如果連續分配並且釋放真正的大型物件,那麼分配成本可能會增加。

  • 具有引用型別的陣列元素。

    LOH 上的特大型物件通常是陣列(很少會有非常大的例項物件)。 如果陣列的元素有豐富的引用,則可能產生成本;如果元素沒有豐富的引用,將不會產生此類成本。 如果元素不包含任何引用,則垃圾回收器根本無需處理此陣列。 例如,如果使用陣列儲存二進位制樹中的節點,一種實現方法是按實際節點引用某個節點的左側節點和右側節點:

   class Node
   {
      Data d;
      Node left;
      Node right;
   };

   Node[] binary_tr = new Node [num_nodes];

如果 num_nodes 非常大,則垃圾回收器需要處理每個元素的至少兩個引用。 另一種方法是儲存左側節點和右側節點的索引:

   class Node
   {
      Data d;
      uint left_index;
      uint right_index;
   } ;

不要將左側節點的資料引用為 left.d,而是將其引用為 binary_tr[left_index].d。 而垃圾回收器無需檢視左側節點和右側節點的任何引用。

在這三種因素中,前兩個通常比第三個更重要。 因此,建議分配重複使用的大型物件池,而不是分配臨時大型物件。

收集 LOH 的效能資料

收集特定區域的效能資料之前,應完成以下操作:

  1. 找到應檢視此區域的證據。

  2. 排查你知道的其他區域,確保未發現可解釋上述效能問題的內容。

參閱部落格嘗試找出解決方案之前先了解問題獲取記憶體和 CPU 的基礎知識的詳細資訊。

可使用以下工具來收集 LOH 效能資料:

.NET CLR 記憶體效能計數器

這些效能計數器通常是調查效能問題的第一步(但是推薦使用 ETW 事件)。 通過新增所需計數器配置效能監視器,如圖 4 所示。 與 LOH 相關的是:

  • 第 2 代回收次數

    顯示自程式開始起第 2 代 GC 發生的次數。 此計數器在第 2 代回收結束時遞增(也稱為完整垃圾回收)。 此計數器顯示上次觀測的值。

  • 大型物件堆大小

    以位元組顯示當前大小,包括 LOH 的可用空間。 此計數器在垃圾回收結束時更新,不在每次分配時更新。

檢視效能計數器的常用方法是使用效能監視器 (perfmon.exe)。 使用“新增計數器”可為關注的程式新增感興趣的計數器。 可將效能計數器資料儲存在日誌檔案中,如圖 4 所示。

在這裡插入圖片描述
圖 4:第 2 代 GC 後的 LOH

也可以程式設計方式查詢效能計數器。 大部分人在例行測試過程中都採用此方式進行收集。 如果發現計數器顯示的值不正常,則可以使用其他方法獲得更多詳細資訊以幫助調查。

建議使用 ETW 事件代替效能計數,因為 ETW 提供更豐富的資訊。

ETW

垃圾回收器提供豐富的 ETW 事件集,幫助瞭解堆的工作內容和工作原理。 以下部落格文章演示瞭如何使用 ETW 收集和了解 GC 事件:

若要標識由臨時 LOH 分配造成的過多第 2 代 GC 次數,請檢視 GC 的“觸發原因”列。 有關僅分配臨時大型物件的簡單測試,可使用以下 PerfView 命令列收集 ETW 事件的資訊:

perfview /GCCollectOnly /AcceptEULA /nogui collect

結果類似於以下類容:

在這裡插入圖片描述
圖 5:使用 PerfView 顯示的 ETW 事件

如下所示,所有 GC 都是第 2 代 GC,並且都由 AllocLarge 觸發,這表示分配大型物件會觸發此 GC。 我們知道這些分配是臨時的,因為“LOH 未清理率 %”列顯示為 1%。

可以收集顯示分配這些大寫物件的人員的其他 ETW 事件。 以下命令列:

perfview /GCOnly /AcceptEULA /nogui collect

收集 AllocationTick 事件,大約每 10 萬次分配就會觸發該事件。 換句話說,每次分配大型物件都會觸發事件。 然後可檢視某個 GC 堆分配檢視,該檢視顯示分配大型物件的呼叫堆疊:

在這裡插入圖片描述
圖 6:GC 堆分配檢視

如圖所示,這是從 Main 方法分配大型物件的簡單測試。

偵錯程式

如果只有記憶體轉儲,則需要檢視 LOH 上實際有哪些物件,你可使用 .NET 提供的 SoS 偵錯程式擴充套件來檢視。

此部分提到的除錯命令適用於 Windows 偵錯程式

以下內容顯示了分析 LOH 的示例輸出:

0:003> .loadby sos mscorwks
0:003> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x013e35ec
sdgeneration 1 starts at 0x013e1b6c
generation 2 starts at 0x013e1000
ephemeral segment allocation context: none
segment   begin allocated     size
0018f2d0 790d5588 790f4b38 0x0001f5b0(128432)
013e0000 013e1000 013e35f8 0x000025f8(9720)
Large object heap starts at 0x023e1000
segment   begin allocated     size
023e0000 023e1000 033db630 0x00ffa630(16754224)
033e0000 033e1000 043cdf98 0x00fecf98(16699288)
043e0000 043e1000 05368b58 0x00f87b58(16284504)
Total Size 0x2f90cc8(49876168)
------------------------------
GC Heap Size 0x2f90cc8(49876168)
0:003> !dumpheap -stat 023e1000 033db630
total 133 objects
Statistics:
MT   Count   TotalSize Class Name
001521d0       66     2081792     Free
7912273c       63     6663696 System.Byte[]
7912254c       4     8008736 System.Object[]
Total 133 objects

LOH 堆大小為 (16,754,224 + 16,699,288 + 16,284,504) = 49,738,016 位元組。 在地址 023e1000 和地址 033db630 之間,8,008,736 位元組由 System.Object物件的陣列佔用,6,663,696 位元組由 System.Byte物件的陣列佔用,2,081,792 位元組由可用空間佔用。

有時,偵錯程式顯示 LOH 的總大小少於 85,000 個位元組。 這是由於執行時本身使用 LOH 分配某些小於大型物件的物件引起的。

因為不會壓縮 LOH,有時會懷疑 LOH 是碎片源。 碎片表示:

  • 託管堆的碎片由託管物件之間的可用空間量來表示。 在 SoS 中,!dumpheap –type Free 命令顯示託管物件之間的可用空間量。

  • 虛擬記憶體 (VM) 地址空間的碎片是標識為 MEM_FREE 的記憶體。 可在 windbg 中使用各種偵錯程式命令來獲取碎片。

以下示例顯示 VM 空間中的碎片:

   0:000> !address
   00000000 : 00000000 - 00010000
   Type     00000000
   Protect 00000001 PAGE_NOACCESS
   State   00010000 MEM_FREE
   Usage   RegionUsageFree
   00010000 : 00010000 - 00002000
   Type     00020000 MEM_PRIVATE
   Protect 00000004 PAGE_READWRITE
   State   00001000 MEM_COMMIT
   Usage   RegionUsageEnvironmentBlock
   00012000 : 00012000 - 0000e000
   Type     00000000
   Protect 00000001 PAGE_NOACCESS
   State   00010000 MEM_FREE
   Usage   RegionUsageFree
   … [omitted]
   -------------------- Usage SUMMARY --------------------------
   TotSize (     KB)   Pct(Tots) Pct(Busy)   Usage
   701000 (   7172) : 00.34%   20.69%   : RegionUsageIsVAD
   7de15000 ( 2062420) : 98.35%   00.00%   : RegionUsageFree
   1452000 (   20808) : 00.99%   60.02%   : RegionUsageImage
   300000 (   3072) : 00.15%   08.86%   : RegionUsageStack
   3000 (     12) : 00.00%   00.03%   : RegionUsageTeb
   381000 (   3588) : 00.17%   10.35%   : RegionUsageHeap
   0 (       0) : 00.00%   00.00%   : RegionUsagePageHeap
   1000 (       4) : 00.00%   00.01%   : RegionUsagePeb
   1000 (       4) : 00.00%   00.01%   : RegionUsageProcessParametrs
   2000 (       8) : 00.00%   00.02%   : RegionUsageEnvironmentBlock
   Tot: 7fff0000 (2097088 KB) Busy: 021db000 (34668 KB)

   -------------------- Type SUMMARY --------------------------
   TotSize (     KB)   Pct(Tots) Usage
   7de15000 ( 2062420) : 98.35%   : <free>
   1452000 (   20808) : 00.99%   : MEM_IMAGE
   69f000 (   6780) : 00.32%   : MEM_MAPPED
   6ea000 (   7080) : 00.34%   : MEM_PRIVATE

   -------------------- State SUMMARY --------------------------
   TotSize (     KB)   Pct(Tots) Usage
   1a58000 (   26976) : 01.29%   : MEM_COMMIT
   7de15000 ( 2062420) : 98.35%   : MEM_FREE
   783000 (   7692) : 00.37%   : MEM_RESERVE

   Largest free region: Base 01432000 - Size 707ee000 (1843128 KB)

通常看到的更多是由臨時大型物件導致的 VM 碎片,這些物件要求垃圾回收器頻繁從作業系統獲取新的託管堆段,並將空託管堆段釋放回作業系統。

要驗證 LOH 是否會生成 VM 碎片,可在 VirtualAllocVirtualFree 上設定一個斷點,檢視是誰呼叫了它們。 例如,如果想知道誰曾嘗試從作業系統分配大於 8MBB 的虛擬記憶體塊,可按以下方式設定斷點:

bp kernel32!virtualalloc "j (dwo(@esp+8)>800000) 'kb';'g'"

只有在分配大小大於 8MB (0x800000) 的情況下呼叫 VirtualAlloc 時,此命令才會進入偵錯程式並顯示呼叫堆疊。

CLR 2.0 增加了稱為“VM 囤積”的功能,用於頻繁獲取和釋放段(包括在大型和小型物件堆上)的情況。 若要指定 VM 囤積,可通過託管 API 指定稱為 STARTUP_HOARD_GC_VM 的啟動標記。 CLR 退回這些段上的記憶體並將其新增到備用列表中,而不會將該空段釋放回作業系統。 (請注意 CLR 不會針對太大型的段執行此操作。)CLR 稍後將使用這些段來滿足新段請求。 下一次應用需要新段時,CLR 將使用此備用列表中的某個足夠大的段。

VM 囤積還可用於想要儲存已獲取的段的應用程式(例如屬於系統上執行的主要應用的部分伺服器應用),以避免記憶體不足的異常。

強烈建議你在使用此功能時認真測試應用程式,以確保應用程式的記憶體使用情況比較穩定。

相關文章