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模式執行。