[CLR via C#]21. 自動內存管理(垃圾回收機制)

目錄

1、理解垃圾回收平臺的基本工做原理

  1. 值類型(含全部枚舉類型)、集合類型、String、Attribute、Delegate和Event所表明的資源無需執行特殊的清理操做。
  2. 若是一個類型表明着或包裝着一個非託管資源或者本地資源(好比數據庫鏈接、套接字、mutex、位圖等),那麼在對象的內存準備回收時,必須執行資源清理代碼。
  3. CLR要求全部的資源都從託管堆分配。
  4. 進程初始化時,CLR要保留一塊連續的地址空間,這個地址空間最初沒有對應的物理存儲空間。這個地址空間就是託管堆。託管堆還維護着一個指針,能夠稱爲NextObjPtr。它指向下一個對象在堆中的分配位置。剛開始時,NextObjPtr設爲保留地址空間的基地址。IL指令使用newobj建立一個對象。newobj指令將致使CLR執行如下步驟:
    1. 計算類型(及其全部基類型)所須要的字節數。
    2. 加上對象的額外開銷的字節數——「類型對象指針」和「同步塊索引」。 

    3. CLR檢查保留區域是否能分配出相應的字節數。若是託管堆有足夠的可用空間,對象將被放入。注意對象這在NextObjPtr指針指向的地址放入的,而且爲它分配的字節會被清零。接着,調用類型的實例構造函數(爲this參數傳遞NextObjPtr),IL指令newobj將返回對象的地址。就在地址返回以前,NextObjPtr指針的值會加上對象佔據的字節數,這樣就會獲得一個新的NextObjPtr值,它指向下一個對象放入托管堆時的地址。
  5. 託管堆之因此能這麼作,是由於它作了一個至關大膽的假設——地址空間和存儲是無限的。這個假設顯然是荒謬的。因此,託管堆必須經過某種機制來容許它作這樣的假設。這種機制就是垃圾回收。
  6. 對象不斷的被建立,NextObjPtr也在不斷的增長,若是NextObjPtr超過了地址空間的末尾,代表託管堆已滿,就必須強制執行一次垃圾回收。

2、 垃圾回收算法

  1. 每一個應用程序都包含一組。每一個根都是一個存儲位置,其中包含指向引用類型對象的指針。該指針要麼引用託管堆中的一個對象,要麼爲null。只有引用類型的變量纔會被認爲是根;值類型的變量永遠不被認爲是根。
  2. 垃圾回收開始執行時,它假設堆中全部對象都是垃圾。
    1. 第一個階段爲標記階段。這個階段,垃圾回收器沿着線程棧向上檢查全部根。若是發現一個根引用了一個對象,就進行」標記」。該標記具備傳遞性。標記好根和它的字段引用的對象以後,垃圾回收器會檢查下一個根,並繼續標記對象。若是垃圾回收期試圖標記先前已經標記了的根,就會中止沿着這個路徑走下去。檢查好全部根以後,堆中將包含一組已標記和未標記的對象。已標記的對象是經過應用程序的代碼能夠到達的對象,而未標記的對象是不可達的。不可達的對象就是垃圾,它們的內存是能夠回收的。
    2. 第二個階段爲壓縮(能夠理解成"內存碎片整理")階段。在這個階段中,垃圾回收器線性遍歷堆,以尋找未標記對象的連續內存塊。若是這個內存塊較小,垃圾回收器會忽略它們。反之,垃圾回收器會把非垃圾的對象移動到這裏已壓縮堆,其實在這是內存碎片整理或許更會適用。天然的,包含那些」指向這些對象的指針」的變量和CPU寄存器如今都會變得無效。因此,垃圾回收器必須從新訪問應用程序的全部根,並修改它們來指向對象的新內存位置。堆內存壓縮以後,託管堆的NextObjPtr指針將指向緊接在最後一個非垃圾回收對象以後的位置。
  3. 因此,垃圾回收器會形成顯著的損失,這是使用託管堆的主要缺點。固然,垃圾回收只在第0代滿的時候纔會發生。在此以前,託管堆性能遠遠高於C運行時堆。

3、垃圾回收與調試

  1. 當JIT編譯器將方法的IL代碼編譯成本地代碼時,JIT編譯器會檢查兩點:定義方法的程序集在編譯時沒有優化;進行當前在一個調試器中執行。若是這兩點都成立,JIT編譯器在生成方法的內部根表時,會將變量的生存期手動延長至方法結束。 

4、使用終結操做來釋放本地資源

  1. 終結是CLR提供的一種機制,容許對象在垃圾回收器回收其內存以前執行一些得體的清理工做。
  2. 任何包裝了本地資源的類型都必須支持終結操做。簡單的說,類型實現了一個命名爲Finalize的方法。當垃圾回收期判斷一個對象是垃圾時,會調用對象的Finalize方法。
  3. C#團隊認爲,Finalize方法是編程語言中須要特殊語法的一種方法。在C#中,必須在類名前加一個~符號來定義Finalize方法。
Internal sealed class SomeType {

     ~SomeType(){

         //這裏的代碼會進入Finalize方法

    }

}

  5. 編譯上述代碼,會發現C#編譯器實際是在模塊的元數據中生成一個名爲Finalize的protected override方法。方法主體被放到try塊中,finally塊放入了一個對base.Finalize的調用。

  6.實現Finalize方法時,通常都會調用Win32 CloseHandle函數,並向該函數傳遞本地資源的句柄。

5、對託管資源使用終結操做

  1. 永遠不要對託管資源使用終結操做,這是有一種很是好的編程習慣。由於對託管資源使用終結操做是一種很是高級的編碼方式,只有極少數狀況下才會用到。
  2. 設計一個類型時,處於如下幾個性能緣由,應避免使用Finalize方法:
    1. 可終結的對象要花費更長的時間來分配,由於指向它們的指針必須先放到終結列表中。("終結列表"在第七節會說到)
    2. 可終結對象會被提高到較老的一代,這會增長內存壓力,並在垃圾回收器斷定爲垃圾時,阻止回收。除此以外,對該對象直接或間接引用的對象都會提高到較老的一代。("代"在第十三節會說到)
    3. 可終結的對象會致使應用程序運行緩慢,由於每一個對象在進行回收時,須要對它們進行額外操做。
  3. 咱們沒法控制Finalize方法什麼時候運行。CLR不保證各個Finalize的調用順序。

6、是什麼致使Finalize方法被調用

  1. 第0代滿 只有第0代滿時,垃圾回收器會自動開始。該事件是目前致使調用Finalize方法最多見的一種方式。("代"在第十三節會說到)
  2. 代碼顯式調用System.GC的靜態方法Collect  代碼能夠顯式請求CLR執行即時垃圾回收操做。
  3. Windows內存不足  當Windows報告內存不足時,CLR會強制執行垃圾回收。
  4. CLR卸載AppDomain  一個ApppDomain被卸載時,CLR認爲該AppDomain不存在任何根,所以會對全部代的對象執行垃圾回收。
  5. CLR關閉  一個進程結束時,CLR就會關閉。CLR關閉會認爲進程中不存在 任何根,所以會調用託管堆中全部的Finalize方法,最後由Windows回收內存。

7、終結操做揭祕

  1. 應用程序建立一個新對象時,new操做符會從堆中分配內存。若是對象的類型定義了Finalize方法,那麼在該類型的實例構造器調用以前,會將一個指向該對象的指針放到一個終結列表(finalization list)中。
  2. 終結列表是由垃圾回收器控制的一個內部數據結構。列表中的每一項都指向一個對象,在回收該對象以前,會先調用對象的Finalize方法。
  3. 下圖1展現了包含幾個對象的一個託管堆。有的對象從應用程序的根可達,有的不可達(垃圾)。對象C,E,F,I,J被建立時,系統檢測到這些對象的類型定義來了Finalize方法,全部指向這些對象的指針要添加到終結列表中。

  4. 垃圾回收開始時,對象B,E,G,H,I和J被斷定爲垃圾。垃圾回收器掃描終結列表以查找指向這些對象的指針。找到一個指針後,該指針會從終結列表中移除,並追加到freachable隊列中。freachable隊列(發音是「F-reachable」)是垃圾回收器的內部數據結構。Freachable隊列中的每一個指針都表明其Finalize方法已準備好調用的一個對象。圖2展現了回收完畢後託管堆的狀況。

  5. 從圖2中咱們能夠看出B,E和H已經從託管堆中回收了,由於它們沒有Finalize方法,而E,I,J則暫時沒有被回收,由於它們的Finalize方法還未調用。
  6. 一個特殊的高優先級的CLR線程負責調用Finalize方法。使用專用的線程可避免潛在的線程同步問題。freachable隊列爲空時,該線程將睡眠。當隊列中有記錄項時,該線程就會被喚醒,將每一項從freachable隊列中移除,並調用每一項的 Finalize方法。
  7. 若是一個對象在freachable隊列中,那麼意味這該對象是可達的,不是垃圾。
  8. 本來,當對象不可達時,垃圾回收器將把該對象當成垃圾回收了,但是當對象進入freachable隊列時,有奇蹟般的」復活」了。而後,垃圾回收器壓縮(內存脆片整理)可回收的內存,特殊的CLR線程將清空freachable隊列,並調用其中每一個對象的Finalize方法。
  9. 垃圾回收器下一次回收時,發現已終結的對象成爲真正的垃圾,由於應用程序的根再也不指向它,freachhable隊列也再也不指向它。因此,這些對象的內存會直接回收。
  10.  整個過程當中,可終結對象須要執行兩次垃圾回收器才能釋放它們佔用的內存。可在實際開發中,因爲對象可能被提高到較老的一代,因此可能要求不止兩次進行垃圾回收。圖3展現了第二次垃圾回收後託管堆中的狀況。

8、Dispose模式:強制對象清理資源

  1. Finalize方法很是有用,由於它確保了當託管對象的內存被釋放時,本地資源不會泄漏。可是,Finalize方法的問題在於,他的調用時間不能保證。另外,因爲他不是公共方法,因此類的用戶不能顯式調用它。
  2. 類型爲了提供顯式進行資源清理的能力,提供了Dispose模式。
  3. 全部定義了Finalize方法的類型都應該同時實現Dispose模式,使類型的用戶對資源的生存期有更多的控制。

9、使用實現了Dispose模式的類型

  1. 調用Dispose或Close只是爲了能在一個肯定的時間強迫對象執行清理;這兩個方法並不能控制託管堆中的對象所佔用的內存的生存期。這意味着即便一個對象已完成了清理,仍然可在它上面調用方法,但會拋出ObjectDisposedException異常。
  2. 建議只有在如下兩種狀況下才調用Dispose或Close:
    1. a)   肯定必須清理資源
    2. b)   肯定能夠安全的調用Dispose或Close,並但願將對象從終結列表中刪除,禁止對象提高到下一代,從而提高性能。

10、C#的using語句

  1. 若是決定顯式地調用Dispose和Close這兩個方法之一,強烈建議把它們放到一個異常處理finally中。這樣能夠保證清理代碼獲得執行。
  2. Using語句就是一種對第1點進行簡化的語法。

11、手動監視和控制對象的生存期

  1. CLR爲每個AppDomain都提供了一個GC句柄表。該表容許應用程序監視對象的生存期,或手動控制對象的生存期。
  2. 在一個AppDomain建立之初,該句柄表是空的。句柄表中的每一個記錄項都包含如下兩種信息:一個指針,它指向託管堆上的一個對象;一個標誌(flag),它指出你想如何監視或控制對象。
  3. 爲了在這個表中添加或刪除記錄項,應用程序要使用以下所示的System.Runtime.InteropServices.GCHandle類型。

12、對象復活

  1. 前面說過,須要終結的一個對象被認爲死亡時,垃圾回收器會強制是該對象重生,使它的Finalize方法得以調用。Finalize方法調用以後,對象才真正的死亡。
  2. 須要終結的一個對象會經歷死亡、重生、在死亡的」三部曲」。一個死亡的對象重生的過程稱爲重生
  3. 復活通常不是一件好事,應避免寫代碼來利用CLR這個」功能」。

十3、代

  1. 代是CLR垃圾回收器採用的一種機制,它惟一的目的就是提高應用程序的性能
  2. 一個基於代的垃圾回收器作出瞭如下幾點假設:
    1. 對象越新,生存期越短。
    2. 對象越老,生存期越長。
    3. 回收堆的一部分,速度快於回收整個堆。
  3. 代的工做原理:
    1. 託管堆在初始化時不包含任何對象。添加到堆的對象稱爲第0代對象。第0代對象就是那些新構造的對象,垃圾回收器從未檢查過它們。圖4展現了一個新啓動的應用程序,它分配了5個對象。過會兒,對象C和E將變得不可達。
    2. CLR初始化時,它會爲第0代對象選擇一個預算容量,假定爲256K(實際容量可能有所不一樣)。因此,若是分配一個新對象形成第0代超過預算,就必須啓動一次垃圾回收。假定對象A到E恰好佔用256K內存。對象F分配時,垃圾回收器必須啓動。垃圾回收器斷定對象C和E爲垃圾,由於會壓縮(內存碎片整理)對象D,使其與對象B相鄰。之因此第0代的預算容量爲256K,是由於全部這些對象都能裝入CPU的L2緩存,使之壓縮(內存碎片整理)能以很是快的速度完成。在垃圾回收中存活的對象(A、B和D)被認爲是第1代對象。第1代對象已經經歷垃圾回收的一次檢查。此時的對如圖5所示。
    3. 一次垃圾回收後,第0代就不包含任何對象了。和前面同樣,新對象會分配到第0代中。在圖6中,應用程序繼續運行,並新分配了對象F到對象K。另外,隨着應用程序繼續運行,對象B、H和J變得不可達,它們的內存將在某一個回收。
    4. 如今,假定分配新對象L會形成第0代超過256KB的預算。因爲第0代達到預算,因此必須啓動垃圾回收器。開始一次垃圾回收時,垃圾回收器必須決定檢查哪些代。
    5. 前面說過,當CLR初始化時,他爲第0代對象選擇了一個預算。一樣的,它還必須爲第1代選擇一個預算。假定爲第1代選擇的預算爲2MB。
    6. 垃圾回收開始時,垃圾回收器還會檢查第1代佔據了多少內存。因爲在本例中。第一代佔據的內存遠遠小於2MB,因此垃圾回收器只檢查第0代。由於此時垃圾回收器只檢查第0代,忽略第1代,因此大大加快了垃圾回收器的速度。可是,對性能最大的提高就是如今沒必要遍歷整個託管堆。若是一個對象引用了一個老對象,垃圾回收器就能夠忽略那個老對象的全部內部引用,從而能更快的構造好可達對象的圖。
    7. 如圖7所示,全部倖存下來的第0代對象變成了第1代的一部分。因爲垃圾回收器沒有檢查第1代,因此對象B的內存並無被回收,即便它在上次垃圾回收時變得不可達。在一次垃圾回收後,第0代不包含任何對象,等着分配新對象。
    8. 假定程序繼續運行,並分配對象L到對象O。另外,在運行過程當中,應用程序中止使用對象G,I,M,是它們變得不可達。此時的託管堆如圖8所示。
    9. 假設分配對象P致使第0代超過預算,垃圾回收發生。因爲第1代中全部對象佔據的內存仍小於2MB,因此垃圾回收器再次決定只回收第0代,忽略第1代不可達的垃圾(對象B和G)。回收後,堆的狀況如圖9所示。
    10. 從圖9中能夠看到,第1代正在緩慢增加。假定第1代的增加致使它全部對象佔據的內存恰好達到2MB。這時,隨着應用程序的運行,並分配了對象P到對S,使第0代對象達到了它的預算容量。這是的堆如圖10所示。
    11. 應用程序試圖分配對象T時,因爲第0代已滿,因此必須開始垃圾回收。可是,此次垃圾回收器發現第1代佔據的內存超過了2MB。因此垃圾回收器此次決定檢查第1代和第0代中的全部對象。兩代都被回收以後,託管堆狀況如圖11所示。

    4. 像前面同樣,垃圾回收後,第0代的倖存者被提高到了第1代,第1代的倖存者被提高到了第2代,第0代再次空出來,準備迎接新對象的到來。第2代中的對象會通過2次或更屢次的檢查。只有在第1代到達預算容量是纔會檢查第1代中的對象。而對此以前,通常已經對第0代進行了好幾回垃圾回收。

  5. CLR的託管堆只支持三代:第0代、第1代和第2代。第0代的預算約爲256KB,第1代的預算約爲2MB,第2代的預算容量約爲10MB。

十4、   線程劫持

  1. 前面討論的垃圾回收算法有一個很大的前提就是:只在一個線程運行。
  2. 在現實開發中,常常會出現多個線程同時訪問託管堆的狀況,或至少會有多個線程同時操做堆中的對象。一個線程引起垃圾回收時,其它線程絕對不能訪問任何線程,由於垃圾回收器可能移動這些對象,更改它們的內存位置。
  3. CLR想要進行垃圾回收時,會當即掛起執行託管代碼中的全部線程,正在執行非託管代碼的線程不會掛起。而後,CLR檢查每一個線程的指令指針,判斷線程指向到哪裏。接着,指令指針與JIT生成的表進行比較,判斷線程正在執行什麼代碼。
  4. 若是線程的指令指針剛好在一個表中標記好的偏移位置,就說明該線程抵達了一個安全點。線程可在安全點安全地掛起,直至垃圾回收結束。若是線程指令指針不在表中標記的偏移位置,則代表該線程不在安全點,CLR也就不會開始垃圾回收。在這種狀況下,CLR就會劫持該線程。也就是說,CLR會修改該線程棧,使該線程指向一個CLR內部的一個特殊函數。而後,線程恢復執行。當前的方法執行完後,他就會執行這個特殊函數,這個特殊函數會將該線程安全地掛起。
  5. 然而,線程有時長時間執行當前所在方法。因此,當線程恢復執行後,大約有250毫秒的時間嘗試劫持線程。過了這個時間,CLR會再次掛起線程,並檢查該線程的指令指針。若是線程已抵達一個安全點,垃圾回收就能夠開始了。可是,若是線程尚未抵達一個安全點,CLR就檢查是否調用了另外一個方法。若是是,CLR再一次修改線程棧,以便從最近執行的一個方法返回以後劫持線程。而後,CLR恢復線程,進行下一次劫持嘗試。
  6. 全部線程都抵達安全點或被劫持以後,垃圾回收才能使用。垃圾回收完以後,全部線程都會恢復,應用程序繼續運行,被劫持的線程返回最初調用它們的方法。
  7. 實際應用中,CLR大多數時候都是經過劫持線程來掛起線程,而不是根據JIT生成的表來判斷線程是否到達了一個安全點。之因此如此,緣由是JIT生成表須要大量內存,會增大工做集,進而嚴重影響性能。

十5、大對象

  1. 任何85000字節或更大的對象都被自動視爲大對象
  2. 大對象從一個特殊的大對象堆中分配。這個堆中採起和前面小對象同樣的方式終結和釋放。可是,大對象永遠不壓縮(內存碎片整理),由於在堆中下移850000字節的內存塊會浪費太多CPU時間。
  3. 大對象老是被認爲是第2代的一部分,因此只能爲須要長時間存活的資源建立大對象。若是分配短期存活的大對象,將致使第2代被更頻繁地回收,進而會損害性能。
相關文章
相關標籤/搜索