共用方式為


Windows 系統上的大型物件堆積

.NET 垃圾收集器(GC)會將物件分為小型和大型物件。 當物體很大時,其某些屬性會比物體很小時顯得更為重要。 例如,壓縮它,也就是在堆積上其他地方的記憶體中複製它可能很昂貴。 因此,垃圾收集器會將大型物件放入大型物件堆(LOH)。 本文將討論哪些物件限定為大型物件、收集大型物件的方式,以及大型物件所施加的效能影響。

這很重要

本文討論只在 Windows 系統上執行的 .NET Framework 和 .NET Core 中的大型物件堆積。 它未涵蓋在其他平臺上的 .NET 實作上執行的LOH。

如何一個物件最終出現在 LOH 上

如果物件的大小大於或等於85,000個字節,則會被視為大型物件。 此數目是由效能微調所決定。 當物件配置要求為85,000個以上的位元組時,運行時間會在大型物件堆積上配置它。

要理解這個意思,了解一些關於垃圾收集器的基本概念是很有幫助的。

垃圾收集器是世代收集器。 它有三代人:第0代、第1代和第2代。 有三代人的原因是,在微調良好的應用程式中,大部分的對象都會在 gen0 中死亡。 例如,在伺服器應用程式中,與每個要求相關聯的配置應該會在要求完成之後死亡。 在處理中的配置請求將被放入 gen1,並在那裡結束。 基本上,gen1 會充當短期物件區域與長期物件區域之間的緩衝區。

新配置的 物件會形成新一代的對象,並且隱含地產生0個集合。 不過,如果它們是大型物件,則會移至大型物件堆(LOH),這有時也稱為第 3 代。 第 3 代是邏輯上收集為層代 2 一部分的實體世代。

大型物件屬於層代 2,因為它們只會在層代 2 集合期間收集。 收集一代人時,也會收集所有年輕一代。 例如,當第 1 代 GC 發生時,會收集第 1 代和 0。 當第 2 代 GC 發生時,會回收整個堆。 因此,第 2 代 GC 也稱為 完整 GC。 本文指的是第 2 代 GC,而不是完整的 GC,但詞彙是可互換的。

世代提供 GC 堆積的邏輯檢視。 實際上,物件會存在於管理堆區段中。 受控堆積區段是 GC 代表管理程式碼從 OS 呼叫 VirtualAlloc 函式 所保留的記憶體區塊。 載入 CLR 時,GC 會配置兩個初始堆積區段:一個用於小型物件(小型物件堆積或 SOH),另一個用於大型物件(大型物件堆積)。

然後,通過將受控物件放置在這些受控堆積區段上來滿足配置要求。 如果物件小於85,000個字節,則會放在SOH的區段上;否則,它會放在 LOH 區段上。 區段會被提交(以較小的區塊),隨著越來越多的物件配置到它們上。 針對SOH,在 GC 中倖存的物件會升階為下一代。 在層代 0 集合中倖存的物件現在會被視為層代 1 物件等等。 不過,在最舊世代中倖存的物件仍被視為最舊的一代。 換句話說,第 2 代的倖存者是第 2 代物件:LOH 的倖存者是LOH物件(以 gen2 收集)。

用戶程式代碼只能在層代 0(小型物件)或 LOH 中配置 (大型物件)。 只有 GC 可以在第 1 代「分配」物件(藉由從第 0 代提升倖存者)和第 2 代(藉由從第 1 代提升倖存者)。

觸發垃圾收集時,GC 會追蹤存活物件並壓縮它們。 但由於壓縮成本昂貴,GC 會掃蕩 LOH:它會從無效的物件中建立一份免費清單,以供稍後重複使用,以滿足大型物件配置要求。 相鄰的死物件會變成一個自由物件。

.NET Core 和 .NET Framework (從 .NET Framework 4.5.1 開始)包含 GCSettings.LargeObjectHeapCompactionMode 屬性,可讓使用者指定在下一個完整封鎖 GC 期間應壓縮 LOH。 未來,.NET 可能會決定自動壓縮 LOH。 這表示,如果您分配大型物件而想確保它們不會移動,您仍應固定它們。

圖 1 說明 GC 在第一次第 0 代 GC 後形成第 1 代,此時 Obj1Obj3 已死亡;並在第一次第 1 代 GC 後形成第 2 代,此時 Obj2Obj5 已死亡。 請注意,這和下列圖例僅供說明之用,它們包含非常少數物件,以便更清楚顯示在堆上發生的情況。 事實上,通常有更多的物件參與垃圾回收 (GC) 過程。

圖 1:第 0 代 GC 和第 1 代 GC
圖 1:第 0 代和第 1 代 GC。

圖 2 顯示,在第 2 代 GC 發現 Obj1Obj2 已經被回收後,GC 會將原本由 Obj1Obj2 所佔用的記憶體整合成連續的可用空間,然後用來滿足 Obj4 的配置要求。 最後一個 對象之後的空間, Obj3到區段的結尾也可以用來滿足配置要求。

圖 2:第 2 代 GC 之後
圖 2:第 2 代 GC 之後

如果沒有足夠的可用空間來容納大型物件配置要求,GC 會先嘗試從OS取得更多區段。 如果失敗,它會觸發第 2 代 GC,希望釋放一些空間。

在第 1 代或第 2 代 GC 期間,垃圾收集器會藉由呼叫 VirtualFree 函式,將沒有任何活動物件的區段釋放回作業系統(OS)。 在區段結尾的最後一個存活對象之後的空間已被釋放(除了 gen0/gen1 所在的暫存型區段之外,垃圾收集器會保留一些空間已認可,因為您的應用程式會立即在其上進行配置)。 雖然已重設,但可用空間仍會繼續認可,這表示OS不需要將數據寫入磁碟。

由於 LOH 只會在層代 2 DC 期間收集,因此 LOH 區段只能在這類 GC 期間釋放。 圖 3 說明垃圾回收器將一個區段(區段 2)釋放回 OS 的情況,並在剩餘區段上解除更多空間的分配。 如果需要使用區段結尾的已釋放空間來滿足大型物件配置要求,則會再次認可記憶體。 如需瞭解提交/釋放的說明,請參閱 「VirtualAlloc」 的文件。

圖 3:第 2 代 GC 之後的 LOH
圖 3:第 2 代 GC 之後的 LOH

何時收集大型物件?

一般而言,GC 會在下列三個條件下發生:

  • 分配超過第 0 代或大型物件的門檻。

    臨界值是世代的屬性。 當垃圾收集器將物件配置到特定代數時,就會設定此代的門檻值。 超過臨界值時,就會在該層層觸發 GC。 當您配置小型或大型物件時,會分別取用層代 0 和 LOH 的臨界值。 當垃圾收集器配置至第 1 代和第 2 代時,它會占用它們的臨界值。 這些臨界值會在程序執行時動態調整。

    這是典型的案例:大部分的DC都是因為受控堆積上的配置而發生。

  • 呼叫了 GC.Collect 方法。

    如果呼叫無 GC.Collect() 參數方法或傳遞 GC.MaxGeneration 另一個多載做為自變數,則會收集LOH以及Managed堆積的其餘部分。

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

    當垃圾收集器收到來自操作系統的高記憶體通知時,就會發生這種情況。 如果垃圾收集器認為執行第 2 代 GC 會有效益,則會觸發一次收集行程。

LOH 效能影響

大型物件堆的配置會以下列方式影響效能。

  • 配置成本。

    CLR 保證將它所提供的每個新物件的記憶體清除乾淨。 這表示大型物件的配置成本是由記憶體清除所主導(除非它觸發 GC)。 如果需要兩個迴圈才能清除一個字節,則需要 170,000 個迴圈才能清除最小的大型物件。 清除 2-GHz 計算機上 16 MB 物件的記憶體大約需要 16 毫秒。 這是一個相當大的成本。

  • 收集成本。

    由於 LOH 和層代 2 會一起收集,如果超過其中一個閾值,就會觸發第 2 代集合。 如果因為 LOH 而觸發第 2 代集合,則第 2 代在 GC 之後不一定小得多。 如果第 2 代的數據不多,則影響最小。 但是,如果第 2 代很大,並觸發許多第 2 代 GC,這可能會導致效能問題。 如果許多大型物件是暫時配置的,而且您有很大的SOH,那麼,您可能會花太多時間在執行GCs上。 此外,如果您持續配置和釋放大型物件,配置成本可能會真正累積。

  • 具有參考型別的陣列元素。

    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 記憶體性能計數器

.NET CLR 記憶體性能計數器通常是調查效能問題的良好第一步(雖然我們建議您使用 ETW 事件)。 查看性能計數器的常見方式是性能監視器(perfmon.exe)。 選取 [新增 ] [Ctrl + A],為您關心的進程新增有趣的計數器。 您可以將性能計數器資料儲存至記錄檔。

.NET CLR 記憶體類別中的下列兩個計數器與 LOH 相關:

  • # 第二代收藏

    顯示自進程啟動以來發生第 2 代 GC 的次數。 計數器會在第 2 代回收結束時遞增(也稱為完整垃圾回收)。 此計數器會顯示最後觀察到的值。

  • 大型物件堆積大小

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

顯示性能監視器中新增計數器的螢幕快照。

您也可以使用 PerformanceCounter 類別,以程式設計方式查詢性能計數器。 針對 LOH,將 “.NET CLR Memory” 指定為 CategoryName ,並將 “Large Object Heap size” 指定為 CounterName

PerformanceCounter performanceCounter = new()
{
    CategoryName = ".NET CLR Memory",
    CounterName = "Large Object Heap size",
    InstanceName = "<instance_name>"
};

Console.WriteLine(performanceCounter.NextValue());

在例行測試流程中,透過程式設計自動化收集計數器是很常見的做法。 當您找出具有非一般值的計數器時,請使用其他方法來取得更詳細的數據,以協助調查。

備註

建議您使用 ETW 事件,而不是性能計數器,因為 ETW 提供更豐富的資訊。

ETW 事件

垃圾收集器提供了豐富的 ETW 事件,以協助您了解堆積在做什麼以及為什麼這樣做。 下列部落格文章示範如何使用 ETW 收集和瞭解 GC 事件:

若要識別由暫時的 LOH 分配所造成的超額第 2 代 GC,請查看 GC 的 [觸發原因] 資料行。 針對只配置暫存大型物件的簡單測試,您可以使用下列 PerfView 命令收集 ETW 事件的相關信息:

perfview /GCCollectOnly /AcceptEULA /nogui collect

結果如下所示:

顯示 PerfView 中 ETW 事件的螢幕快照。

如您所見,所有 GCs 都是第 2 代 GCs,而且全都是由 AllocLarge 觸發,這表示配置大型物件觸發此 GC。 我們知道這些分配是暫時的,因為 LOH存活率 % 欄位顯示為1%。

您可以收集其他 ETW 事件,告知誰配置了這些大型物件。 下列命令行:

perfview /GCOnly /AcceptEULA /nogui collect

會收集AllocationTick事件,大約每10萬個配置會引發一次。 換句話說,每次配置大尺寸物件時都會觸發事件。 然後,您可以查看一個 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.ObjectSystem.Byte佔用 6,663,696 個字節,可用空間佔用 2,081,792 個字節。

有時候,調試程式會顯示LOH的總大小小於85,000個字節。 這是因為運行時間本身會使用LOH來配置一些小於大型物件的物件。

因為 LOH 未壓縮,所以有時候 LOH 會被視為片段的來源。 碎片化的意思是:

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

  • 虛擬記憶體 (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 上設定斷點,以查看它們是被誰呼叫的。 例如,若要查看誰嘗試從OS配置大於8 MB的虛擬記憶體區塊,您可以設定如下的斷點:

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

此命令會中斷至調試程式,只有在 呼叫 VirtualAlloc 且配置大小大於 8 MB 時,才會顯示呼叫堆疊(0x800000)。

CLR 2.0 新增了稱為 VM Hoarding 的功能,對於經常取得和釋放區段(包括大型和小型物件堆積上的區段)的案例很有用。 若要指定 VM Hoarding,您需要在裝載 API 中指定名為 STARTUP_HOARD_GC_VM 的啟動旗標。 CLR 會取消認可這些區段上的記憶體,並將它們放在待命清單中,而不是將空白區段釋放回 OS。 (請注意,CLR 不會針對太大的區段執行此動作。CLR 稍後會使用這些區段來滿足新的區段要求。 下次您的應用程式需要新的區段時,如果CLR可以找到足夠大的區段,CLR就會使用此待命清單中的一個區段。

VM 囤積也適用於想要保留已取得的區段的應用程式,例如某些在系統上執行的主要應用程式的伺服器應用程式,以避免記憶體不足例外狀況。

強烈建議您在使用這項功能時仔細測試應用程式,以確保應用程式具有相當穩定的記憶體使用量。