一、基礎
首先,為了深入瞭解垃圾回收(GC),我們要了解一些基礎知識:
CLR
:Common Language Runtime,即公共語言執行時,是一個可由多種面向CLR的程式語言使用的“執行時”,包括記憶體管理、程式集載入、安全性、異常處理和執行緒同步等核心功能。- 託管程式中的兩種記憶體堆:
託管堆
:CLR維護的用於管理引用型別物件的堆,在程式初始化時,由CLR劃出一個地址空間區域作為託管堆。當區域被非垃圾物件填滿後,CLR會分配更多的區域,直到整個程式地址空間(受程式的虛擬地址空間限制,32位程式最多分配1.5GB,而64位最多可分配8TB)被填滿。本機堆
:由名為VirtualAlloc的Windows API分配的,用於非託管程式碼所需的記憶體。
NextObjPtr
:CLR維護的一個指標,指向下一個物件在堆中的分配位置。初始為地址空間區域的基地址。- CLR將物件分為大物件和小物件,兩者分配的地址空間區域不同。我們下方的講解更關注小物件。
大物件
:大於等於85000位元組的物件。“85000”並非常數,未來可能會更改。小物件
:小於85000位元組 的物件。
然後明確幾個前提:
- CLR要求所有引用型別物件都從託管堆分配。
- C#是執行於CLR之上的。
C#new
一個新物件時,CLR會執行以下操作:
- 計算型別的欄位(包括從基類繼承的欄位)所需的位元組數。
- 加上物件開銷所需的位元組數。每個物件都有兩個開銷欄位:型別物件指標和同步塊索引,32位程式為8位元組,64位程式為16位元組。
- CLR檢查託管堆是否有足夠的可用空間,如果有,則將物件放入
NextObjPtr
指向的地址,並將物件分配的位元組清零。接著呼叫構造器,物件引用返回之前,NextObjPtr
加上物件真正佔用的位元組數得到下一個物件的分配位置。
弄清楚以上知識點後,我們繼續來了解CLR是如何進行“垃圾回收”的。
二、垃圾回收的流程
我們先來看垃圾回收的演算法與主要流程:
演算法:引用跟蹤演算法
。因為只有引用型別的變數才能引用堆上的物件,所以該演算法只關心引用型別的變數,我們將所有引用型別的變數稱為根
。
主要流程:
1.首先,CLR暫停程式中的所有執行緒。防止執行緒在CLR檢查期間訪問物件並更改其狀態。
2.然後,CLR進入GC的標記階段。
a. CLR遍歷堆中的物件(實際上是某些代的物件,這裡可以先認為是所有物件),將同步塊索引欄位中的一位設為0,表示物件是不可達
的,要被刪除。
b. CLR遍歷所有根
,將所引用物件的同步塊索引位設為1,表示物件是可達
的,要保留。
3.接著,CLR進入GC的碎片整理階段。
a. 將可達物件壓縮到連續的記憶體空間(大物件堆的物件不會被壓縮)
b. 重新計算根
所引用物件的地址。
4.最後,NextObjPtr
指標指向最後一個可達物件之後的位置,恢復應用程式的所有執行緒。
三、垃圾回收的具體細節
CLR的GC是基於代的垃圾回收器
,它假設:
- 物件越新,生存期越短
- 物件越老,生存期越長
- 回收堆的一部分,速度快於回收整個堆
託管堆最多支援三代物件:
- 第0代物件:新構造的未被GC檢查過的物件
- 第1代物件:被GC檢查過1次且保留下來的物件
- 第2代物件:被GC檢查大於等於2次且保留下來的物件
第0代回收只會回收第0代物件,第1代回收則會回收第0代和第1代物件,而第2代回收表示完全回收,會回收所有物件。
CLR初始化時,會為第0代和第1代物件選擇一個預算容量(單位:KB)。如下圖,CLR為ABCD四個第0代物件分配了空間,如果建立一個新的物件導致第0代容量超過預算時,CLR會進行GC。
A0 | B0 | C0(不可達) | D0 |
---|
GC後的堆如下圖,ABD三個物件提升為第1代物件,此時無第0代物件
A1 | B1 | D1 |
---|
假設程式繼續執行到某個時刻時,託管堆如下,其中FGHIJ為第0代物件
A1 | B1 | D1(不可達) | F0 | G0(不可達) | H0 | I0 | J0 |
---|
根據GC假設的前兩條可知,它會優先檢查第0代物件,那麼GC第0代回收後的託管堆如下,FHIJ提升為第1代物件
A1 | B1 | D1(不可達) | F1 | H1 | I1 | J1 |
---|
隨著第1代的增加,GC會發現其佔用了太多記憶體,所以會同時檢查第0代和第1代物件,如某個時刻的託管堆如下,其中K為第0代物件
A1 | B1 | D1(不可達) | F1 | H1(不可達) | I1 | J1 | K0 |
---|
GC第1代回收後的託管堆如下,其中ABFIJ都為第2代物件,K為第1代物件。
A2 | B2 | F2 | I2 | J2 | K1 |
---|
還有一些額外的規則需要注意:
- 在進行第1代回收之前,一般都已經對第0代物件回收了好幾次了。
- 如果物件提升到了第2代,它會長期保持存活,基本上只有當GC進行完全垃圾回收(包括0、1、2代的物件)時才會進行回收。
- 如果GC回收第0代時發現回收了大量記憶體,則會縮減第0代的預算,這意味著GC更頻繁,但做的事情也減少了;反之,如果發現沒有多少記憶體被回收,就會增大第0代的預算,這意味著GC次數更少,但每次回收的記憶體相對要多。對於第1代和第2代物件來說,也是如此。
- 如果回收後發現仍然沒有得到足夠的記憶體且無法增大預算,GC就會執行一次完全垃圾回收,如果還不夠,就會丟擲
OutOfMemoryException
異常。
四、何時進行垃圾回收
- 應用程式
new
一個物件時,CLR發現沒有足夠的第0代物件預算來分配該物件時 - 程式碼顯式呼叫
System.GC.Collect()
方法時。注意不要濫用該方法 - Windows報告低記憶體情況時
- CLR正在解除安裝AppDomain時。會回收該AppDomain的所有代物件
- CLR正在關閉時。CLR在程式正常終止(而不是通過工作管理員等外部終止)時關閉,會回收程式中的所有物件。
五、垃圾回收模式
CLR啟動時,會選擇一個GC主模式,該模式不會更改,直到程式終止。
- 工作站:預設的,針對客戶端應用程式進行優化。GC造成的時延很低,不會導致UI執行緒出現明顯的假死狀態
- 伺服器:針對伺服器端應用程式進行優化,主要是優化吞吐量和資源利用。
可以在配置檔案中告訴CLR使用伺服器回收模式:
<configuration>
<runtime>
<gcServer enabled="true"/>
</runtime>
</configuration>
另外,GC還支援兩種子模式:併發(預設)和非併發。主要區別在於併發模式中GC有一個額外的後臺執行緒,它能在應用程式執行時併發標記物件。可以在配置檔案中告訴CLR不要使用併發回收模式:
<configuration>
<runtime>
<gcConcurrent enabled="false"/>
</runtime>
</configuration>
當然,你也可以通過GCSetting
類的GCLatencyMode
屬性對垃圾回收進行某些控制(在你沒有完全瞭解影響的情況下,強烈建議不要更改):
模式 | 說明 |
---|---|
Batch | 關閉併發GC,.net framework 版本伺服器模式預設值 |
Interactive | 開啟併發GC,工作站模式與 .net core 版本伺服器模式的預設值 |
LowLatency | 在短期的、時間敏感的操作中(如動畫繪製)使用這個低延遲模式,該模式會盡力阻止第2代垃圾回收,因為花費時間較多,只有當記憶體過低時才會回收第2代。 |
SustainedLowLatency | 這個低延遲模式不會導致長時間的GC暫停,該模式會盡力阻止非併發GC執行緒對第2代垃圾回收(但是允許後臺GC執行緒對其的回收),只有當記憶體過低時才會阻塞回收第2代,適用於需要迅速響應的應用程式(如股票等)。 |
另外,還有一個模式叫做
NoGCRegion
,用於在程式執行關鍵路徑時將GC執行緒掛起。但是你不能將該值直接賦值給GCLatencyMode
屬性,要通過呼叫System.GC.TryStartGCRegion
方法才可以,並呼叫System.GC.EndGCRegion
方法結束。
六、注意事項
- 靜態欄位引用的物件會一直存在,直到用於載入型別的
AppDomain
解除安裝為止 - 由於碎片整理的開銷相對較大,因此GC在划算時才會進行碎片整理,並非每次都會執行。
- 大物件始終為第2代,而且目前版本GC不會壓縮大物件,因為移動代價過高。
- 第0代和第1代總是位於同一個記憶體段,而第2代可能跨越多個記憶體段。
七、特殊的Finalize(終結器)
包含本機資源的型別被GC時,GC會回收物件在託管堆中使用的記憶體。但這樣會造成本機資源的洩漏,為了處理這種情況,CLR提供了稱為終結的機制——允許物件在判定為垃圾之後,但在物件記憶體被回收前執行一些程式碼。在C#中的表示如下:
class SomeType
{
// 這是一個 Finalize 方法
~SomeType() { }
}
其生成的IL程式碼為:
可以看到,C#編譯器實際是在模組的後設資料中生成了名為Finalize
的protected override
方法,並且方法主體的程式碼被放置在try
塊中,並在finally
塊中呼叫base.Finalize
(本例呼叫了Object
的終結器)。
那麼,終結的內部是如何工作的呢?
new
新物件時,如果該物件的型別定義了Finalize
方法,那麼在該型別的例項構造器被呼叫之前,會將指向該物件的指標放到一個終結列表
中,該列表由GC內部控制。- 當可終結物件被回收時,會將引用從終結列表移動到freachable佇列中,該佇列由GC內部控制。
- CLR會啟用一個特殊的高優先順序執行緒來專門呼叫
Finalze
方法。freachable佇列為空時,該執行緒將睡眠;但一旦佇列中有記錄項出現,執行緒就會被喚醒,將每一項都從freachable佇列中移除,並呼叫每個物件的Finalize
方法。
如果型別的
Finalize
方法是從System.Object
繼承的,CLR就不認為該物件是“可終結”的,只有當型別重寫了Object
的Finalize
方法時,才會將型別及其派生型別的物件視為“可終結”的。
注意,除非有必要,否則應儘量避免定義終結器。原因如下:
- 可終結物件在回收時,必須保證存活,這就可能導致其被提升為另一代,生存期延長,導致記憶體無法及時回收。另外,其內部引用的所有物件也必須保證都存活,一些被認為是垃圾的物件在可終結物件回收後也無法直接回收,直到下一次(甚至多次)GC時才會被回收。
- Finalize 方法在GC完成後才會執行,而GC的執行時機無法控制,也就導致該方法的執行時間也無法控制。
- Finalize 方法中不要訪問其他可終結物件,因為CLR無法保證多個 Finalize 方法的執行順序。如果訪問了已終結的物件,Finalize 方法丟擲未處理的異常,導致程式終止,無法捕捉異常。
在實際專案開發中,想要避免釋放本機資源基本不可能,但是我們可以通過規範程式碼來規避異常,這就需要用到IDisposable
介面了。示例程式碼如下:
public class MyResourceHog : IDisposable
{
//標識資源是否已被釋放
private bool _hasDisposed = false;
public void Dispose()
{
Dispose(true);
//阻止GC呼叫 Finalize
GC.SuppressFinalize(this);
}
/// <summary>
/// 如果類本身包含非託管資源,才需要實現 Finalize
/// </summary>
~MyResourceHog()
{
Dispose(false);
}
protected virtual void Dispose(bool isDisposing)
{
if (_hasDisposed) return;
//表明由 Dispose 呼叫
if (isDisposing)
{
//釋放託管資源
}
//釋放非託管資源。無論 Dispose 還是 Finalize 呼叫,都應該釋放非託管資源
_hasDisposed = true;
}
}
public class DerivedResourceHog : MyResourceHog
{
//基類與繼承類應該使用各自的標識,防止子類設定為true時無法執行基類
private bool _hasDisposed = false;
protected override void Dispose(bool isDisposing)
{
if (_hasDisposed) return;
if (isDisposing)
{
//釋放託管資源
}
//釋放非託管資源
base.Dispose(isDisposing);
_hasDisposed = true;
}
}