C#筆記 – 托管堆與垃圾回收
- 在面向對象的環境,每個類型都代表可供程序使用的「一種資源」
- 要使用這些資源,必須為資源分配內存
- 訪問一個資源有以下步驟
- 調用IL指令newobj(C#的new指令),為資源分配內存
- 初始化內存,調用實例構造器來設置資源的初始狀態
- 訪問成員(使用資源)
- 摧毀成員狀態,進行清理
- GC釋放內存
分配資源基礎
-
CLR要求所有對象都要從托管堆分配
- 進程初始化時,CLR划出一個地址空間區域作為托管堆
- 同時維護一個指針(NextObjPrt):該指針指向下一個對象在堆中的分配位置
- 該指針一開始指向托管堆的地址空間區域的基地址
- 區域被非垃圾對象填滿後,CLR會分配更多的區域
- 直至整個進程地址空間都被填滿
- 應用程序的內存受進程的虛擬地址空間限制
- 32位進程最多能分配1.5GB
- 64位進程最多能分配8TB
-
C#的new操作符導致CLR執行以下步驟
- 計算類型的字段的所需字節數
- 加上開銷字段的所需字節數
- 類型對象指針
- 同步塊索引
- 32位應用程序中,該兩個字段各需要8個字節
- 64位應用程序中,該兩個字段各需要16個字節
- CLR檢查托管堆空間是否有足夠空間
-
如有,則在NextObjPtr指針指向的地址放入對象,為對象分配的字節會被清零
- 調用類型的構造器,準備把對象引用返回至new操作符
- 返回前,NextObjPtr指針的值會加上對象占用的字節數得到一個新值,作為下個對象放入托管堆時的地址
-

-
如果沒有,則執行GC
- 基本的GC算法
-
引用跟蹤算法
- 只關心引用類型的變量,只有這種變量能引用堆上的對象
- 值類型變量直接包含值類型實例
- 所有引用類型的變量都稱之為「根」
- 靜態字段引用的對象一直存在,直到用於加載類型的AppDomain卸載為止
- 濫用靜態字段/讓靜態字段引用的對象過於龐大容易導致內存泄漏
- 只關心引用類型的變量,只有這種變量能引用堆上的對象
-
步驟:
-
開始階段:
- 會暫停進程中的所有線程,防止線程在CLR檢查期間訪問對象並改變其狀態
-
標記階段:
- CLR遍歷堆中的所有對象,將同步塊索引中的位設為0,表明所有對象都應刪除
- 檢查所有活動根,查看它們引用了哪些對象
- 如果一個根包含null,CLR忽略這個根並繼續檢查下個根
- 如果根引用了堆上的對象,CLR會標記這個對象,將其同步塊索引中的位設為1
- 然後CLR會繼續檢查對象中的根,標記這些根引用的對象
- 如果發現對象已被標記,就不重新檢查對象的字段
- 檢查完畢後,堆中的對象要麼已標記,要麼未標記
- 已標記的對象不能被GC,因為至少有一個根在引用它,可以通過該引用它的變量抵達(訪問)它,是「可達的」
- 未標記的對象代表不存在可以抵達<(訪問)它的變量,是「不可達的」
-
一旦根離開作用域,對象就會變得「不可達」,GC接著就會回收其內存
class Program { static void Main(string[] args) { Timer t = new Timer(TimerCallback, null, 0, 2000); Console.ReadLine(); } static void TimerCallback(Object o) { Console.WriteLine("In TimerCallback: " + DateTime.Now); GC.Collect(); } } -
如不用任何特殊編譯器開關編譯該代碼並運行,TimerCallback只會運行一次
- 因為GC發現在初始化後,Main方法再也沒有用過變量t,t會被回收並被停止觸發
- 除非在另外一個地方,顯式調用其Dispose方法,t才可以活到被釋放的一刻
-
-
壓縮階段:
- CLR壓縮所有幸存下來的對象,使它們占用連續的空間
- 恢復引用的「局部性」,減少應用程序的工作集
- 解決空間碎片化的問題
- CLR壓縮所有幸存下來的對象,使它們占用連續的空間
-
-
- 基本的GC算法
-

代(Generation)
- CLR的GC是基於代的垃圾回收器,它假設
- 對象越新,生存期越短
- 對象越老,生存期越長
- 回收堆的一部分的速度快於回收整個堆
運作邏輯
- 初始化:
- 托管堆在初始化時不包含對象,添加到堆的對象稱為第0代對象
- 新構造的對象
- 從未被GC檢查
- CLR初始化時會為這些0代對象選擇一個預算容量,如果分配新對象時導致需求容量 > 預算容量,便啟動一次GC
- 托管堆在初始化時不包含對象,添加到堆的對象稱為第0代對象
- 第一次GC:
- 在首次GC後,存活下來的對象(經歷了一次GC檢查的對象)會成為「第1代對象」
- GC過1次後,第0代對象被清空(該回收掉的回收掉,該升至第1代的升至第1代)
- 此後新增的對象,又會再次分配到第0代中
- 而第0代又一次超出預算後,GC會啟動
- 現在GC還會檢查第1代占用的內存
- 如果內存占用少於CLR為第1代選擇的預算,GC就會只檢查第0代的對象並進行回收
- 否則,GC會同時為第0代和第1代執行檢查和回收
- 現在GC還會檢查第1代占用的內存
- 忽略第1代的對象,可提升相當的性能
- 不必遍歷托管堆中的每個對象
- 如果根引用了老一代的對象,GC可以忽略該老對象內部的所有引用
- 除非老對象引用了新對象
- GC會利用JIT內部的機制,該機制在對象的引用字段發生變化時,會設置一個對應的位標志
- GC會知道自上一次GC以來,哪些老對象已被寫入
- 只有字段發生變化的老對象需要檢查是否引用了第0代中的新對象
- GC會利用JIT內部的機制,該機制在對象的引用字段發生變化時,會設置一個對應的位標志
- 不必遍歷托管堆中的每個對象
- 在代際的GC中,越老的對象活得越長,而由於第1代的對象有可能會被跳過,不被GC檢查,因此,1代對象可能會有一些不可達的對象留存著
- 1代的對象會在每次GC後緩慢增長,而當1代的內存占用 > CLR為第1代選擇的預算後,GC將會清理第1代和第0代的對象
- 第二次GC:
- 第1代對象被清理後,存活的對象會升至第2代(經過了2次或多次檢查)
- 托管堆只支持3代(第0代、第1代、第2代)
- CLR的GC是自調節的
- 初始化時,CLR會為每一代選擇預算
- 而每次回收時,也會根據所回收的代數的情況,重新評估所需要的預算
- 如果GC發現回收0代後存活下來的對象很少,就可能減少0代的預算
- 分配空間減少 -> 回收更頻繁
- 事實上,如果0代中所有對象都是垃圾,GC就不必壓縮任何內存,NextObjPtr指針指回第0代的起始處
- 如果應用程序的線程大多數時候都在棧頂閒置,GC工作會更有效率
- 線程有事做就被喚醒,創建一組短期存活的對象,返回,然後繼續睡眠。如:
- GUI
- 用戶產生輸入 -> 線程被喚醒 -> 創建對象處理輸入 -> 返回 -> 創建的對象成為垃圾
- 服務器應用
- 客戶端請求 -> 創建對象代表客戶端執行工作 -> 結果發回客戶端 -> 線程回到線程池 -> 創建的對象成為垃圾
- GUI
- 線程有事做就被喚醒,創建一組短期存活的對象,返回,然後繼續睡眠。如:
- 如果應用程序的線程大多數時候都在棧頂閒置,GC工作會更有效率
- 如果GC發現回收0代後存活的對象很多,就會增大0代的預算
- GC次數減少
- 每次回收的內存更多
- 1代和2代的情況也類似
- 如果GC發現回收0代後存活下來的對象很少,就可能減少0代的預算

GC觸發條件
- 第0代超過預算
- 顯式調用GC.Collect
- 大多數時候避免使用,除非發生了一些具有特殊性質的事件
- 非重複性
- 大量舊對象死亡
- 大多數時候避免使用,除非發生了一些具有特殊性質的事件
- Windows報告低內存情況
- CLR正在卸載AppDomain
- CLR認為其中一切都不是根,執行涵蓋所有代的GC
- 仍然會壓縮/釋放內存
- CLR正在關閉
- 進程正常終止(不是從外部,如任務管理器終止)
- CLR認為其中一切都不是根,執行涵蓋所有代的GC
- 不會壓縮/釋放內存
大對象
- 一般超過85000字節(未來可能有更改,不是常量)的會被認為是大對象
- 大對象不在小對象的地址空間分配,在進程地址空間的其他地方分配
- 目前GC不壓縮大對象,在內存中移動它們代價過高
- 大對象總是第2代,所以只能為需要長時間存活的資源創建大對象
- 分配短時間存活的大對象會導致第2代會頻繁回收,損害性能
- 大對象一般是大字符串(XML/Json)、I/O操作的字節數組等等
GC模式
- GC模式的選擇以進程為單位,一旦決定了模式,進程結束前不會改變
- 兩個模式
- 工作站
- 針對客戶端
- GC造成的線程掛起時間很短
- 假定機器上運行的其他程序不會消耗太多CPU資源
- 服務器
- 針對服務器
- 假定機器上沒有運行其他程序
- 所有CPU用來輔助GC
- 托管堆會被拆分成幾個區域,每個CPU一個
- GC在每個CPU上運行一個特殊線程,每個線程和其他線程并發回收它自己的區域
- 工作站
- 兩個子模式
- 并發
- GC有一個額外的後台線程,能在應用程序運行時并發標記對象
- 消耗比非并發大
- 非并發
- 并發
需要特殊清理的類型
-
有的類型除了需要內存外,還需要本機資源
- 如FileStream類
-
包含本機資源的類型被GC時,GC會回收對象在托管堆中使用的內存,但是由於GC對本機資源一無所知,因此會導致本機資源的泄漏
- CLR提供了「終結」機制,允許對象被判定為垃圾後,在對象「真正」回收前執行一些代碼
- 實際上,被視為垃圾的可終結對象會經歷兩次垃圾回收
- Finalize方法會在第一次垃圾回收後執行,一次回收後,這些對象並沒有真正被回收,因為在「終結」時必須存活
- 從而導致這些對象,包括它們所引用的字段,一律會被提升到另一代,增大內存耗用
- 應避免為引用類型字段定義可終結對象
- 實際上,被視為垃圾的可終結對象會經歷兩次垃圾回收
- 任何包裝了本機資源的類型都支持「終結」
- CLR提供了「終結」機制,允許對象被判定為垃圾後,在對象「真正」回收前執行一些代碼
-
System.Object定義了Finalize虛方法,如果對象的類重寫了該方法,對象被判定為垃圾後,會執行該方法
-
public class DisposeType { ~DisposeType() { Console.WriteLine("Target Disposed"); } } -
重寫該方法後,C#編譯器會在元數據中生成一個Finalize方法
-
Method #1 (06000004) ------------------------------------------------------- MethodName: Finalize (06000004) Flags : [Family] [Virtual] [HideBySig] [ReuseSlot] (000000c4) RVA : 0x000020c0 ImplFlags : [IL] [Managed] (00000000) CallCnvntn: [DEFAULT] hasThis ReturnType: Void No arguments.
-
-
在IL中,該方法裡的代碼放到一個try塊中,並在finally塊中放入一個base.Finalize的調用
-
.method family hidebysig virtual instance void Finalize() cil managed { .override [System.Runtime]System.Object::Finalize // 程式碼大小 24 (0x18) .maxstack 1 IL_0000: nop .try { IL_0001: nop IL_0002: ldstr "Target Disposed" IL_0007: call void [System.Console]System.Console::WriteLine(string) IL_000c: nop IL_000d: leave.s IL_0017 } // end .try finally { IL_000f: ldarg.0 IL_0010: call instance void [System.Runtime]System.Object::Finalize() //base.Finalize IL_0015: nop IL_0016: endfinally } // end handler IL_0017: ret } // end of method DisposeType::Finalize
-
-
-
Finalize的執行時間是無法控制的
- Finalize只有在GC完成後才會執行,而GC又只有在程序請求更多內存時才發生
- 多個Finalize的調用順序也無法控制
IDisposable接口
- 包裝了本機資源的類一般都有實現IDisposable接口,從而讓開發者手動去釋放其本機資源
- 實際上,並非一定要調用Dispose才能保證本機資源得到清理
- 本機資源的清理最終總會發生,調用Dispose只是控制這個動作的發生時間
- 調用Dispose本身不會將托管對象從托管堆刪除,只有GC後,內存才會得以回收
- Dispose的使用最好是在確定必須清理資源的時候
-
如果確定要顯式調用Dispose,最好將其放在異常處理的finally塊中,保證清理代碼得到執行
-
byte[] bytes = new byte[] { 1, 2, 3, 4 }; FileStream fs = new FileStream("Test.dat", FileMode.Create); try { fs.Write(bytes, 0, bytes.Length); } finally { if(fs != null) { fs.Dispose(); } }
-
-
也可以使用using語句
-
using語句初始化一個對象並將其引用保存至一個變量中
-
編譯using代碼時,編譯器自動生成對應的try和finally塊,並在finally塊中生成代碼將變量轉型為IDisposable並調用Dispose方法
-
因此,using語句只能用於那些實現了IDisposable接口的類型
-
byte[] bytes = new byte[] { 1, 2, 3, 4 }; using (FileStream fs2 = new FileStream("Test.dat", FileMode.Create)) { fs2.Write(bytes, 0, bytes.Length); } File.Delete("Test.dat"); -
IL_001e: stloc.1 .try { IL_001f: nop IL_0020: ldloc.1 IL_0021: ldloc.0 IL_0022: ldc.i4.0 IL_0023: ldloc.0 IL_0024: ldlen IL_0025: conv.i4 IL_0026: callvirt instance void [System.Runtime]System.IO.Stream::Write(uint8[], int32, int32) IL_002b: nop IL_002c: nop IL_002d: leave.s IL_003a } // end .try finally { IL_002f: ldloc.1 IL_0030: brfalse.s IL_0039 IL_0032: ldloc.1 IL_0033: callvirt instance void [System.Runtime]System.IDisposable::Dispose() IL_0038: nop IL_0039: endfinally } // end handler
-
-
終結的內部工作原理
- 當新對象被創建時,new操作從堆中分配內存
- 如果對象類型重寫了System.Object的Finalize方法,在其實例構造器被調用之前,會將指向該對象的指針放到「終結列表」(finalization list)裡
- 「終結列表」
- 由GC控制的一個內部數據結構,每一項都指向一個需要在回收其內存前調用其Finalize方法的對象
- 「終結列表」

- 第一次GC時,不可達的對象(D、F、H)被判定為垃圾
-
- GC掃描終結列表,查找被判定為垃圾的對象是否在列表裡(代表有重寫終結器)
- 在列表裡的話,會將對象引用從終結列表中移除並添加到freachable(F-reachable, F for Finalize)隊列中
- 「freachable」隊列
- GC的一種內部數據結構,隊列每個引用都代表其Finalize方法已準備好調用的一個對象
- 「freachable」隊列
- 在列表裡的話,會將對象引用從終結列表中移除並添加到freachable(F-reachable, F for Finalize)隊列中
- GC掃描終結列表,查找被判定為垃圾的對象是否在列表裡(代表有重寫終結器)

- Finalize方法會由一個高優先級的CLR線程所調用
- 當freachable隊列為空時,該線程進入睡眠
- 一旦隊列出現記錄項,線程被喚醒,移除隊列裡每個元素的同時調用它們的Finalize方法
- 元素在freachable隊列時,其引用仍會被保留,因此此時它們仍然是「可達的」
- 當一個重寫了Finalize方法的對象「不可達」,進行首次GC時將它們的引用從終結列表移除,放入freachable隊列時,對象會重新變成「可達」
- 此時對象內存則無法被回收,甚至會因為「經歷了一次GC」,而提升至較老的一代
- 當一個重寫了Finalize方法的對象「不可達」,進行首次GC時將它們的引用從終結列表移除,放入freachable隊列時,對象會重新變成「可達」
- 元素在freachable隊列時,其引用仍會被保留,因此此時它們仍然是「可達的」
- 在第二次GC時,「已經在上次GC中調用過Finalize的對象」就會變成真正的垃圾,因為freachable隊列也不再保留它們的引用,此時內存才會真正被回收

- 然而,由於可終結對象需要執行兩次GC才能回收內存,而對象可能會被提升至另一代,在提升至另一代的情況下,可能就不是兩次GC就能回收的事情了
對象生存期的控制和監視
- CLR為每個AppDomain提供了一個GC句柄表,允許應用程序監視/控制對象生存期
- 該表創建時是空白的,使用GCHandle.Alloc靜態方法新增記錄項
- GCHandle.Alloc接受1~2個參數
- 對象引用
- GCHandleType
- 監視生存期,檢測GC甚麼時候判定對象不可達
- Weak:執行時對象的Finalize方法的執行狀態不確定,內存可能還沒回收
- WeakTrackResurrection:執行時對象的Finalize方法已經執行,且內存已經回收
- 控制生存期,告訴GC,即使沒有根引用該對象,該對象也必須留在內存中
- Normal:GC發生時,內存可以壓縮
- Pinned:GC發生時,內存不能壓縮
- 監視生存期,檢測GC甚麼時候判定對象不可達
- GCHandle.Alloc接受1~2個參數
- 表中每個記錄項都包含兩種信息
- 對托管堆中的一個對象的引用
- 指出如何監視/控制對象的flag
- 該表創建時是空白的,使用GCHandle.Alloc靜態方法新增記錄項
- GC對GC句柄表的使用(GC發生時,GC的行為)
- GC標記所有可達對象,掃描句柄表,所有Normal/Pinned對象被看成「根」,標記這些對象及其裡面字段引用的對象
- 掃描句柄表,查找所有Weak記錄項,如果引用了未標記的對象,引用值改為null
- GC掃描終結列表,把不可達對象從終結列表移至freachable隊列,使對象重新變成可達(復活)
- 掃描句柄表,查找所有WeakTrackResurrection記錄項(由freachable隊列的記錄項所引用),如果引用了未標記的對象,引用值改為null
- GC壓縮內存,碎片整理
- Pinned對象不會「移動」
參考書目
- 《CLR via C#》(第4版) Jeffrey Richter