共用方式為


Garbage Collector Basics and Performance Hints (記憶體回收行程基本概念和效能提示)

 

波多黎各 Mariani
Microsoft Corporation

2003 年 4 月

總結: .NET 垃圾收集行程提供高效能的記憶體佈建服務,且不會有長期片段問題。 本文說明垃圾收集行程的運作方式,然後討論在垃圾收集環境中可能會遇到的一些效能問題。 (10 個列印頁面)

適用於:
   Microsoft® .NET Framework

目錄

簡介
簡化的模型
收集垃圾
效能
完成
結論

簡介

若要瞭解如何善用垃圾收集行程,以及您在垃圾收集環境中執行時可能會遇到的效能問題,請務必瞭解垃圾收集行程的運作方式,以及這些內部工作如何影響執行程式的基本概念。

本文分成兩個部分:首先,我將討論 Common Language Runtime (CLR) 一般使用簡化模型進行垃圾收集行程的本質,然後我會討論該結構的一些效能影響。

簡化的模型

如需說明目的,請考慮下列受管理堆積的簡化模型。 請注意,這不是實際實作的內容。

圖 1. 受控堆積的簡化模型

此簡化模型的規則如下所示:

  • 所有垃圾收集物件都會從一個連續的位址空間範圍配置。
  • 堆積分成層 (稍後) ,以便只查看少量堆積來消除大部分的垃圾。
  • 世代內的物件大致上是相同的年齡。
  • 較高編號的層代表示具有較舊物件的堆積區域,這些物件更可能穩定。
  • 最舊的物件位於最低位址,而新物件則是在增加位址時建立。 (位址在上圖 1 中遞增。)
  • 新物件的配置指標會標示所使用 (配置) 和未使用 (可用) 記憶體區域之間的界限。
  • 定期壓縮堆積的方式是移除死物件,並將即時物件滑動到堆積的低位址結尾。 這會展開圖表底部的未使用區域,在其中建立新物件。
  • 記憶體中的物件順序會維持其建立順序,以取得良好的位置。
  • 堆積中的物件之間永遠不會有任何間距。
  • 只有部分可用空間已 認可。 必要時,** 會從 保留 位址範圍內的作業系統取得更多記憶體。

收集垃圾

最容易瞭解的收集類型是完全壓縮的垃圾收集,因此我一開始會討論這一點。

完整集合

在完整集合中,我們必須停止程式執行,並在 GC 堆積中找到所有 目錄。 這些根會以各種形式出現,但最明顯的是指向堆積的堆疊和全域變數。 從根目錄開始,我們會流覽每個物件,並遵循每個流覽物件中包含的每個物件指標,將物件標示為繼續。 如此一來,收集器就會找到每個 可連線即時 物件。 另一個物件, 即無法連線 的物件現在 會受到回應

圖 2. 根目錄到 GC 堆積

一旦識別出無法連線的物件,我們想要回收該空間以供稍後使用;此時收集器的目標是將 即時 物件向上滑動,並消除浪費的空間。 停止執行後,收集器可以安全地移動所有物件,並修正所有指標,讓所有專案都正確連結在其新位置。 存留物件會升階至下一代號碼 (,也就是世代的界限會更新) 且執行可以繼續。

部分集合

可惜的是,完整垃圾收集每次都太昂貴,因此現在適合討論集合中的世代如何協助我們。

首先,讓我們考慮一個虛構的案例,也就是我們非常幸好的情況。 假設最近有一個完整集合,且堆積已妥善壓縮。 程式執行會繼續,併發生某些配置。 事實上,許多和許多配置都會發生,而且在有足夠的配置之後,記憶體管理系統會決定要收集的時間。

現在,以下是我們得到快樂的地方。 假設自上一個集合以來,我們在所有時間都未寫入任何較舊的物件上,只有新配置的 層代零 (gen0) ,物件已寫入。 如果發生這種情況,我們就會是絕佳的情況,因為我們可以大幅簡化垃圾收集程式。

除了我們一般的完整收集之外,我們可以假設所有較舊的物件 (第1 代,2 代) 仍然存在,或至少足以運作,因此值得查看這些物件。 此外,因為沒有任何一個是 (記住我們有多好?) 較舊的物件沒有指標指向較新的物件。 因此,我們可以看看所有根目錄,以及是否有任何根指向舊物件只會忽略這些根目錄。 對於其他根目錄 (指向 gen0 的根目錄,) 我們會如往常一樣繼續進行,並遵循所有指標。 每當找到回到舊物件的內部指標時,我們會忽略它。

完成該程式時,我們會流覽第0 代中的每個即時物件,而不需要造訪舊世代中的任何物件。 然後,第0 代物件可以像往常一樣進行清理,而我們只會滑動該記憶體區域,讓較舊的物件保持未中斷。

現在,這真的是很好的情況,因為我們知道大部分的死空間可能位於有大量變換的較新物件中。 許多類別會為其傳回值、暫存字串和排序其他公用程式類別建立暫存物件,例如列舉值和 whatnot。 只要查看第0 代,我們就能輕鬆地透過只查看少數物件來回複大部分的無效空間。

可惜的是,我們永遠不會好用這個方法,因為至少有一些較舊的物件會系結至變更,以便指向新的物件。 如果發生這種情況,就不足以只忽略它們。

讓世代能夠使用寫入屏障

若要讓上述演算法實際運作,我們必須知道哪些較舊的物件已經過修改。 為了記住已變更物件的位置,我們使用稱為 卡片資料表的資料結構,並維護 Managed 程式碼編譯器會產生所謂的 寫入屏障 的資料結構。這兩個概念是產生型垃圾收集成功的核心。

卡片資料表可以透過各種方式實作,但最簡單的方式就是將其視為位陣列。 卡片資料表中的每個位都代表堆積上的記憶體範圍,假設是 128 個位元組。 每次程式將物件寫入某個位址時,寫入屏障程式碼必須計算寫入 128 位元組區塊,然後在卡片資料表中設定對應的位。

有了這個機制,我們現在可以重新流覽集合演算法。 如果我們執行第0 代垃圾收集,我們可以使用上述的演算法,忽略舊世代的任何指標,但一旦完成,我們也必須在每個物件中找到每個物件指標,該區塊上標示為已修改卡片資料表中的區塊。 我們必須將那些視為根目錄。 如果我們也考慮這些指標,則我們只會正確地收集 gen0 物件。

如果卡片資料表一律已滿,但實際上,較舊世代的指標很少實際經過修改,所以此方法可大幅節省成本。

效能

現在,我們有一個基本模型來瞭解事情的運作方式,讓我們考慮一些可能會讓其變慢的問題。 這可讓我們瞭解我們應該嘗試避免讓收集器獲得最佳效能的一些專案。

太多配置

這真的是可能發生錯誤的最基本事項。 使用垃圾收集行程配置新的記憶體相當快。 如上圖 2 所示,通常需要發生的一切,通常是讓配置指標移至「已配置」端為新物件建立空間,這不會比這樣快很多。 不過,垃圾收集的發生時間愈快,而且所有專案都相等,最好晚發生。 因此,當您建立新物件時,請務必確定它真的必要且適合這麼做,即使只建立一個物件的速度也很快。

這聽起來可能很明顯,但實際上很容易忘記您撰寫的一小行程式碼可能會觸發許多配置。 例如,假設您撰寫某種比較函式,並假設您的物件具有關鍵詞欄位,而且您希望比較不區分指定順序的關鍵字。 現在在此案例中,您無法只比較整個關鍵字字串,因為第一個關鍵字可能非常短。 使用 String.Split 將關鍵字字串分成幾個片段,然後使用一般不區分大小寫的比較來比較每個片段。 聽起來沒問題嗎?

同樣地,如此一來,就像這樣不是好主意。 您會看到 String.Split 會建立字串陣列,這表示一個新的字串物件,代表您關鍵字字串中原本每個關鍵字的新字串物件,再加上陣列的一個其他物件。 Yikes! 如果我們在排序的內容中執行此動作,這 有許多 比較,而您的雙行比較函式現在會建立非常大量的暫存物件。 突然,垃圾收集行程將代表您非常努力工作,即使是使用最聰明的收集配置,也只需要清除許多垃圾。 最好撰寫完全不需要配置的比較函式。

Too-Large配置

使用傳統配置器時,例如 malloc () ,程式設計人員通常會撰寫程式碼,盡可能呼叫 malloc () ,因為它們知道配置的成本比較高。 這可轉譯為以區塊配置的做法,通常是以推測方式配置我們可能需要的物件,以便我們可以執行較少的總配置。 然後,預先配置的物件會從某種集區手動管理,有效地建立一種高速自訂配置器。

在受控世界中,基於數個原因,此做法較不具吸引力:

首先,執行配置的成本非常低-沒有搜尋免費區塊,如同傳統配置器一樣;所有需要發生的都是免費和配置區域之間的界限需要移動。 低配置成本表示集區最吸引人的原因不存在。

其次,如果您選擇預先配置,當然會比立即需求需要更多的配置,進而強制其他可能不必要的垃圾收集。

最後,垃圾收集行程將無法針對您手動回收的物件回收空間,因為從全域觀點來看,所有這些物件,包括目前未使用的物件仍 會存留。 您可能會發現大量的記憶體會浪費,讓準備好使用,但不會手邊使用中的物件。

這並不表示預先配置一律是錯誤的主意。 例如,您可能想要強制某些物件一開始配置在一起,但您可能會發現它比 Unmanaged 程式碼中的一般策略更不具吸引力。

太多指標

如果您建立的資料結構是大型指標網格,您將有兩個問題。 首先,有許多物件寫入 (請參閱下方的圖 3) ,第二,當收集該資料結構時,您將會讓垃圾收集行程遵循所有這些指標,並視需要變更它們,就像移動一樣。 如果您的資料結構長期存留且不會變更太多,則收集器只需要在第2 代層級) (發生完整集合時流覽所有指標。 但是,如果您以暫時性為基礎建立這類結構,假設是處理交易的一部分,則您會更頻繁地支付成本。

圖 3. 指標中的資料結構繁重

指標中繁重的資料結構也可能會有其他問題,與垃圾收集時間無關。 同樣地,如我們稍早所討論,當物件建立時,它們會依配置順序連續配置。 如果您要建立大型、可能複雜的資料結構,例如從檔案還原資訊,這很適合。 即使您有不同的資料類型,所有物件都會在記憶體中緊密結合,進而協助處理器快速存取這些物件。 不過,隨著時間經過且您的資料結構經過修改,新物件可能需要附加至舊物件。 這些新物件會在稍後建立,因此不會接近記憶體中的原始物件。 即使垃圾收集行程確實壓縮您的記憶體,您的物件也不會在記憶體中四處隨機顯示,它們只會「滑動」一起移除浪費的空間。 產生的雜亂可能會隨著時間而變得不正確,因此您可能會想要為整個資料結構建立全新的複本,全部都已妥善封裝,並讓舊的收集器在適當時間受到收集器所修改。

太多根

垃圾收集行程當然必須在收集時提供根特殊處理,它們一律必須列舉並重複考慮。 第0 代集合只能快速到您未提供根目錄的溢位考慮範圍。 如果您要建立在區域變數中有許多物件指標的深層遞迴函式,結果實際上會相當昂貴。 這項成本不僅在於必須考慮所有這些根目錄,也會產生這些根目錄的額外大量第0 代物件中,這些根根可能會持續運作的時間不長, (下面討論過) 。

太多物件寫入

再次參考我們先前的討論,請記住,每次 Managed 程式修改物件指標時,也會觸發寫入屏障程式碼。 這可能會因為兩個原因而不正確:

首先,寫入屏障的成本可能相當於您第一次嘗試執行之作業的成本。 例如,例如,在某種列舉值類別中執行簡單的作業,您可能會發現您需要在每一個步驟中將一些索引鍵指標從主要集合移至列舉值。 這是您可能想要避免的事項,因為您實際上因為寫入屏障而複製這些指標的成本加倍,而且您可能必須在列舉值上執行一或多次每個迴圈。

其次,如果您實際上在舊物件上寫入,則觸發寫入屏障會加倍。 當您修改較舊的物件時,您可以有效地建立其他根目錄,以檢查下一次垃圾收集發生時,上述) 討論的 (。 如果您修改了足夠的舊物件,您可以有效地否定與收集最新一代相關聯的一般速度改善。

這兩個原因當然會由在任何類型的程式中執行太多寫入的一般原因所補充。 所有專案都相等,最好在讀取或寫入時觸控較少的記憶體 (,事實上) ,以便更符合經濟效益地使用處理器的快取。

太多幾乎長生命週期物件

最後,可能是產生垃圾收集行程的最大陷阱是建立許多物件,這些物件既不完全暫時,也不會完全存留。 這些物件可能會造成許多問題,因為它們不會由第0 代集合清除, (最便宜的) ,因為它們仍然是必要的,而且它們甚至可能會因為第1 代集合仍在使用中而存留,但之後很快就會終止。

問題在於,一旦物件到達第2 代層級,只有完整集合會予以清除,而完整集合就足以讓垃圾收集行程盡可能延遲它們。 因此,有許多「幾乎長時間存留」物件的結果是您的第2 代通常會成長,可能以警示率成長;它可能不會像您想要一樣快地清除,而且清除時,其成本當然會比您可能想要的還要高。

若要避免這些類型的物件,您的最佳防禦線會像這樣:

  1. 盡可能配置較少的物件,因為請注意您正在使用的暫存空間量。
  2. 將較長的物件大小維持在最小。
  3. 盡可能在堆疊上保留最少的物件指標, (這些指標是根目錄) 。

如果您執行這些動作,您的 Gen0 集合可能會更有效率,而第1 代集合不會非常快速成長。 因此,第1 代集合可以較不頻繁地完成,而且當您謹慎地執行 Gen1 集合時,您的中型存留期物件將已經無效,而且可以在該時間復原、成本低。

如果一切順利,則在穩定狀態作業期間,您的第2 代大小完全不會增加!

完成

既然我們已使用簡化的配置模型討論一些主題,我想要稍微複雜一點,讓我們可以討論一個更重要的現象,也就是完成項和最終處理的成本。 簡單來說,完成項可以出現在任何類別中,這是垃圾收集行程承諾在回收該物件的記憶體之前,對其他無效物件進行呼叫的選擇性成員。 在 C# 中,您會使用 ~Class 語法來指定完成項。

最終處理如何影響集合

當垃圾收集行程第一次遇到其他無效但仍然需要完成的物件時,它必須放棄嘗試回收該物件的空間。 物件會改為新增至需要最終化的物件清單,此外,收集器也必須確保物件中的所有指標在完成之前保持有效。 這基本上與說出需要最終處理的每個物件都如同收集器的觀點中的暫時根物件一樣。

集合完成之後,aptly 命名 的最終化執行緒 將會經歷需要最終處理的物件清單,並叫用完成項。 完成此動作時,物件會再次變成無效,而且會以正常方式自然收集。

最終處理和效能

透過對最終處理的基本瞭解,我們已經可以推斷一些非常重要事項:

首先,需要最終處理的物件會比不完成的物件更久。 事實上, 他們可以更久 地生存。 例如,假設第2 代中的物件必須完成。 最終處理將會排程,但物件仍在第2 代中, 因此直到下一代2 集合發生之後才會重新收集。 這確實是很長的時間,事實上,如果一切順利,它 將會 很長一段時間,因為第2 代集合成本很高, 因此我們希望它們 不常發生。 需要最終化的較舊物件可能需要等到數十個,如果不是數百個第0 代集合,才能回收其空間。

其次,需要最終處理的物件會造成附屬損害。 由於內建物件指標必須維持有效狀態,不僅需要直接在記憶體中執行最終化後置的物件,還會直接和間接地參考物件的所有專案保留在記憶體中。 如果物件的大型樹狀結構是由需要最終處理的單一物件錨定,則整個樹狀結構可能會隨著我們剛才討論而持續一段時間。 因此,請務必謹慎使用完成項,並將其放在盡可能少的內建物件指標的物件上。 在剛才提供的樹狀結構範例中,您可以藉由將需要最終化的資源移至個別物件,並將該物件的參考保留在樹狀結構根目錄中,輕鬆地避免問題。 透過該適中變更,只有一個物件 (希望一個良好的小型物件) 會閒置,而最終處理成本會最小化。

最後,需要最終處理的物件會為完成項執行緒建立工作。 如果您的最終處理常式很複雜,其中一個和唯一完成項執行緒會花費許多時間執行這些步驟,這可能會導致待辦工作,因而造成更多物件等待最終處理。 因此,完成項執行的工作越少越重要。 請記住,雖然所有物件指標在完成期間仍然有效,但可能是這些指標會導致已經完成的物件,因此可能較不實用。 即使指標有效,在最終化程式碼中避免遵循物件指標,通常最安全。 安全、簡短的完成程式碼路徑是最佳路徑。

IDisposable 和 Dispose

在許多情況下,一律需要完成的物件,藉由實作 IDisposable 介面來避免該成本。 這個介面提供替代方法,用於回收程式設計人員已知存留期的資源,而且實際上會發生相當多的情況。 當然,如果您的物件只是只使用記憶體,因此完全不需要最終處理或處置,則比較好;但是,如果需要最終處理,而且在許多情況下,明確管理物件是簡單且實用的,則實作 IDisposable 介面是避免或至少降低最終處理成本的絕佳方式。

在 C# 剖析中,此模式相當實用:

class X:  IDisposable
{
   public X(…)
   {
   … initialize resources … 
   }

   ~X()
   {
   … release resources … 
   }

   public void Dispose()
   {
// this is the same as calling ~X()
        Finalize(); 

// no need to finalize later
System.GC.SuppressFinalize(this); 
   }
};

手動呼叫 Dispose 會混淆收集器讓物件保持運作的需求,並呼叫完成項。

結論

.NET 垃圾收集行程提供高速佈建服務,並充分運用記憶體,而且沒有長期分散問題,不過,您可以執行比最佳效能還少的動作。

若要充分利用配置器,您應該考慮下列做法:

  • 配置所有記憶體 (或盡可能) 同時搭配指定資料結構使用。
  • 移除可避免的暫時配置,且複雜度很少。
  • 最小化寫入物件指標的次數,特別是對舊物件所做的寫入。
  • 減少資料結構中指標的密度。
  • 盡可能限制使用完成項,然後只對「分葉」物件使用。 如有必要,請中斷物件,以協助處理此動作。

定期檢閱您的重要資料結構,並使用配置分析工具之類的工具來執行記憶體使用量設定檔,將能讓您的記憶體使用量保持有效,並讓垃圾收集行程最適合您。