深究 CLR
大型物件堆積的面目
Maoni Stephens

目錄
CLR 記憶體回收行程 (GC) 會將物件區分成小型和大型的類別。當物件是大型的,與其相關聯的屬性就會變得比物件是小型時更加重要。例如,要壓縮 (複製記憶體到堆積上的其他地方) 時需要耗費大量資源。在本月的專欄中,我將深入探索大型物件堆積。我會討論什麼條件才能將物件區分為大型物件,如何回收這些大型物件,以及大型物件對效能有何種影響。
大型物件堆積與 GC
在 Microsoft® .NET Framework 1.1 和 2.0 中,如果物件大於或等於 85,000 個位元組,就會視為大型物件。這個數字是為了效能微調的需要所訂定的結果。接收到物件配置要求時,若符合該大小臨界值,它就會配置在大型物件堆積上。這實際上是什麼意思?為了要了解這個概念,讓我們先討論有關 .NET 記憶體回收行程的基本原理。
大多數的人都知道,.NET 記憶體回收行程是層代式的回收行程。它具有三個層代:層代 0、層代 1 以及層代 2。這樣區分的理由,是微調過的應用程式中,您可以預期大多數物件都會在層代 0 結束。例如,在伺服器應用程式中,與每項要求相關聯的配置,都應該在要求完成之後結束。執行中的配置要求則會進入層代 1,並且在該處結束。基本上,層代 1 是扮演年輕物件區域與長期存留物件區域之間的緩衝區。
從層代的角度來看,大型物件屬於層代 2,因為只有在有層代 2 回收時才會回收它們。回收層代時,也會回收所有較年輕的層代。因此,舉例來說,回收層代 1 記憶體時,會同時回收層代 1 和 0。而回收層代 2 記憶體時,就會回收整個堆積。基於這個理由,層代 2 的記憶體回收也稱為完整記憶體回收。在本專欄中,我將使用「層代 2 記憶體回收」,而不使用「完整記憶體回收」一詞,但是兩者是可以互換的。
層代是記憶體回收行程堆積的邏輯檢視。在實體上,物件是存留在 Managed 堆積區段上。Managed 堆積區段是記憶體的區塊,由記憶體回收行程代替 Managed 程式碼向 OS (透過呼叫 VirtualAlloc) 要求保留。載入 CLR 時,會配置兩個初始堆積區段,一個針對小型物件,另一個針對大型物件,這些區段我分別稱之為小型物件堆積 (Small Object Heap,SOH) 以及大型物件堆積 (Large Object Heap,LOH)。
然後藉由將 Managed 物件置於這些 Managed 堆積區段中,就可以滿足配置的要求了。如果物件小於 85,000 個位元組,它就會置於 SOH 區段上;否則就會在 LOH 區段上。隨著愈來愈多的物件配置在區段之上,這些區段就會被認可 (以較小的區塊)。
對於 SOH 來說,通過記憶體回收存留的物件會提升到下一個層代;因此通過層代 0 回收存留的物件就可視為層代 1 的物件,以此類推。然而,通過最老層代存留的物件仍可視為屬於最老的層代。換句話說,來自層代 2 的存留者就是層代 2 的物件;來自 LOH 的存留者就是 LOH 的物件 (用層代 2 來回收)。使用者的程式碼只能配置在層代 0 (小型物件) 或 LOH (大型物件) 中。唯有記憶體回收行程才能「配置」物件於層代 1 (藉由提升來自層代 0 的存留者) 與層代 2 (藉由提升來自層代 1 和 2 的存留者) 中。
觸發記憶體回收時,記憶體回收行程會追蹤所有作用中的物件,並且加以壓縮。對於 LOH 來說,因為壓縮要耗費大量資源,所以 CLR 團隊選擇將其掃除,將無作用物件建立成一份可用清單,稍後再用此清單來滿足大型物件配置的要求。鄰近的無作用物件會結合成為一個可用物件。
要牢記在心的是,即使目前我們尚未壓縮 LOH,我們將來可能會如此。因此如果您配置了大型物件,並且想要確定它們不會更動,那麼您仍然應該將其固定。
請注意,以下所示的圖片僅供說明的目的使用。我使用了很少的物件,只為了展示會在堆積上發生的情況。事實上,在其中會有很多的物件。
[圖 1] 說明的案例,是我在第一個層代 0 GC 之後形成了層代 1,其中 Obj1 和 Obj3 已無作用;以及我在第一個層代 1 GC 之後形成了層代 2,其中 Obj2 和 Obj5 已無作用。
圖 1 SOH 配置與記憶體回收 (按一下影像以放大圖片)
[圖 2] 說明在層代 2 記憶體回收之後 (其中您可以看見 Obj1 和 Obj2 已無作用),我從 Obj1 和 Obj2 原來佔用的記憶體中,形成了單一可用空間,然後再使用此空間來滿足 Obj4 的配置要求。在最後的物件 Obj3 之後,一直到區段結尾的空間,仍然可以用來滿足更多的配置要求。
圖 2 LOH 配置與記憶體回收 (按一下影像以放大圖片)
如果我沒有足夠的可用空間來處理大型物件配置的要求,就會先嘗試從 OS 取得更多區段。如果此舉失敗了,我就會觸發層代 2 的記憶體回收,希望能釋放出一些空間。
在層代 2 記憶體回收期間,我將沒有作用中物件的區段釋放歸還給 OS (藉由呼叫 VirtualFree)。從最後的作用中物件到區段結尾的記憶體會取消認可。可用空間雖然重設過,但仍然是認可的,這表示 OS 並不必將其中的資料寫回磁碟中。[圖 3] 顯示我將區段釋放歸還 OS (區段 2) 並在其餘區段上取消認可更多空間的情況。如果我必須使用位於區段結尾的已取消認可空間,來滿足新的大型物件配置要求,我就會再次認可記憶體。
圖 3 在記憶體回收期間釋放 LOH 上的無作用區段 (按一下影像以放大圖片)
如需認可/取消認可的說明,請參閱有關 VirtualAlloc 的 MSDN
® 文件,網址為
go.microsoft.com/fwlink/?LinkId=116041。
當回收大型物件時
若要判斷回收大型物件的時機,讓我們先討論平常何時會發生記憶體回收的議題。如果下列狀況之一成立,就會發生記憶體回收:
配置超過了層代 0 或大型物件的臨界值大多數 GC 的發生,都是因為 Managed 堆積上的配置 (這是最典型的情況)。
呼叫 System.GC.Collect 在層代 2 上呼叫 GC.Collect (藉由不傳送任何引數給 GC.Collect,或是將 GC.MaxGeneration 作為引數傳送) 時,就會立即將 LOH 與其餘的 Managed 堆積一起回收。
系統處於記憶體不足的狀態從 OS 收到高記憶體的通知時,就會發生這種情況。如果我認為執行層代 2 的 GC 會有幫助,我就會觸發它。
臨界值是每個層代的屬性。當您將物件配置到某個層代,就會增加該層代中記憶體的數量,並且進一步接近層代的臨界值。而且當某層代的臨界值已超過時,該層代上就會觸發記憶體回收。因此當您配置小型或大型物件時,就會耗用層代 0 和 LOH 個別的臨界值。當記憶體回收行程配置成層代 1 和 2 時,就會耗用層代 1 的臨界值。這些臨界值會隨著程式的執行而動態地調整。
LOH 效能的影響
讓我們看看配置的成本。CLR 會保證我送出的每個新物件都已清除。這表示大型物件的配置成本,是完全由記憶體清除來掌控的 (除非它觸發記憶體回收)。如果要花兩個週期的時間才能清除 1 個位元組,這就表示要花 170,000 個週期才能清除最小的大型物件。經常可以看見有人配置過大的大型物件。對於 2GHz 電腦上的 16MB 物件來說,就要花大約 16 毫秒的時間才能清除記憶體。這是相當大的成本。
現在我們來看看回收的成本。如前所述,LOH 與層代 2 會一起回收。如果超過了其中一項的臨界值,就會觸發層代 2 回收。如果因為 LOH 而觸發了層代 2,那麼在記憶體回收之後,層代 2 本身就不一定會變小。如果在層代 2 上沒有太多資料,這就不成問題。但是如果層代 2 很大,一旦觸發許多層代 2 記憶體回收,就可能會造成效能的問題。如果暫時地配置了許多大型物件,而且您有大的 SOH 的話,就可能會花太長的時間來執行記憶體回收;而且如果您繼續配置並釋放相當大的物件,配置的成本就會暴增。
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;
};
如此一來,您就可以用 binary_tr[left_index].d 來參考左邊節點的資料,而不用 left.d 來參考它,而且記憶體回收行程就不必查看左邊和右邊節點的任何參考。
在進行回收的三個理由之中,前兩個通常比第三個更為重要。因此,如果您可以配置大型物件的集區並重複使用它們,而不要配置暫時的物件,這樣做最好。Yun Jin 在其部落格文章中,有這種緩衝區集區的好例子 (
go.microsoft.com/fwlink/?LinkId=115870)。當然,您應該要將緩衝區的大小設定大一點。
收集 LOH 的效能資料
有一些方式可用來收集與 LOH 相關的效能資料。但是在說明它們之前,讓我們討論一下為什麼需要這麼做。
您在開始收集特定區域的效能資料之前,應該要有足夠的理由讓您查看這個區域,或是您已查看過所知道的其他區域,但未能發現任何導致所觀察效能問題的原因。
建議您閱讀我的部落格文章,以取得詳細說明 (請參閱
go.microsoft.com/fwlink/?LinkId=116467)。我在其中討論了記憶體和 CPU 的基本原理。.此外,《深究 CLR》的 2006 年 11 月號專欄也有探討記憶體問題,其中討論到診斷 Managed 處理序效能問題時的步驟,有哪些可能與 Managed 堆積有關 (請參閱
msdn2.microsoft.com/magazine/cc163528)。
在調查效能問題時,.NET CLR 記憶體效能計數器通常都是很好的起點。與 LOH 相關的計數器包括層代 2 回收的數目以及大型物件堆積的大小。層代 2 回收的數目會顯示出自處理序開始起,發生過層代 2 記憶體回收的次數。層代 2 記憶體回收 (亦稱為完整的記憶體回收) 結束時,會遞增此計數器。此計數器會顯示最後一次記錄的值。
大型物件堆積的大小是以位元組為單位的目前大小,其中包含大型物件堆積的可用空間。此計數器是在記憶體回收結束時更新,而不是在每次配置的時候。
查看效能計數器的常用方式,就是透過效能監視器 (PerfMon.exe)。請使用 [新增計數器],為您關心的處理序新增有意義的計數器,如 [圖 4] 所示。
圖 4 在效能監視器中新增計數器 (按一下影像以放大圖片)
您可以在 PerfMon 中,將效能計數器資料儲存到記錄檔中。效能計數器也可以用程式設計的方式來查詢。許多人都會使用這種方式在日常測試程序中收集相關資訊。當您發覺到計數器有不正常的數值時,就可以使用其他方式取得詳細資訊,來協助進行調查。
使用偵錯工具
在我開始說明之前,您應該注意我在本區段中將提及的偵錯命令,都適用於 Windows® 偵錯工具。如果您需要查看哪些物件實際有在 LOH 上,可以使用 CLR 所提供的 SoS 偵錯工具延伸功能,這在我先前所提到的 2006 年 11 月號的專欄中說明過。分析 LOH 的範例輸出顯示於 [圖 5] 中。
[圖 5] 的粗體區段表示 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 個位元組是可用空間。

圖 5 LOH 輸出
0:003> .loadby sos mscorwks
0:003> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x013e35ec
generation 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 總共的大小是小於 85,000 個位元組。為什麼會這樣?這是因為執行階段本身會使用 LOH 來配置一些比大型物件小的物件。
因為 LOH 並未壓縮,所以有時候 LOH 會被懷疑是造成分散片段的來源。您真的必須先釐清分散片段的意義。Managed 堆積具有分散片段,這可由 Managed 物件之間的可用空間數量來表示 (換句話說,就是當您在 SoS 中執行 !dumpheap –type Free 時所看見的);虛擬記憶體 (VM) 位址空間也會有分散片段,這就是標示為
MEM_FREE 的記憶體,您也可以使用 windbg 中的各種偵錯工具命令來檢視 (請參閱
go.microsoft.com/fwlink/?LinkId=116470)。
[圖 6] 顯示虛擬記憶體空間中的分散片段 (請注意圖中的粗體文字)。

圖 6 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)
如前所述,Managed 堆積上的分散片段會用於配置要求。經常可以看到虛擬記憶體的分散片段,這是由需要經常進行記憶體回收的暫時性大型物件所造成的,因為這樣才能從 OS 取得新的 Managed 堆積區段,並將空的區段釋放歸還給 OS。
若要驗證 LOH 是否會造成 VM 分散片段,您可以在 VirtualAlloc 和 VirtualFree 上設定中斷點,再查看何者會呼叫它們。例如,若要查看何者會嘗試從 OS 配置大於 8MB 的 VM 區塊,我就可以設定中斷點如下:
bp kernel32!virtualalloc "j (dwo(@esp+8)>800000) 'kb';'g'"
如此一來,在呼叫 VirtualAlloc 時,如果配置的大小大於 8MB (0x800000),就會中斷並進入偵錯工具並顯示呼叫堆疊,否則就不會中斷並進入偵錯工具。
在 CLR 2.0 中,我們新增了稱為 VM Hoarding 的功能,如果您處於經常會取得再釋放區段 (包括大型與小型物件堆積的區段) 的情況,就可能適用此功能。若要指定 VM Hoarding,您可以透過裝載 API 來指定稱為 STARTUP_HOARD_GC_VM 的啟動旗標 (請參閱
go.microsoft.com/fwlink/?LinkId=116471)。當您指定這個旗標時,這些區段上的記憶體只會加以取消認可並放置於待命清單上,而不會將空的區段釋放歸還給 OS。待命清單上的區段以後會用來滿足新區段的要求。因此,下次當我需要新的區段時,如果可以找到夠大的區段,就會先使用此待命清單中的區段。
請注意,對於太大的區段來說並不會這麼做。對於想要保存已取得區段的應用程式 (例如想要盡可能避免 VM 空間有分散片段,以免造成記憶體不足而發生錯誤的伺服器應用程式) 來說,這項功能也很有用。而且它們通常可以這麼做,因為它們通常都是電腦上具掌控權的應用程式。我強烈建議您在使用這項功能時要仔細測試應用程式,以確保記憶體的使用情形穩定。
大型物件很耗費資源。配置的成本很高,因為 CLR 必須清除新配置之大型物件所佔用的記憶體,CLR 才能保證所有新配置物件所佔用的記憶體皆已清除。LOH 會與其餘的堆積一起回收,因此請仔細分析它會如何影響您應用程式的效能。建議您盡可能重複使用大型物件,以避免在 Managed 堆積和 VM 空間上造成分散片段。
最後要提的是,目前 LOH 尚未在回收時進行壓縮,但是您不應該依賴這項實作細節。因此,若要確定某項目不會被 GC 更動,請一律固定它。現在您可以帶著新發現的 LOH 知識,輕鬆掌控堆積的運用。