CLR和.Net對象生存週期

標籤:GC .Net C# CLRhtml


前言

對象的生存週期和垃圾回收一直是容易被咱們忽略的知識點,由於咱們如今高級語言編程平臺太「智能」了,自動的異常處理,內存管理,線程同步,以致於咱們中的大部分人只須要循序漸進面向對象編程就能完成大部分的工做——寫接口的時候繼承一個IDisposable,釋放文件佔用的時候強制Close一下,異步編程就用Async和Await……算法

好比最近結合ABP框架寫Web Api項目的時候,對於最重要的兩個消息處理對象HttpRequestMessaga和HttpResponseMessage的釋放過程,我幾乎徹底不用知道他們的生存環境,只要在後臺寫好對應的邏輯代碼便可。若是咱們不瞭解這些東西,只是遵循規範在使用的話,或許也能寫出好看的代碼,但這和程序員鑽研的精神就不符合了。因此趁着小組內的講課機會,我整理了下之前積累的一些讀書和博客筆記,將我對於這些基礎知識點的理解歸納了一下,主要討論下.Net平臺上的一些常見概念,以及應用程序如何構造新對象,包括對象的生命週期和回收工做。但願可以爲你們寫出更優雅的代碼,更深刻地理解.Net平臺提供一點微小的幫助數據庫

Tips1:由於本人水平有限,同時也是爲了社區的和諧發展,本博文將盡可能不涉及不一樣語言和平臺之爭,最多隻是比較下不一樣語言間的異同。不過有興趣的JRs能夠看看趙三本的《Why Java Sucks and C# Rocks》系列,至少對理解C#的一些特性仍是挺有幫助的。編程

外站引用圖片點擊可跳轉源連接,其餘全部圖示都由Visio做出。api


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將發生垃圾回收:

  1. 系統具備低的物理內存。
  2. 由託管堆上已分配的對象使用的內存超出了可接受的閾值(即將涉及到代的概念)。隨着進程的運行,此閾值會不斷地進行調整。
  3. 強制調用 GC.Collect 方法。
  4. CLR正在卸載應用程序域(AppDomain)
  5. 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、一、2三個代(System.GC.MaxGeneration=2):

  • 第 0 代: 從沒有被標記爲回收的新分配對象
  • 第 1 代: 在上一次垃圾回收中沒有被回收的對象
  • 第 2 代: 在一次以上的垃圾回收後仍然沒有被回收的對象.

讓咱們用一些圖示具體看看代的工做原理吧

  1. 託管堆在程序初始化時不包含對象,這時候添加到堆的對象就是第 0 代對象,這些對象並未經歷過GC檢查。一段時間後,C,F,H對象被標記爲不可達。
    第0代對象

  2. CLR初始化時爲第0代對象選擇一個預算容量,假如這時分配一個新對象形成第0代超過預算,此時CLR就會觸發一次GC操做。好比說A-H對象正好用完了第 0 代的空間,此時再操做時就會引起一次GC操做。GC後第 0 代對象不包括任何對象,而且第一代對象也已經被壓縮整理到連續的地址空間中。
    第1代對象

Tips:垃圾回收發生於第 0 代滿的時候

  1. 每次新對象仍然會被分配到第 0 代中,以下圖所示,CLR又從新分配了I-N對象,一段時間後,第 0 代和第 1 代都產生了新的垃圾對象
    兩次垃圾回收之間

Tips:CLR不只爲第 0 代對象選擇了預算,也爲第 1 代,第 2 代對象選擇了預算。
不過因爲GC是自調節的,這意味着GC可能會根據應用程序構造對象的實際狀況調整每代的預算(每次GC後,發現對象多存活增長預算,發現少存活減小預算),這樣進程工做集大小也會實時不一樣,進一步優化了GC性能。

  1. 疾射此時CLR再爲第 0 代對象加入新對象時形成超過第 0 代預算的狀況,GC將從新開啓。GC將檢查第 1 代預算使用狀況,假如第 1 代佔用內存遠少於預算,GC將只檢查第 0 代對象,即使此時原來的第 1 代對象中也出現了垃圾對象。這符合假設中的第一點,同時GC也不用再遍歷整個託管堆,從而優化了GC操做性能。
    兩次垃圾回收

  2. 此後,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 塊中被調用。
IDsm.Exe解析

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. 參考資料

相關文章
相關標籤/搜索