標簽:GC .Net C CLR "前言" "1. 基礎概念明晰" "1.1 公告語言運行時" "1.2 托管模塊" "1.3 對象和類型" "1.4 垃圾回收器" "2. 垃圾回收模型" "2.1 為什麼需要垃圾回收" "2.2 什麼時候進行垃圾回收" "2.3 垃圾回收時發生了什麼" "2.4 ...
標簽:GC .Net C# CLR
1. 基礎概念明晰
* 1.1 公告語言運行時
* 1.2 托管模塊
* 1.3 對象和類型
* 1.4 垃圾回收器2. 垃圾回收模型
* 2.1 為什麼需要垃圾回收
* 2.2 什麼時候進行垃圾回收
* 2.3 垃圾回收時發生了什麼
* 2.4 GC為我們解決了什麼問題
* 2.5 代數的概念(Generation)
* 2.6 使用System.GC類控制垃圾回收
* 2.7 非托管對象資源回收
前言
對象的生存周期和垃圾回收一直是容易被我們忽略的知識點,因為我們現在高級語言編程平臺太“智能”了,自動的異常處理,記憶體管理,線程同步,以至於我們中的大部分人只需要按部就班面向對象編程就能完成大部分的工作——寫介面的時候繼承一個IDisposable,釋放文件占用的時候強制Close一下,非同步編程就用Async和Await……
比如最近結合ABP框架寫Web Api項目的時候,對於最重要的兩個消息處理對象HttpRequestMessaga和HttpResponseMessage的釋放過程,我幾乎完全不用知道他們的生存環境,只要在後臺寫好對應的邏輯代碼即可。如果我們不瞭解這些東西,只是遵循規範在使用的話,或許也能寫出好看的代碼,但這和程式員鑽研的精神就不符合了。所以趁著小組內的講課機會,我整理了下以前積累的一些讀書和博客筆記,將我對於這些基礎知識點的理解概括了一下,主要討論下.Net平臺上的一些常見概念,以及應用程式如何構造新對象,包括對象的生命周期和回收工作。希望能夠為大家寫出更優雅的代碼,更深入地理解.Net平臺提供一點微小的幫助
Tips1:因為本人水平有限,同時也是為了社區的和諧發展,本博文將儘量不涉及不同語言和平臺之爭,最多只是比較下不同語言間的異同。不過有興趣的JRs可以看看趙三本的《Why Java Sucks and C# Rocks》系列,至少對理解C#的一些特性還是挺有幫助的。
外站引用圖片點擊可跳轉源鏈接,其他所有圖示都由Visio作出。
1. 基礎概念明晰
1.1 公共語言運行時
顧名思義,公共語言運行時(Common Language Runtime,CLR)是一個可以由多種編程語言使用的運行時,如同java的JVM(Java Virtual Machine)。CLR的核心功能包括記憶體管理,程式集載入,類型安全,異常處理和線程同步,而且還負責對代碼實施嚴格的類型安全檢查,保證代碼的準確性,這些功能都可以提供給面向CLR的所有語言(C#,F#等)使用。
.NET Framework 的版本號無需對應於它所包含的 CLR 的版本號。以下給出兩個版本號關聯表,詳情參閱.NET Framework 版本和依賴關係
.NET Framework | CLR |
---|---|
1.0 | 1.0 |
1.1 | 1.1 |
2.0 | 2.0 |
3.0 | 2.0 |
3.5 | 2.0 |
4 | 4 |
4.5.x | 4 |
4.6.x | 4 |
涉及到.Net Core當中的CoreCLR和目前.Net Framework上的CLR的比較,大家可以參見
.NET Core has two major components. It includes a small runtime that is built from the same codebase as the .NET Framework CLR. The .NET Core runtime includes the same GC and JIT (RyuJIT), but doesn’t include features like Application Domains or Code Access Security. The runtime is delivered on NuGet, via the Microsoft.CoreCLR package.
以及
CoreCLR started as a copy of CLR. It has been modified to support different OSes. They're maintained separately and in parallel.
可以看到兩者並沒有什麼特別變化,記憶體管理,GC,線程同步的機制也都是類似的(畢竟CoreCLR原先就是由CLR的版本分支出去的,詳見CoreCLR官方Git),更多的其實是在伺服器OS的優化(GC,GIT等)下了功夫。特別是在當前CoreCLR學習資料比較少的情況下,開發人員把.Net Framework實現的CLR搞搞懂也就差不多了。
1.2 托管模塊
CLR並不關心開發人員使用什麼語言來進行編程,只要我們使用的編譯器(充當語法檢查器和‘正確代碼’分析器)是面向CLR的就行。常見的語言編譯器包括C++/CLI,C#,F#,VB和一個中間語言彙編器(Intermediate Language,IL) ,以下是編譯器編譯代碼的過程,可以看到最終都是生成包含中間代碼(IL)和托管數據(可進行垃圾回收的數據類型)的托管模塊。
下圖代表CLR將源代碼編譯成托管模塊並最終運行,其中JIT將IL代碼轉換成本機CPU指令
那托管模塊是標準的32位或64位Microsoft Windows可移植執行體文件,主要由以下幾部分組成
- PE32或PE32+
- CLR頭
- 元數據
- IL代碼(基於棧,也稱為托管代碼)
什麼是托管代碼和非托管代碼
托管代碼:由公共語言運行庫環境(而不是直接由操作系統)執行的代碼。托管代碼應用程式可以獲得公共語言運行庫服務,例如自動垃圾回收、運行庫類型檢查和安全支持等。這些服務幫助提供獨立於平臺和語言的、統一的托管代碼應用程式行為。
非托管代碼:在公共語言運行庫環境的外部,由操作系統直接執行的代碼。非托管代碼必須提供自己的垃圾回收、類型檢查、安全支持等服務;它與托管代碼不同,後者從公共語言運行庫中獲得這些服務。例如COM/COM++組件,ActiveX控制項,API函數,指針運算,自製的資源文件,一般情況下我們會採取手動回收,如調用Dispose介面或使用using包裹邏輯塊,
1.3 對象和類型
CLR支持兩種類型,引用類型和值類型。
引用類型總是從托管堆分配,每次我們通過使用new操作符返回對象記憶體地址——即指向對象數據的記憶體地址,而後把這個記憶體地址pop進線程棧中。為了避免每次實例化對象都要進行一次記憶體分配,CLR也為我們提供了另一種輕量級類型——值類型,值類型的實例一般線上程棧上直接分配,不同於引用類型變數中包含指向實例的地址,值類型變數中直接就包含了實例本身的欄位。
兩種類型具體的比較和擴展就不在這裡延伸了,唯一要重申的就是引用類型總是處於已裝箱狀態。
Tips:進程初始化時,CLR會自動划出一個地址空間區域作為托管堆(相對於本機堆的說法,是由一個由CLR訪問的隨即記憶體塊)。每個托管進程都有一個托管堆,進程中的所有線程都在同一堆上分配對象記憶。這裡還涉及到一個重要的指針,Jeffrey將稱為NextObjPtr,由CLR進行維護,該指針指向下一個對象在堆中的分配位置。
對於托管堆而言,分配一個對象只是修改NextObjPtr指針的指向,這個速度是非常快的。事實上,在托管堆上分配一個對象和線上程棧上分配記憶體的速度很接近。
不妨把托管堆想象成是一間房子,入住的對象一開始都是有門卡(和引用類型的變數關聯證明)的房客,後來因為不交錢了(失去了關聯證明)就被趕出來了,詳細的交互過程會在之後說明。
CLR要求所有對象(主要指引用類型)都用new操作符創建,new操作符在完成四步操作以後,會返回指向托管堆上新建對象的一個引用(或指針,視情況而定),在使用完以後,C#並沒有如C++對應的delete操作符來刪除對象,也就是說,開發人員是沒有辦法顯示釋放為對象分配的記憶體,但是CLR採用了垃圾回收機制,能夠自動檢測到一個對象是否可達,並且自動釋放資源。
1.4 垃圾回收器
垃圾回收器(Garbage Collector)簡稱GC,採用引用跟蹤演算法,在CLR中用作自動記憶體管理器,用於控制的分配和釋放的托管記憶體。剛纔的堆比作是房子的話,GC就是堆的清潔工。它主要為開發人員提供以下作用
- 開發應用程式時不必釋放記憶體。
- 有效分配托管堆上的對象。
- 回收不再使用的對象,清除它們的記憶體,並保留記憶體以用於將來分配。托管對象會自動獲取乾凈的內容來開始,因此,它們的構造函數不必對每個數據欄位進行初始化。
- 通過確保對象不能使用另一個對象的內容來提供記憶體安全。
垃圾回收器跟蹤並回收托管記憶體中分配的對象。垃圾回收器會定期執行垃圾回收來回收記憶體分配給對象沒有有效的引用。當無法滿足記憶體要求,使用可用的可用記憶體(如new 時發現記憶體占滿),垃圾回收時會自動發生。或者,應用程式可以強制垃圾收集使用 Collect 方法。
整個垃圾回收過程包括以下步驟 ︰
- 垃圾回收器搜索托管代碼中引用的托管對象。
- 垃圾回收器嘗試完成未被引用的對象。
- 垃圾回收器釋放未被引用的對象,並回收它們的記憶體。
結合托管堆,.Net已經為開發人員提供了一個很簡便的編程模型:分配並初始化記憶體直接使用。大多數類型並不需要我們進行資源清理,GC會自動釋放記憶體。只是針對於一些特殊對象時,如文件占用,資料庫連接,開發人員才需要手動銷毀資源占用空間。
2. 垃圾回收模型
經過了上面基礎概念明晰的講解,想必大家已經對整個.Net平臺上的代碼編寫,編譯和運行過程有了一個簡單的認識,接下來就讓我們更加深入地瞭解下整個回收模型。
2.1 為什麼需要垃圾回收
我們始終要明確一個概念,為什麼我們需要垃圾回收——這是因為我們的運行環境記憶體總是有限的。當CLR在托管堆上為非垃圾對象分配地址空間時,總是分配出新的地址空間,且呈連續分配。也正因為這種引用的“局部化”(工作集的集中+對象駐留在記憶體中),托管堆的性能是極快的,但這畢竟是基於“記憶體無限”而言。實際環境中記憶體總是有限的(或者期待Intel和Google實現記憶體無限的黑科技),所以CLR才通過GC的技術刪除托管堆中不再使用的數據對象。
2.2 什麼時候進行垃圾回收
當滿足以下條件之一時CLR將發生垃圾回收:
- 系統具有低的物理記憶體。
- 由托管堆上已分配的對象使用的記憶體超出了可接受的閾值(即將涉及到代的概念)。隨著進程的運行,此閾值會不斷地進行調整。
- 強制調用 GC.Collect 方法。
- CLR正在卸載應用程式域(AppDomain)
- CLR正在關閉。
Tips:對於未裝箱的值類型對象而言,由於其不在堆上分配,一旦定義了該類型的一個實例的方法不再活動,為它們分配的存儲資源就會被釋放,而不是等著進行垃圾回收
2.3 垃圾回收時發生了什麼
上文提到GC是一種分代式垃圾回收器(同JVM,具體處理上有差異),使用引用計數演算法,該演算法只關心引用類型變數,下文中統一將該類變數稱為根。
Tips:所有的全局和靜態對象指針是應用程式的根對象,另外線上程棧上的局部變數/參數也是應用程式的根對象,還有CPU寄存器中的指向托管堆的對象也是根對象。
具體流程如下:
- GC的準備階段
在這個階段,CLR會暫停進程中的所有線程,這是為了防止線程在CLR檢查根期間訪問堆。 - GC的標記階段
當GC開始運行時,它會假設托管堆上的所有對象都是垃圾。也就是說,假定沒有根對象,也沒有根對象引用的對象,然後GC開始遍歷根對象並構建一個由所有和根對象之間有引用關係對象構成的對象圖,然後,GC會挨個遍歷根對象和引用對象,假如一個根包含null,GC會忽略這個根並繼續檢查下個根(這很關鍵)。反之,假如根引用了堆上的對象,GC就會標記那個對象並加入對象圖中。如果GC發現一個對象已經在圖中就會換一個路徑繼續遍歷。這樣做有兩個目的:一是提高性能,二是避免無限迴圈。
Tips:將引用賦值為null並不意味著強制GC立即啟動並把對象從堆上移除,唯一完成的事情是顯式取消了引用和之前 引用所指向對象之間的連接。
如下圖所示,根直接引用了對象A,C,D,F。標記對象D時,垃圾回收器發現這個對象含有一個引用對象H的欄位,所以H也會被標記,整個過程一直持續到所有根���查完畢。下圖是回收之前的托管堆模型
這裡我們還註意到了NextObjPtr對象始終保持指向最後一個對象放入托管堆的地址。
Tips:等標記過程結束後,堆中的對象只有標記和未標記兩種狀態,由上文標記規則我們可以知道,被標記的對象至少被一個根引用,我們把這種對象稱為可達(也稱為幸存),反之稱為不可達。
GC的碎片整理階段
所有的根對象都檢查完之後,GC構建的對象圖中就有了應用程式中所有的可達對象。托管堆上所有不在這個圖上的對象就是要做回收的垃圾對象了。同時,CLR會對堆中非垃圾對象進行位置上的整理,使它們覆蓋占用連續的記憶體空間(這個動作還伴隨著對根返回新的記憶體地址的行為),這樣一方面恢復了引用的“局部化”,壓縮了工作集,同時空出了空間給其他對象入住,另外也解決了本機堆的空間碎片化問題。GC恢復階段
完成了綜上的所有操作後,CLR也恢復了原先暫停的所有線程,使這些線程可以繼續訪問對象。
下圖是回收之後的托管堆模型
可以看到不可達的BEGIJ對象都已經被回收了,並且可達對象的位置也重新排列了,NextObjPtr依然指向最後一個可達對象之後的位置,為CLR下一次操作對象標識分配位置。
2.4 GC為我們解決了什麼問題
通過以上描述可知,不同於C/C++需要手動管理記憶體,GC的自動垃圾回收機製為我們解決了可能存在的記憶體泄漏和因為訪問被釋放記憶體而造成的記憶體損壞的問題。
2.5 代數的概念(Generation)
如流程描述一樣,垃圾回收會有顯著的性能損失,這是使用托管堆的一個明顯的缺點。上文中曾提到CLR的GC是基於代的分代式垃圾回收器,而代就是一種為了降低GC對性能影響的機制,代的設計思路也很簡單:
- 對象越新,生命周期越短,反之也成立
- 回收托管堆的一部分,速度快於回收整個堆
基於以上假設,托管堆中的每個對象都可以被分為0、1、2三個代(System.GC.MaxGeneration=2):
- 第 0 代: 從沒有被標記為回收的新分配對象
- 第 1 代: 在上一次垃圾回收中沒有被回收的對象
- 第 2 代: 在一次以上的垃圾回收後仍然沒有被回收的對象.
讓我們用一些圖示具體看看代的工作原理吧
托管堆在程式初始化時不包含對象,這時候添加到堆的對象就是第 0 代對象,這些對象並未經歷過GC檢查。一段時間後,C,F,H對象被標記為不可達。
CLR初始化時為第0代對象選擇一個預算容量,假如這時分配一個新對象造成第0代超過預算,此時CLR就會觸發一次GC操作。比如說A-H對象正好用完了第 0 代的空間,此時再操作時就會引發一次GC操作。GC後第 0 代對象不包括任何對象,並且第一代對象也已經被壓縮整理到連續的地址空間中。
Tips:垃圾回收發生於第 0 代滿的時候
- 每次新對象仍然會被分配到第 0 代中,如下圖所示,CLR又重新分配了I-N對象,一段時間後,第 0 代和第 1 代都產生了新的垃圾對象
Tips:CLR不僅為第 0 代對象選擇了預算,也為第 1 代,第 2 代對象選擇了預算。
不過由於GC是自調節的,這意味著GC可能會根據應用程式構造對象的實際情況調整每代的預算(每次GC後,發現對象多存活增加預算,發現少存活減少預算),這樣進程工作集大小也會實時不同,進一步優化了GC性能。
疾射此時CLR再為第 0 代對象加入新對象時造成超過第 0 代預算的情況,GC將重新開啟。GC將檢查第 1 代預算使用情況,假如第 1 代占用記憶體遠少於預算,GC將只檢查第 0 代對象,即便此時原來的第 1 代對象中也出現了垃圾對象。這符合假設中的第一點,同時GC也不用再遍歷整個托管堆,從而優化了GC操作性能。
此後,CLR仍然是按照規則對第 0 代分配對象,知道第 0 代預算被塞滿才會發生垃圾回收,把對象補充到第 1 代中,此時分兩種情況,假如第 1 代對象空間仍然小於預算,此時第 1 代中的垃圾對象仍然不會進行回收(如4圖中所示)。假如第 1 代對象在某個時間段增長到超過預算的階段,那麼CLR將在下一次進行GC回收時,檢查第 1 代對象,然後統一回收第 0 代和第 1 代中的垃圾對象。回收以後,第 0 代的幸存對象提升到第 1 代,第 1 代的幸存對象提升到了第 2 代。此時第 0 代回歸空餘狀態
6.至此,CLR已經進行了數次GC操作才最終將對象分配到了第 2 代中
2.6 使用System.GC類控制垃圾回收
MSDN上對System.GC類的定義是
控制系統垃圾回收器(一種自動回收未使用記憶體的服務)。
上文也提到垃圾回收觸發條件之一就是代碼顯示調用此類下的Collect方法,我們具體用代碼結合下代的知識演示下
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
}
class Program
{
private static void Main(string[] args)
{
Console.WriteLine("托管堆上分配位元組數: {0}", GC.GetTotalMemory(false));
Console.WriteLine("當前系統支持的最大代數", GC.MaxGeneration);
Person person = new Person { Name = "Jeffrey", Age = 100 };
Console.WriteLine(person.ToString());
Console.WriteLine("Generation of s is: {0}", GC.GetGeneration(person));
GC.Collect();
GC.WaitForPendingFinalizers();//等待對象被終結,推薦每次調用Collect方法使用該方法
Console.WriteLine("Generation of s is: {0}", GC.GetGeneration(person));
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine("Generation of s is: {0}", GC.GetGeneration(person));
Console.ReadKey();
}
}
運行結果如下,可以發現每次回收後,未被回收對象的代都增加了1
2.7 非托管對象資源回收
至此我們大概瞭解了GC的工作原理和常見垃圾回收的條件和調用方法,對於CLR而言,大多數類型只要分配了記憶體就能夠正常工作,但有的類型除了記憶體還需要本機資源,比如說常用的FileStream,便需要打開一個文件(本機資源)並保存文件句柄,或者是資料庫連接信息,那麼我們就需要顯式釋放非托管對象,因為GC僅能跟蹤托管堆上的記憶體資源。這就引伸出了可終結對象(Finalize)和可處置對象(IDisposable)這兩種處理方式
2.7.1 可終結對象(Finalize)
當包含本機資源的類型被GC時,GC會回收對象在托管堆上使用的記憶體,同時提供了一種稱為終結器(Finalization)的機制,允許對象在判定為垃圾之後,在對象記憶體在回收之前執行一些代碼。當一個對象被判定不可達後,對象將終結它自己,並釋放包裝著的本機資源,之後,GC再從托管堆中回收對象。
Tips:這裡的類型都還指的是托管堆上的引用類型
在.NET基類System.Object中, 定義了名為Finalize()的虛方法。開發人員可以重寫Object基類的Finalize方法,GC判定對象不可達後,會調用重寫的該方法,重寫方式如下,類似於C++的析構器寫法。
class Finalization{
~Finalization()
{
//這裡的代碼會進入Finalize方法
Console.WriteLine("Enter Finalize()");
}
}
以下是Finalize的IL代碼,通過查看Finalize的IL代碼,可以看到主體的代碼放到了一個try 塊中,而基類方法則在finally 塊中被調用。
Tips1:這些不可達的對象都是在GC完成以後才調用Finalize方法,所以這些對象的記憶體不是被馬上回收的,並且會被提升到下一代,這增大了記憶體損耗,並且Finalize方法的執行時間無法控制,所以原則上並不提倡使用終結器機制,GC調用Finalize方法的內部實現不在這裡贅述了。其實重寫Finalize方法的必要原因就是C#類通過平臺調用或複雜的COM組件任務使用了非托管資源。
Tips2:本機資源的清理最終總會發生
如果你必須要使用Finalize的話,Jeffrey給出的建議是“確保Finalize方法儘可能快的執行,要避免所有可能引起阻塞的操作,包括任何線程同步操作,同時也要確保Finalize方法不會引起任何異常,如果有異常垃圾回收器會繼續執行其他對象的Finalize方法直接忽略掉異常”。
2.7.2 可處置對象(IDisposable)
上文提到Finalize的一些不可避免的缺點,特別是Finalize方法的執行時間是無法控制的,所以假如開發人員想要儘可能快地手動清除本機資源時,可以實現IDisposable介面, 它定義了一個名為Dispose()的方法。這也是我們熟悉的開發模式,比如FileStream類型便實現了IDisposable介面,所以具體的使用這裡便不再贅述。只是需要額外說明的是,並不一定要顯式調用Dispose方法,才能保證非托管資源得到清理,調用Dispose方法只是控制這個清理動作的發生時間而已。同樣的,Dispose方法也不會將托管對象從托管堆中刪除,我們要記住在正常情況下,只有在GC之後,托管堆中的記憶體才能得以釋放。我們的習慣用法是將Dispose方法放入try finally的finally塊中,以確保代碼的順利執行
class Program
{
static void Main(string[] args)
{
FileStream fs = new FileStream("temp.txt",FileMode.Create);
try
{
var charData = new char[] {'1', '2', '3'};
var bytes = new byte[charData.Length];
Encoder enc = Encoding.UTF8.GetEncoder();
enc.GetBytes(charData, 0, charData.Length, bytes, 0, true);
fs.Seek(0, SeekOrigin.Begin);
fs.Write(bytes, 0, bytes.Length);
}
finally
{
fs.Dispose();
}
Console.WriteLine("Success!");
}
}
C#語言也為我們提供了一個using語句,它允許我們使用簡單的語法來獲得和上述代碼相同的效果,查看IL代碼也發現具有相同的try finally塊,具體就不演示了。
Tips:using語句只適用於那些實現了IDisposable介面的類型
3. 總結
至此,我們把CLR,托管堆,GC操作觸發條件,基於代的GC的內部實現機制,顯式釋放資源操作都走馬觀花地整理了一遍。考慮到實際使用中,我們並不會太過於關註一些不常見的用法,所以諸如Finalize的實現細節,以及垃圾回收模式等知識文中也就沒有提及,有興趣的博友可以去MSDN或者翻閱相關書籍擴展下。
對GC實際的理解上,我更喜歡把CLR比作是房東,將托管堆比作是一間大公寓,每次有對象(根)在CLR登記後,CLR就會給它提供一個身份證明(引用地址),記錄到房客租賃登記表上(線程棧)。因為這件大公寓空間仍然是有限的,房客的重要性也不一樣,所以大公寓將不同的房間劃分為天字型大小,地字型大小,人字型大小三種房間(選擇預算),房東比較重感情,所以剛來的房客嘛,管你有錢沒錢,先給我去人字型大小帶著。每次人字型大小房間不夠住的時候,房東就會安排清理工(GC)來安排房間歸屬了。對人字型大小房間的房客,清理工會一個個檢查過去,看看有沒有房客和房東關係疏遠了(不可達),這些沒心沒肺的(也可能是房東主動提出絕交)全都滾出去,那些剩下來的再安排到地房間去。假如地字型大小房間沒滿,清理工就不檢查了(代的性能優化),滿了再依舊安排。假如你是地字型大小的,就算你和房東絕交了,也會考慮再讓你住些日子。那如果有時候發現一些房客就是暫住下,人數又多,離開又早,那清理工就會調整下房間,把各層級的房間數目再分配下。
匆忙之作,歡迎勘誤,不勝感激。
4. 參考資料
- 什麼是.NET?
- .NET 對象生命周期
- MSDN Magazine Issues and Downloads
- 改善C#程式的建議4:C#中標準Dispose模式的實現
- Fundamentals of Garbage Collection
- C# Finalize和Dispose的區別