一、基礎
首先,為了深入了解垃圾回收(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;
}
}