在面向對象的環境,每個類型都代表可供程序使用的「一種資源」
要使用這些資源,必須為資源分配內存
訪問一個資源有以下步驟
調用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移動了內存的對象後,需要刷新引用的位置
從每個根減去所引用的對象在內存中偏移的字節數
完成壓縮後,NextObjPtr指向最後一個幸存對象之後的位置,恢復所有線程的進行
代(Generation)
CLR的GC是基於代的垃圾回收器,它假設
對象越新,生存期越短
對象越老,生存期越長
回收堆的一部分的速度快於回收整個堆
運作邏輯
初始化:
托管堆在初始化時不包含對象,添加到堆的對象稱為第0代對象
新構造的對象
從未被GC檢查
CLR初始化時會為這些0代對象選擇一個預算容量,如果分配新對象時導致需求容量 > 預算容量,便啟動一次GC
第一次GC:
在首次GC後,存活下來的對象(經歷了一次GC檢查的對象)會成為「第1代對象」
GC過1次後,第0代對象被清空(該回收掉的回收掉,該升至第1代的升至第1代)
此後新增的對象,又會再次分配到第0代中
而第0代又一次超出預算後,GC會啟動
現在GC還會檢查第1代占用的內存
如果內存占用少於CLR為第1代選擇的預算,GC就會只檢查第0代的對象並進行回收
否則,GC會同時為第0代和第1代執行檢查和回收
忽略第1代的對象,可提升相當的性能
不必遍歷托管堆中的每個對象
如果根引用了老一代的對象,GC可以忽略該老對象內部的所有引用
除非老對象引用了新對象
GC會利用JIT內部的機制,該機制在對象的引用字段發生變化時,會設置一個對應的位標志
GC會知道自上一次GC以來,哪些老對象已被寫入
只有字段發生變化的老對象需要檢查是否引用了第0代中的新對象
在代際的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
用戶產生輸入 -> 線程被喚醒 -> 創建對象處理輸入 -> 返回 -> 創建的對象成為垃圾
服務器應用
客戶端請求 -> 創建對象代表客戶端執行工作 -> 結果發回客戶端 -> 線程回到線程池 -> 創建的對象成為垃圾
如果GC發現回收0代後存活的對象很多,就會增大0代的預算
GC次數減少
每次回收的內存更多
1代和2代的情況也類似
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方法會在第一次垃圾回收後執行,一次回收後,這些對象並沒有真正被回收,因為在「終結」時必須存活
從而導致這些對象,包括它們所引用的字段,一律會被提升到另一代,增大內存耗用
應避免為引用類型字段定義可終結對象
任何包裝了本機資源的類型都支持「終結」
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方法已準備好調用的一個對象
Finalize方法會由一個高優先級的CLR線程所調用
當freachable隊列為空時,該線程進入睡眠
一旦隊列出現記錄項,線程被喚醒,移除隊列裡每個元素的同時調用它們的Finalize方法
元素在freachable隊列時,其引用仍會被保留,因此此時它們仍然是「可達的」
當一個重寫了Finalize方法的對象「不可達」,進行首次GC時將它們的引用從終結列表移除,放入freachable隊列時,對象會重新變成「可達」
此時對象內存則無法被回收,甚至會因為「經歷了一次GC」,而提升至較老的一代
在第二次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發生時,內存不能壓縮
表中每個記錄項都包含兩種信息
對托管堆中的一個對象的引用
指出如何監視/控制對象的flag
GC對GC句柄表的使用(GC發生時,GC的行為)
GC標記所有可達對象,掃描句柄表,所有Normal/Pinned對象被看成「根」,標記這些對象及其裡面字段引用的對象
掃描句柄表,查找所有Weak記錄項,如果引用了未標記的對象,引用值改為null
GC掃描終結列表,把不可達對象從終結列表移至freachable隊列,使對象重新變成可達(復活)
掃描句柄表,查找所有WeakTrackResurrection記錄項(由freachable隊列的記錄項所引用),如果引用了未標記的對象,引用值改為null
GC壓縮內存,碎片整理
Pinned對象不會「移動」
參考書目
- 《CLR via C#》(第4版) Jeffrey Richter