C#垃圾回收機制詳解

風靈使發表於2018-06-28

GC的前世與今生

雖然本文是以.net作為目標來講述GC,但是GC的概念並非才誕生不久。早在1958年,由鼎鼎大名的圖林獎得主John McCarthy所實現的Lisp語言就已經提供了GC的功能,這是GC的第一次出現。Lisp的程式設計師認為記憶體管理太重要了,所以不能由程式設計師自己來管理。但後來的日子裡Lisp卻沒有成氣候,採用記憶體手動管理的語言佔據了上風,以C為代表。出於同樣的理由,不同的人卻又不同的看法,C程式設計師認為記憶體管理太重要了,所以不能由系統來管理,並且譏笑Lisp程式慢如烏龜的執行速度。的確,在那個對每一個Byte都要精心計算的年代GC的速度和對系統資源的大量佔用使很多人的無法接受。而後,1984年由Dave Ungar開發的Small talk語言第一次採用了Generational garbage collection的技術(這個技術在下文中會談到),但是Small talk也沒有得到十分廣泛的應用。

直到20世紀90年代中期GC才以主角的身份登上了歷史的舞臺,這不得不歸功於Java的進步,今日的GC已非吳下阿蒙。Java採用VM(Virtual Machine)機制,由VM來管理程式的執行當然也包括對GC管理。90年代末期.net出現了,.net採用了和Java類似的方法由CLR(Common Language Runtime)來管理。這兩大陣營的出現將人們引入了以虛擬平臺為基礎的開發時代,GC也在這個時候越來越得到大眾的關注。

為什麼要使用GC呢?也可以說是為什麼要使用記憶體自動管理?有下面的幾個原因:
1、提高了軟體開發的抽象度;
2、程式設計師可以將精力集中在實際的問題上而不用分心來管理記憶體的問題;
3、可以使模組的介面更加的清晰,減小模組間的偶合;
4、大大減少了記憶體人為管理不當所帶來的Bug
5、使記憶體管理更加高效。

總的說來就是GC可以使程式設計師可以從複雜的記憶體問題中擺脫出來,從而提高了軟體開發的速度、質量和安全性。

什麼是GC

GC如其名,就是垃圾收集,當然這裡僅就記憶體而言。Garbage Collector(垃圾收集器,在不至於混淆的情況下也成為GC)以應用程式的root為基礎,遍歷應用程式在Heap上動態分配的所有物件[2],通過識別它們是否被引用來確定哪些物件是已經死亡的哪些仍需要被使用。已經不再被應用程式的root或者別的物件所引用的物件就是已經死亡的物件,即所謂的垃圾,需要被回收。這就是GC工作的原理。為了實現這個原理,GC有多種演算法。比較常見的演算法有Reference Counting,Mark Sweep,Copy Collection等等。目前主流的虛擬系統.net CLRJava VMRotor都是採用的Mark Sweep演算法。

一、Mark-Compact 標記壓縮演算法

簡單把.NETGC演算法看作Mark-Compact演算法
階段1: Mark-Sweep 標記清除階段
先假設heap中所有物件都可以回收,然後找出不能回收的物件,給這些物件打上標記,最後heap中沒有打標記的物件都是可以被回收的
階段2: Compact 壓縮階段
物件回收之後heap記憶體空間變得不連續,在heap中移動這些物件,使他們重新從heap基地址開始連續排列,類似於磁碟空間的碎片整理
這裡寫圖片描述
Heap記憶體經過回收、壓縮之後,可以繼續採用前面的heap記憶體分配方法,即僅用一個指標記錄heap分配的起始地址就可以
主要處理步驟:將執行緒掛起=>確定roots=>建立reachable objectsgraph=>物件回收=>heap壓縮=>指標修復
可以這樣理解rootsheap中物件的引用關係錯綜複雜(交叉引用、迴圈引用),形成複雜的graphrootsCLRheap之外可以找到的各種入口點。GC搜尋roots的地方包括全域性物件、靜態變數、區域性物件、函式呼叫引數、當前CPU暫存器中的物件指標(還有finalizationqueue)等。主要可以歸為2種型別:已經初始化了的靜態變數、執行緒仍在使用的物件(stack+CPU register)
Reachable objects:指根據物件引用關係,從roots出發可以到達的物件。例如當前執行函式的區域性變數物件A是一個rootobject,他的成員變數引用了物件B,則B是一個reachable object。從roots出發可以建立reachable objectsgraph,剩餘物件即為unreachable,可以被回收
這裡寫圖片描述

指標修復是因為compact過程移動了heap物件,物件地址發生變化,需要修復所有引用指標,包括stackCPUregister中的指標以及heap中其他物件的引用指標
Debugrelease執行模式之間稍有區別,release模式下後續程式碼沒有引用的物件是unreachable的,而debug模式下需要等到當前函式執行完畢,這些物件才會成為unreachable,目的是為了除錯時跟蹤區域性物件的內容

傳給了COM+的託管物件也會成為root,並且具有一個引用計數器以相容COM+的記憶體管理機制,引用計數器為0時這些物件才可能成為被回收物件
Pinnedobjects指分配之後不能移動位置的物件,例如傳遞給非託管程式碼的物件(或者使用了fixed關鍵字),GC在指標修復時無法修改非託管程式碼中的引用指標,因此將這些物件移動將發生異常。pinnedobjects會導致heap出現碎片,但大部分情況來說傳給非託管程式碼的物件應當在GC時能夠被回收掉

二、Generational 分代演算法

程式可能使用幾百M、幾G的記憶體,對這樣的記憶體區域進行GC操作成本很高,分代演算法具備一定統計學基礎,對GC的效能改善效果比較明顯
將物件按照生命週期分成新的、老的,根據統計分佈規律所反映的結果,可以對新、老區域採用不同的回收策略和演算法,加強對新區域的回收處理力度,爭取在較短時間間隔、較小的記憶體區域內,以較低成本將執行路徑上大量新近拋棄不再使用的區域性物件及時回收掉
分代演算法的假設前提條件:
1、大量新建立的物件生命週期都比較短,而較老的物件生命週期會更長
2、對部分記憶體進行回收比基於全部記憶體的回收操作要快
3、新建立的物件之間關聯程度通常較強。heap分配的物件是連續的,關聯度較強有利於提高CPU cache的命中率

.NET將heap分成3個代齡區域: Gen 0、Gen 1、Gen 2
alt

Heap分為3個代齡區域,相應的GC有3種方式: # Gen 0 collections, # Gen 1 collections, #Gen 2 collections。如果Gen 0 heap記憶體達到閥值,則觸發0代GC,0代GCGen 0中倖存的物件進入Gen 1。如果Gen 1的記憶體達到閥值,則進行1代GC,1代GCGen 0 heapGen 1 heap一起進行回收,倖存的物件進入Gen2。2代GCGen 0 heap、Gen 1 heapGen 2 heap一起回收
Gen 0Gen 1比較小,這兩個代齡加起來總是保持在16M左右;Gen2的大小由應用程式確定,可能達到幾G,因此0代和1代GC的成本非常低,2代GC稱為fullGC,通常成本很高。粗略的計算0代和1代GC應當能在幾毫秒到幾十毫秒之間完成,Gen 2 heap比較大時fullGC可能需要花費幾秒時間。大致上來講.NET應用執行期間2代、1代和0代GC的頻率應當大致為1:10:100

三、Finalization QueueFreachable Queue

這兩個佇列和.net物件所提供的Finalize方法有關。這兩個佇列並不用於儲存真正的物件,而是儲存一組指向物件的指標。當程式中使用了new操作符在Managed Heap上分配空間時,GC會對其進行分析,如果該物件含有Finalize方法則在Finalization Queue中新增一個指向該物件的指標。在GC被啟動以後,經過Mark階段分辨出哪些是垃圾。再在垃圾中搜尋,如果發現垃圾中有被Finalization Queue中的指標所指向的物件,則將這個物件從垃圾中分離出來,並將指向它的指標移動到Freachable Queue中。這個過程被稱為是物件的復生Resurrection),本來死去的物件就這樣被救活了。為什麼要救活它呢?因為這個物件的Finalize方法還沒有被執行,所以不能讓它死去。Freachable Queue平時不做什麼事,但是一旦裡面被新增了指標之後,它就會去觸發所指物件的Finalize方法執行,之後將這個指標從佇列中剔除,這是物件就可以安靜的死去了。.net frameworkSystem.GC類提供了控制Finalize的兩個方法,ReRegisterForFinalizeSuppressFinalize。前者是請求系統完成物件的Finalize方法,後者是請求系統不要完成物件的Finalize方法。ReRegisterForFinalize方法其實就是將指向物件的指標重新新增到Finalization Queue中。這就出現了一個很有趣的現象,因為在Finalization Queue中的物件可以復生,如果在物件的Finalize方法中呼叫ReRegisterForFinalize方法,這樣就形成了一個在堆上永遠不會死去的物件,像鳳凰涅槃一樣每次死的時候都可以復生。

託管資源:

Net中的所有型別都是(直接或間接)從System.Object型別派生的。

CTS中的型別被分成兩大類——引用型別(reference type,又叫託管型別[managed type]),分配在記憶體堆上。值型別(value type),值型別分配在堆疊上。如圖

alt

值型別在棧裡,先進後出,值型別變數的生命有先後順序,這個確保了值型別變數在推出作用域以前會釋放資源。比引用型別更簡單和高效。堆疊是從高地址往低地址分配記憶體。

引用型別分配在託管堆(Managed Heap)上,宣告一個變數在棧上儲存,當使用new建立物件時,會把物件的地址儲存在這個變數裡。託管堆相反,從低地址往高地址分配記憶體,如圖

alt

.net中超過80%的資源都是託管資源。

非託管資源:

ApplicationContext,Brush,Component,ComponentDesigner,Container,Context,Cursor,FileStream,Font,Icon,Image,Matrix,Object,OdbcDataReader,OleDBDataReader,Pen,Regex,Socket,StreamWriter,Timer,Tooltip ,檔案控制程式碼,GDI資源,資料庫連線等等資源。可能在使用的時候很多都沒有注意到!

.NET的GC機制有這樣兩個問題:

首先,GC並不是能釋放所有的資源。它不能自動釋放非託管資源。

第二,GC並不是實時性的,這將會造成系統效能上的瓶頸和不確定性。

GC並不是實時性的,這會造成系統效能上的瓶頸和不確定性。所以有了IDisposable介面,IDisposable介面定義了Dispose方法,這個方法用來供程式設計師顯式呼叫以釋放非託管資源。使用using 語句可以簡化資源管理。

示例

當你用Dispose方法釋放未託管物件的時候,應該呼叫GC.SuppressFinalize。如果物件正在終結佇列(finalization queue),GC.SuppressFinalize會阻止GC呼叫Finalize方法。因為Finalize方法的呼叫會犧牲部分效能。如果你的Dispose方法已經對委託管資源作了清理,就沒必要讓GC再呼叫物件的Finalize方法(MSDN)。附上MSDN的程式碼,大家可以參考.

public class BaseResource : IDisposable
{
// 指向外部非託管資源
private IntPtr handle;
// 此類使用的其它託管資源.
private Component Components;
// 跟蹤是否呼叫.Dispose方法,標識位,控制垃圾收集器的行為
private bool disposed = false;

// 建構函式
public BaseResource()
{
// Insert appropriate constructor code here.
}

// 實現介面IDisposable.
// 不能宣告為虛方法virtual.
// 子類不能重寫這個方法.
public void Dispose()
{
Dispose(true);
// 離開終結佇列Finalization queue
// 設定物件的阻止終結器程式碼
//
GC.SuppressFinalize(this);
}

// Dispose(bool disposing) 執行分兩種不同的情況.
// 如果disposing 等於 true, 方法已經被呼叫
// 或者間接被使用者程式碼呼叫. 託管和非託管的程式碼都能被釋放
// 如果disposing 等於false, 方法已經被終結器 finalizer 從內部呼叫過,
//你就不能在引用其他物件,只有非託管資源可以被釋放。
protected virtual void Dispose(bool disposing)
{
// 檢查Dispose 是否被呼叫過.
if (!this.disposed)
{
// 如果等於true, 釋放所有託管和非託管資源
if (disposing)
{
// 釋放託管資源.
Components.Dispose();
}
// 釋放非託管資源,如果disposing為 false,
// 只會執行下面的程式碼.
CloseHandle(handle);
handle = IntPtr.Zero;
// 注意這裡是非執行緒安全的.
// 在託管資源釋放以後可以啟動其它執行緒銷燬物件,
// 但是在disposed標記設定為true前
// 如果執行緒安全是必須的,客戶端必須實現。

}
disposed = true;
}
// 使用interop 呼叫方法
// 清除非託管資源.
[System.Runtime.InteropServices.DllImport("Kernel32")]
private extern static Boolean CloseHandle(IntPtr handle);

// 使用C# 解構函式來實現終結器程式碼
// 這個只在Dispose方法沒被呼叫的前提下,才能呼叫執行。
// 如果你給基類終結的機會.
// 不要給子類提供解構函式.
~BaseResource()
{
// 不要重複建立清理的程式碼.
// 基於可靠性和可維護性考慮,呼叫Dispose(false) 是最佳的方式
Dispose(false);
}

// 允許你多次呼叫Dispose方法,
// 但是會丟擲異常如果物件已經釋放。
// 不論你什麼時間處理物件都會核查物件的是否釋放,
// check to see if it has been disposed.
public void DoSomething()
{
if (this.disposed)
{
throw new ObjectDisposedException();
}
}


// 不要設定方法為virtual.
// 繼承類不允許重寫這個方法
public void Close()
{
// 無引數呼叫Dispose引數.
Dispose();
}

public static void Main()
{
// Insert code here to create
// and use a BaseResource object.
}
}

GC.Collect() 方法

作用:強制進行垃圾回收。

GC的方法:

名稱 說明
Collect() 強制對所有代進行即時垃圾回收。
Collect(Int32) 強制對零代到指定代進行即時垃圾回收。
Collect(Int32, GCCollectionMode) 強制在 GCCollectionMode 值所指定的時間對零代到指定代進行垃圾回收。

GC注意事項:

1、只管理記憶體,非託管資源,如檔案控制程式碼,GDI資源,資料庫連線等還需要使用者去管理

2、迴圈引用,網狀結構等的實現會變得簡單。GC的標誌也壓縮演算法能有效的檢測這些關係,並將不再被引用的網狀結構整體刪除。

3、GC通過從程式的根物件開始遍歷來檢測一個物件是否可被其他物件訪問,而不是用類似於COM中的引用計數方法。

4、GC在一個獨立的執行緒中執行來刪除不再被引用的記憶體

5、GC每次執行時會壓縮託管堆

6、你必須對非託管資源的釋放負責。可以通過在型別中定義Finalizer來保證資源得到釋放。

7、物件的Finalizer被執行的時間是在物件不再被引用後的某個不確定的時間。注意並非和C++中一樣在物件超出宣告週期時立即執行解構函式

8、Finalizer的使用有效能上的代價。需要Finalization的物件不會立即被清除,而需要先執行Finalizer.Finalizer不是在GC執行的執行緒被呼叫。GC把每一個需要執行Finalizer的物件放到一個佇列中去,然後啟動另一個執行緒來執行所有這些Finalizer.而GC執行緒繼續去刪除其他待回收的物件。在下一個GC週期,這些執行完Finalizer的物件的記憶體才會被回收。

9、.NET GC使用”代”(generations)的概念來優化效能。代幫助GC更迅速的識別那些最可能成為垃圾的物件。在上次執行完垃圾回收後新建立的物件為第0代物件。經歷了一次GC週期的物件為第1代物件。經歷了兩次或更多的GC週期的物件為第2代物件。代的作用是為了區分區域性變數和需要在應用程式生存週期中一直存活的物件。大部分第0代物件是區域性變數。成員變數和全域性變數很快變成第1代物件並最終成為第2代物件。

10、GC對不同代的物件執行不同的檢查策略以優化效能。每個GC週期都會檢查第0代物件。大約1/10的GC週期檢查第0代和第1代物件。大約1/100的GC週期檢查所有的物件。重新思考Finalization的代價:需要Finalization的物件可能比不需要Finalization在記憶體中停留額外9個GC週期。如果此時它還沒有被Finalize,就變成第2代物件,從而在記憶體中停留更長時間。

相關文章