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 代垃圾回收”而不是“完整垃圾回收”,但它 們可以互換。
垃圾回收器堆的各代是按邏輯劃分的。實際上,對象存在於托管堆棧段上。托管堆 棧段是垃圾回收器通過調用 VirtualAlloc 代表托管代碼在操作系統上保留的內存塊。加載 CLR 時,將 分配兩個初始堆棧段(一個用於小型對象,另一個用於大型對象),我將它們分別稱為小型對象堆 (SOH) 和大型對象堆 (LOH)。
然後,通過將托管對象置於任一托管堆棧段上來滿足分配請求。如果對象 小於 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 分配和垃圾回收
如果沒有足夠的可用空間來容納大型對象分配請求,我會先嘗試從操作系統獲取 更多段。如果失敗,我將觸發第 2 代垃圾回收以便釋放一些空間。
在第 2 代垃圾回收期間,我 會把握時機將不包含任何活動對象的段釋放回操作系統(通過調用 VirtualFree)。從最後一個存在的對 象到該段末尾的內存將退回。而且,盡管已重置可用空間,但仍會提交它們,這意味著操作系統無需將其 中的數據重新寫入磁盤。圖 3 說明了一種情況,我將一個段(段 2)釋放回操作系統,並在剩下的段中 退回了更多空間。如果需要使用該段末尾的已退回空間來滿足新的大型對象分配請求,我可以再次提交該 內存。
圖 3 垃圾回收期間在 LOH 上釋放的已消除段
有關提交/退回的說明,請參閱有關 VirtualAlloc 的 MSDN® 文檔,網址為 go.microsoft.com/fwlink/?LinkId=116041。
何時回收大型對象
要確定何時回收大型對象,我們首先討論一下通常何時會執行垃圾回收。如 果發生下列情況之一,將執行垃圾回收:
分配超出第 0 代或大型對象阈值 大部分 GC 都是由於 需在托管堆上進行分配而執行(這是最典型的情況)。
調用 System.GC.Collect 如果對第 2 代 調用 GC.Collect(通過不向 GC.Collect 傳遞參數或將 GC.MaxGeneration 作為參數傳遞),將立即回 收 LOH 及其他托管堆。
系統內存太低 收到來自操作系統的高內存通知時會發生此情況。如果我 認為執行第 2 代垃圾回收會有所幫助,就會觸發一個垃圾回收。
阈值是各代的屬性。將對象分配 給某代時,會增加該代的內存量,使之接近該代的阈值。當超出某代的阈值時,便會在該代觸發垃圾回收 。因此,當您分配小型或大型對象時,需要分別使用第 0 代和 LOH 的阈值。當垃圾回收器分配到第 1 代和第 2 代中時,將使用第 1 代的阈值。運行此程序時,會動態調整這些阈值。
LOH 性能意義
下面,我們來看一下分配成本。CLR 確保清除了我提供的每個新對象的內存。這意味著大型對象 的分配成本完全由清理的內存(除非觸發了垃圾回收)決定。如果需要兩輪才能清除 1 個字節,則意味 著需要 170,000 輪才能清除最小的大型對象。這對於分配較大的大型對象的人們來說很平常。對於 2GHz 計算機上的 16MB 對象,大約需要 16ms 才能清除內存。這些成本相當大。
現在我們來看一下回 收成本。前面曾提到,LOH 和第 2 代將一起回收。如果超過兩者中任何一個的阈值,都會觸發第 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 的基礎知識。另外,2006 年 11 月期刊中的“CLR 全面透徹解 析”針對內存問題進行了調查,介紹了在托管過程中診斷可能與托管堆相關的性能問題涉及的步驟 (請參見 msdn2.microsoft.com/magazine/cc163528)。
.NET CLR 內存性能計數器通常是調查性能問題的第一步。與 LOH 相關的計數器顯示第 2 代回收的數 目和大型對象堆的大小。第 2 代回收的數目顯示了自回收過程開始執行第 2 代垃圾回收的次數。計數器 會在第 2 代垃圾回收(也稱為完整垃圾回收)結束時遞增。此計數器顯示最後看到的值。
大型對 象堆大小指的是大型對象堆的當前大小(以字節為單位,包括可用空間)。此計數器將在垃圾回收結束時 更新,而不是在每次分配時更新。
查看性能計數器的常用方法是使用性能監視器 (PerfMon.exe) 。使用“添加計數器”可為您關注的過程添加感興趣的計數器,如圖 4 所示。
圖 4 在性能監視器 中添加計數器
您可以將性能計數器數據保存在性能監視器的日志文件中,也可以編程方式查詢性 能計數器。大部分人在例行測試過程中都采用此方式進行收集。如果發現計數器顯示的值不正常,則可以 使用其他方法獲得更多詳細信息以幫助調查。
使用調試器
在開始之前,請注意我此部分提 及的調試命令僅適用於 Windows® 調試器。如果需要查看 LOH 上實際存在的對象,您可以使用 CLR 提供的 SoS 調試器擴展,在前面提到的 2006 年 11 月期刊中已對此進行了介紹。圖 5 中顯示了分析 LOH 的輸出示例。
圖 5 中的加粗部分顯示 LOH 堆的大小為 (16,754,224 + 16,699,288 + 16,284,504 =) 49,738,016 個字節。而在 023e1000 和 033db630 之間,System.Object[] 對象占用了 8,008,736 個字節;System.Byte[] 對象占用了 6,663,696 個字節;可用空間占用了 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 是碎片源。事實上,在得出這個結論前,您最好先弄清什麼是碎片。有一種托管堆碎片,由托管 對象之間的可用空間量指示(換句話說,在 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)
前面曾提到,托管堆上的碎片用於分配請求。通常看到的更多是由臨時大型對象導致的虛擬內存碎片 ,需要頻繁進行垃圾回收以便從操作系統獲取新的托管堆段,並將空托管堆段釋放回操作系統。
要驗證 LOH 是否會生成 VM 碎片,可在 VirtualAlloc 和 VirtualFree 上設置一個斷點,查看是誰 調用了它們。例如,如果想知道誰曾嘗試從操作系統分配大於 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)。指定此 標志後,只會退回這些段上的內存並將其添加到備用列表中,而不會將該空段釋放回操作系統。備用列表 上的段以後可用於滿足新的段請求。因此,下次需要新段時,如果可以從此備用列表找到足夠大的段,便 可以使用它。
請注意,對於太大的段,該功能不起作用。此功能還可供某些應用程序用以承載其已獲得的段,如一 些服務器應用程序,它們會盡可能避免生成 VM 空間碎片以防出現內存不足錯誤。由於它們通常是計算機 上的主應用程序,所以可以執行這些操作。強烈建議您在使用此功能時認真測試您的應用程序,以確保內 存使用情況比較穩定。
大型對象費用很高。由於 CLR 需要清除一些新分配大型對象的內存,以滿足 CLR 清除所有新分配對 象內存的保證,所以分配成本相當高。LOH 將與堆的其余部分一起回收,所以請仔細分析這會對您的應用 程序性能造成什麼影響。如果可以,建議重新使用大型對象以避免托管堆和 VM 空間中生成碎片。
最後,到目前為止,在回收過程中尚不能壓縮 LOH,但不應依賴於此實現詳情。因此,要確保某些內 容未被 GC 移動,請始終將其固定起來。現在,請利用您剛學到的 LOH 知識對堆進行控制。
請將您的問題和意見發送至 [email protected]。