CLR via C#-托管堆和垃圾回收
托管堆基礎
訪問類型的資源
面向對象的環境中,每個類型都代表可供程序使用的一種資源。要使用這些資源,必須為代表資源的類型分配內存。以下是訪問一個資源所需的步驟。
①調用IL指令newobj,為代表資源的類型分配內存,由new操作符來完成。
②初始化內存,設置資源的初始狀態並使資源可用,類型的實例構造器負責設置初始狀態。
③訪問類型的成員來使用資源。
④摧毀資源的狀態以進行清理。
⑤釋放內存,垃圾回收器獨自負責這一步。
托管堆為開發人員提供了一個簡化的編程模型,分配並初始化資源並直接使用。
大多數類型都無需資源清理,垃圾回收器會自動釋放內存。在類中也可以調用Dispose方法立即進行垃圾回收。
從托管堆分配資源
CLR要求所有對象都從托管堆分配
進程初始化時,CLR劃出一個地址空間區域作為托管堆。
NextObjPtr指針
CLR還要維護一個指針,稱為NextObjPtr,這個指針指向下一個對象再堆中的分配位置。剛開始時,NextObjPtr設為地址空間區域的基地址。
一個區域被非垃圾對象填滿後,CLR會分配更多的區域,這個過程一直重復,直至整個進程地址空間都被填滿。
所以你的應用程序的內存受進程的虛擬地址空間的限制,32位進程最多能分配1.5GB,64位進程最多能分配8TB。
new操作符使CLR執行的工作
①計算類型的字段,包括從基類繼承的字段所需的字節數。
②加上對象的開銷所需的字節數。每個對象都要兩個開銷字段,類型對象指針和同步塊索引。
③CLR檢查區域中是否有分配對象所需要的字節數,如果托管堆空間足夠,就在NextObjPtr指針指向的地址放入對象,為對象分配的字節會被清零。
④接著調用類型的實例構造器,為this參數傳遞NextObjPtr,new操作符返回對象引用。返回之前,NextObjPtr指針的值會加上對象占用的字節數來得到一個新值,即下個對象放入托管堆時的地址。
下圖展示了包含三個對象的一個托管堆,如果要分配新對象,他將放在NextObjPtr指針指向的位置,緊接在對象C後。
垃圾回收算法
引用計數法
許多系統采用了引用計數法來管理對象的生存期,堆上的每個對象都維護著一個內存字段來統計程序中多少部分在使用對象。
如果有些部分不再需要的對象,就遞減對象的計數字段。計數字段變成0,對象就可以從內存中刪除了。
但是面對互相持有的情況,計數器就會永遠不為0。鑒於引用計數垃圾回收算法存在的問題,CLR改為使用一種引用跟蹤法。
引用跟蹤法
引用跟蹤算法只關心引用類型的變量,因為只有這種變量才能引用堆上的對象,值類型變量直接包含值類型實例。我們將所有的引用類型的變量都稱為根。
①暫停所有線程
垃圾回收時首先暫停進程中所有線程,防止檢查期間訪問對象並改變其狀態。
②垃圾回收標記階段
CLR遍歷堆中所有對象,將同步塊索引字段中的一位設為0,表示此對象應刪除。
③CLR檢查所有活動根
查看他們引用了哪些對象。如果一個根包含null,CLR忽略這個根並繼續檢查下個根。
任何根如果引用了堆上的對象,CLR都會標記那個對象,將該對象的同步塊索引中的位設為1。
一個對象被標記後,CLR會再檢查那個對象中的根,標記他們引用的對象。
如果發現對象已經被標記,就不重新檢查對象的字段,避免了因為循環引用而產生死循環。
檢查完畢後堆中對象已標記的對象不能被垃圾回收,這種對象被稱為可達的,未標記的對象是不可達的。
④垃圾回收壓縮階段
CLR將堆中已標記的對象壓縮,使它們占用連續的內存空間。
壓縮後,根引用的還是對象最初在內存中的位置,所以CLR還要從每個根減去所引用的對象在內存中偏移的字節數。
包裝根引用的還是之前的對象,只是對象在內存中換了位置。
⑤移動NextObjPtr指針
壓縮結束後NextObjPtr指針指向最後一個幸存對象之後的位置。
下一個對象將分配在這個位置。
⑥CLR恢復應用程序的所有線程
如果CLR在一次GC之後回收不了內存,而且進程中沒有空間來分配新的GC區域,就說明該進程的內存已耗盡。
代
CLR的垃圾回收是基於代的垃圾回收器,並遵循以下原則
①對象越新,生存期越短。
②對象越老,生存期越長。
③回收堆的一部分,速度快於回收整個堆。
代的原理
①托管堆在初始化時不包含對象,添加到堆的對象稱為第0代對象,也就是那些新構造的對象,GC從未檢查過他們。
下圖展示一個新啟動的應用程序,他分配了五個對象,運行一段時間後,對象C和E變得不可達。
CLR初始化時位第0代對象選擇一個預算容量,以KB位單位。如果分配一個新對象造成第0代超過預算就必須啟動一次垃圾回收。
②假設A到E剛好用完第0代的空間,那麽分配對象F就必須啟動垃圾回收。
垃圾回收器判斷C和E是垃圾,壓縮D使之與B相鄰,在垃圾回收中存活的對象B和D現在成為第1代對象。
一次垃圾回收後第0代就不包含任何對象了,新對象會分配到第0代中。
③程序繼續運行,分配了對象F到K,運行一段時間後B,H和J變得不可達。
④假定現在分配新對象L會造成第0代超出預算,必須進行垃圾回收。垃圾回收器根據預算容量檢查代。
對象越新生存期越短。因此第0代包含更多垃圾的可能性更大,能回收更多的內存。
選擇忽略第1代中的對象,可以加快垃圾回收速度,不必遍歷托管堆中的每個對象。
如果根或對象引用了老一代的某個對象,垃圾回收器就可以忽略老對象內部的所有引用,能在更短的時間內構造好可達對象圖。
為了確保對老對象的已更新字段進行檢查,垃圾回收器在對象的引用字段發生變化時,會設置一個對應的位標誌。
這樣就知道自上次垃圾回收以來那些老對象的字段是否發生變化,對變化的老對象檢查是否引用了第0代中的任何對象。
所有幸存的第0代對象都成為了第1代的一部分。即使對象B已經不可達,但也沒有被垃圾回收。
⑤假設應用程序繼續運行,分配對象L到O,此外停止使用對象G、L和M使他們不可達。
⑥假設分配對象P導致第0代超過預算,垃圾回收發生。
第1代中所有的對象占據的內存仍小於預算所以垃圾回收器再次決定只回收第0代。
⑦第1代正在緩慢增長,假定第1代的增長導致他的所有對象占用了全部預算。
這時應用程序繼續運行,並分配對象P到S,使第0代對象達到他的預算容量。
⑧應用程序試圖分配對象T時,由於第0代已經滿了,所以必須開始垃圾回收。
但這一次垃圾回收器發現第1代占用了太多內存,以至於用完了預算。
所以垃圾回收器覺得需要檢查第1代和第0代中的所有對象。
和之前一樣,第0代的幸存者被提升到第1代,第1代的幸存者被提升至第2代,第0代再次空出來了。
托管堆只支持三代
CLR初始化會為每一代選擇預算,但是CLR的垃圾回收是自調節的,垃圾回收器會在執行GC是了解應用程序的行為。
假如應用程序構造了許多對象,但每個對象用的時間都很短,此時就會對第0代垃圾回收。
如果垃圾回收器發現在回收第0代後存活下來的對象很少,就可能減少第0代的預算。
分配空間的減少意味著垃圾回收更加頻繁,但垃圾回收器每次工作量也小了。
例如第0代中所有對象都是垃圾,只需讓NextObjPtr指針回到第0代起始處即可。
同樣的,垃圾回收器發現在回收第0代後存活下來的對象很多,就可能增加第0代的預算。這對第1代和第2代同樣適用。
垃圾回收觸發條件
①代碼顯式調用Syste.GC的靜態Collect方法
②Windows報告低內存情況
③CLR正在寫卸載AppDomain
④CLR正在關閉,進程終止
大對象
CLR將對象分為大對象和小對象。85000字節以上的對象是大對象。
①大對象不是在小對象的地址空間分配,而是在進程地址空間的其他地方分配。
②目前的GC不壓縮大對象,在內存中移動他們代價過高。
③大對象總是第2代,不可能是第1代或第0代。所以只能為需要長時間存活的資源創建大對象。
大對象一般是大字符串,比如XML、JSON或者I/O操作的字節數組。
垃圾回收模式
CLR啟動時會選擇一個GC模式,進程中之前不會改變。
①工作站,針對客戶端優化,GC延時低。
②服務器,針對服務器端應用程序。
應用程序默認以工作站GC模式運行。
CLR via C#-托管堆和垃圾回收