學習筆記- GC與記憶體管理
深入GC與記憶體管理
託管堆中存放引用型別物件,因此GC的記憶體管理的目標主要都是引用型別物件,本文中涉及的物件如無明確說明都指的是引用型別物件。
物件建立及生命週期
一個物件的生命週期簡單概括就是:建立>使用>釋放,在.NET中一個物件的生命週期:
- new建立物件並分配記憶體
- 物件初始化
- 物件操作、使用
- 資源清理(非託管資源)
- GC垃圾回收
那其中重要的一個環節,就是物件的建立,大部分的物件建立都是開始於關鍵字new。為什麼說是大部分呢,因為有個別引用型別是由專門IL指令的,比如string有ldstr指令(參考前面的文章:
引用物件都是分配在託管堆上的, 先來看看託管堆的基本結構,如下圖,託管堆中的物件是順序存放的,託管堆維護著一個指標NextObjPtr,它指向下一個物件在堆中的分配位置。
建立一個新物件的主要流程:
以題目2中的程式碼為例,模擬一個物件的建立過程:
public class User { public int Age { get; set; } public string Name { get; set; } public string _Name = "123" + "abc"; public List<string> _Names; }
- 物件大小估算,共計40個位元組:
- 屬性Age值型別Int,4位元組;
- 屬性Name,引用型別,初始為NULL,4個位元組,指向空地址;
- 欄位_Name初始賦值了,由前面的文章(.NET面試題解析(03)-string與字串操作)可知,程式碼會被編譯器優化為_Name=”123abc”。一個字元兩個位元組,字串佔用2×6+8(附加成員:4位元組TypeHandle地址,4位元組同步索引塊)=20位元組,總共記憶體大小=字串物件20位元組+_Name指向字串的記憶體地址4位元組=24位元組;
- 引用型別欄位List<string> _Names初始預設為NULL,4個位元組;
- User物件的初始附加成員(4位元組TypeHandle地址,4位元組同步索引塊)8個位元組;
- 記憶體申請: 申請44個位元組的記憶體塊,從指標NextObjPtr開始驗證,空間是否足夠,若不夠則觸發垃圾回收。
- 記憶體分配: 從指標NextObjPtr處開始劃分44個位元組記憶體塊。
- 物件初始化: 首先初始化物件附加成員,再呼叫User物件的建構函式,對成員初始化,值型別預設初始為0,引用型別預設初始化為NULL;
- 託管堆指標後移: 指標NextObjPtr後移44個位元組。
- 返回記憶體地址: 返回物件的記憶體地址給引用變數。
GC垃圾回收
GC是垃圾回收(Garbage Collect)的縮寫,是.NET核心機制的重要部分。她的基本工作原理就是遍歷託管堆中的物件,標記哪些被使用物件(那些沒人使用的就是所謂的垃圾),然後把可達物件轉移到一個連續的地址空間(也叫壓縮),其餘的所有沒用的物件記憶體被回收掉。
首先,需要再次強調一下託管堆記憶體的結構,如下圖,很明確的表明了,只有GC堆才是GC的管轄區域,關於載入堆在前面文中有提到過(.NET面試題解析(04)-型別、方法與繼承)。GC堆裡面為了提高記憶體管理效率等因素,有分成多個部分,其中 兩個主要部分:
- 0/1/2代:代齡(Generation)在後面有專門說到;
- 大物件堆(Large Object Heap),大於85000位元組的大物件會分配到這個區域,這個區域的主要特點就是:不會輕易被回收;就是回收了也不會被壓縮(因為物件太大,移動複製的成本太高了);
圖3(Figure-3)
什麼是垃圾?簡單理解就是沒有被引用的物件。
垃圾回收的基本流程包含以下三個關鍵步驟:
① 標記
先假設所有物件都是垃圾,根據應用程式根指標Root遍歷堆上的每一個引用物件,生成可達物件圖,對於還在使用的物件(可達物件)進行標記(其實就是在物件同步索引塊中開啟一個標示位)。
其中Root根指標儲存了當前所有需要使用的物件引用,他其實只是一個統稱,意思就是這些物件當前還在使用,主要包含:靜態物件/靜態欄位的引用;執行緒棧引用(區域性變數、方法引數、棧幀);任何引用物件的CPU暫存器;根引用物件中引用的物件;GC Handle table;Freachable佇列等。
② 清除
針對所有不可達物件進行清除操作,針對普通物件直接回收記憶體,而對於實現了終結器的物件(實現了解構函式的物件)需要單獨回收處理。清除之後,記憶體就會變得不連續了,就是步驟3的工作了。
③ 壓縮
把剩下的物件轉移到一個連續的記憶體,因為這些物件地址變了,還需要把那些Root跟指標的地址修改為移動後的新地址。
垃圾回收的過程示意圖如下:
垃圾回收的過程是不是還挺辛苦的,因此建議不要隨意手動呼叫垃圾回收GC.Collect(),GC會選擇合適的時機、合適的方式進行記憶體回收的。
關於代齡(Generation)
當然,實際的垃圾回收過程可能比上面的要複雜,如果沒次都掃描託管堆內的所有物件例項,這樣做太耗費時間而且沒有必要。分代(Generation)演算法是CLR垃圾回收器採用的一種機制,它唯一的目的就是提升應用程式的效能。分代回收,速度顯然快於回收整個堆。分代(Generation)演算法的假設前提條件:
1、大量新建立的物件生命週期都比較短,而較老的物件生命週期會更長
2、對部分記憶體進行回收比基於全部記憶體的回收操作要快
3、新建立的物件之間關聯程度通常較強。heap分配的物件是連續的,關聯度較強有利於提高CPU cache的命中率
如圖3,.NET將託管堆分成3個代齡區域: Gen 0、Gen 1、Gen 2:
- 第0代,最新分配在堆上的物件,從來沒有被垃圾收集過。任何一個新物件,當它第一次被分配在託管堆上時,就是第0代(大於85000的大物件除外)。
- 第1代,0代滿了會觸發0代的垃圾回收,0代垃圾回收後,剩下的物件會搬到1代。
- 第2代,當0代、1代滿了,會觸發0代、1代的垃圾回收,第0代升為第1代,第1代升為第2代。
大部分情況,GC只需要回收0代即可,這樣可以顯著提高GC的效率,而且GC使用啟發式記憶體優化演算法,自動優化記憶體負載,自動調整各代的記憶體大小。
非託管資源回收
.NET中提供釋放非託管資源的方式主要是:Finalize() 和 Dispose()。
Dispose():
常用的大多是Dispose模式,主要實現方式就是實現IDisposable介面,下面是一個簡單的IDisposable介面實現方式。
public class SomeType : IDisposable { public MemoryStream _MemoryStream; public void Dispose() { if (_MemoryStream != null) _MemoryStream.Dispose(); } }
Dispose需要手動呼叫,在.NET中有兩中呼叫方式:
//方式1:顯示介面呼叫 SomeType st1=new SomeType(); //do sth st1.Dispose(); //方式2:using()語法呼叫,自動執行Dispose介面 using (var st2 = new SomeType()) { //do sth }
第一種方式,顯示呼叫,缺點顯而易見,如果程式猿忘了呼叫介面,則會造成資源得不到釋放。或者呼叫前出現異常,當然這一點可以使用try…finally避免。
一般都建議使用第二種實現方式,他可以保證無論如何Dispose介面都可以得到呼叫,原理其實很簡單,using()的IL程式碼如下圖,因為using只是一種語法形式,本質上還是try…finally的結構。
Finalize() :終結器(解構函式)
首先了解下Finalize方法的來源,她是來自System.Object中受保護的虛方法Finalize,無法被子類顯示重寫,也無法顯示呼叫,是不是有點怪?。她的作用就是用來釋放非託管資源,由GC來執行回收,因此可以保證非託管資源可以被釋放。
- 無法被子類顯示重寫:.NET提供類似C++解構函式的形式來實現重寫,因此也有稱之為解構函式,但其實她只是外表和C++裡的解構函式像而已。
- 無法顯示呼叫:由GC來管理和執行釋放,不需要手動執行了,再也不用擔心猿們忘了呼叫Dispose了。
所有實現了終結器(解構函式)的物件,會被GC特殊照顧,GC的終止化佇列跟蹤所有實現了Finalize方法(解構函式)的物件。
- 當CLR在託管堆上分配物件時,GC檢查該物件是否實現了自定義的Finalize方法(解構函式)。如果是,物件會被標記為可終結的,同時這個物件的指標被儲存在名為終結佇列的內部佇列中。終結佇列是一個由垃圾回收器維護的表,它指向每一個在從堆上刪除之前必須被終結的物件。
- 當GC執行並且檢測到一個不被使用的物件時,需要進一步檢查“終結佇列”來查詢該物件型別是否含有Finalize方法,如果沒有則將該物件視為垃圾,如果存在則將該物件的引用移動到另外一張Freachable列表,此時物件會被複活一次。
- CLR將有一個單獨的高優先順序執行緒負責處理Freachable列表,就是依次呼叫其中每個物件的Finalize方法,然後刪除引用,這時物件例項就被視為不再被使用,物件再次變成垃圾。
- 下一個GC執行時,將釋放已經被呼叫Finalize方法的那些物件例項。
上面的過程是不是很複雜!是就對了,如果想徹底搞清楚,沒有捷徑,不要偷懶,還是去看書吧!
簡單總結一下:Finalize()可以確保非託管資源會被釋放,但需要很多額外的工作(比如終結物件特殊管理),而且GC需要執行兩次才會真正釋放資源。聽上去好像缺點很多,她唯一的優點就是不需要顯示呼叫。
有些程式設計意見或程式猿不建議大家使用Finalize,儘量使用Dispose代替,我覺得可能主要原因在於:第一是Finalize本身效能並不好;其次很多人搞不清楚Finalize的原理,可能會濫用,導致記憶體洩露。因此就乾脆別用了,其實微軟是推薦大家使用的,不過是和Dispose一起使用,同時實現IDisposable介面和Finalize(解構函式),其實FCL中很多類庫都是這樣實現的,這樣可以兼具兩者的優點:
- 如果呼叫了Dispose,則可以忽略物件的終結器,物件一次就回收了;
- 如果程式猿忘了呼叫Dispose,則還有一層保障,GC會負責物件資源的釋放;
效能優化建議
儘量不要手動執行垃圾回收的方法:GC.Collect()
垃圾回收的執行成本較高(涉及到了物件塊的移動、遍歷找到不再被使用的物件、很多狀態變數的設定以及Finalize方法的呼叫等等),對效能影響也較大,因此我們在編寫程式時,應該避免不必要的記憶體分配,也儘量減少或避免使用GC.Collect()來執行垃圾回收,一般GC會在最適合的時間進行垃圾回收。
而且還需要注意的一點,在執行垃圾回收的時候,所有執行緒都是要被掛起的(如果回收的時候,程式碼還在執行,那物件狀態就不穩定了,也沒辦法回收了)。
推薦Dispose代替Finalize
如果你瞭解GC記憶體管理以及Finalize的原理,可以同時使用Dispose和Finalize雙保險,否則儘量使用Dispose。
選擇合適的垃圾回收機制:工作站模式、伺服器模式
題目答案解析:
1. 簡述一下一個引用物件的生命週期?
- new建立物件並分配記憶體
- 物件初始化
- 物件操作、使用
- 資源清理(非託管資源)
- GC垃圾回收
2. 建立下面物件例項,需要申請多少記憶體空間?
public class User { public int Age { get; set; } public string Name { get; set; } public string _Name = "123" + "abc"; public List<string> _Names; }
40位元組記憶體空間,詳細分析文章中給出了。
3. 什麼是垃圾?
一個變數如果在其生存期內的某一時刻已經不再被引用,那麼,這個物件就有可能成為垃圾
4. GC是什麼,簡述一下GC的工作方式?
GC是垃圾回收(Garbage Collect)的縮寫,是.NET核心機制的重要部分。她的基本工作原理就是遍歷託管堆中的物件,標記哪些被使用物件(哪些沒人使用的就是所謂的垃圾),然後把可達物件轉移到一個連續的地址空間(也叫壓縮),其餘的所有沒用的物件記憶體被回收掉。
5. GC進行垃圾回收時的主要流程是?
① 標記:先假設所有物件都是垃圾,根據應用程式根Root遍歷堆上的每一個引用物件,生成可達物件圖,對於還在使用的物件(可達物件)進行標記(其實就是在物件同步索引塊中開啟一個標示位)。
② 清除:針對所有不可達物件進行清除操作,針對普通物件直接回收記憶體,而對於實現了終結器的物件(實現了解構函式的物件)需要單獨回收處理。清除之後,記憶體就會變得不連續了,就是步驟3的工作了。
③ 壓縮:把剩下的物件轉移到一個連續的記憶體,因為這些物件地址變了,還需要把那些Root跟指標的地址修改為移動後的新地址。
6. GC在哪些情況下回進行回收工作?
- 記憶體不足溢位時(0代物件充滿時)
- Windwos報告記憶體不足時,CLR會強制執行垃圾回收
- CLR解除安裝AppDomian,GC回收所有
- 呼叫GC.Collect
- 其他情況,如主機拒絕分配記憶體,實體記憶體不足,超出短期存活代的存段門限
7. using() 語法是如何確保物件資源被釋放的?如果內部出現異常依然會釋放資源嗎?
using() 只是一種語法形式,其本質還是try…finally的結構,可以保證Dispose始終會被執行。
8. 解釋一下C#裡的解構函式?為什麼有些程式設計建議裡不推薦使用解構函式呢?
C#裡的解構函式其實就是終結器Finalize,因為長得像C++裡的解構函式而已。
有些程式設計建議裡不推薦使用解構函式要原因在於:第一是Finalize本身效能並不好;其次很多人搞不清楚Finalize的原理,可能會濫用,導致記憶體洩露,因此就乾脆別用了
9. Finalize() 和 Dispose() 之間的區別?
Finalize() 和 Dispose()都是.NET中提供釋放非託管資源的方式,他們的主要區別在於執行者和執行時間不同:
- finalize由垃圾回收器呼叫;dispose由物件呼叫。
- finalize無需擔心因為沒有呼叫finalize而使非託管資源得不到釋放,而dispose必須手動呼叫。
- finalize不能保證立即釋放非託管資源,Finalizer被執行的時間是在物件不再被引用後的某個不確定的時間;而dispose一呼叫便釋放非託管資源。
- 只有class型別才能重寫finalize,而結構不能;類和結構都能實現IDispose。
另外一個重點區別就是終結器會導致物件復活一次,也就說會被GC回收兩次才最終完成回收工作,這也是有些人不建議開發人員使用終結器的主要原因。
10. Dispose和Finalize方法在何時被呼叫?
- Dispose一呼叫便釋放非託管資源;
- Finalize不能保證立即釋放非託管資源,Finalizer被執行的時間是在物件不再被引用後的某個不確定的時間;
11. .NET中的託管堆中是否可能出現記憶體洩露的現象?
是的,可能會。比如:
- 不正確的使用靜態欄位,導致大量資料無法被GC釋放;
- 沒有正確執行Dispose(),非託管資源沒有得到釋放;
- 不正確的使用終結器Finalize(),導致無法正常釋放資源;
- 其他不正確的引用,導致大量託管物件無法被GC釋放;
12. 在託管堆上建立新物件有哪幾種常見方式?
- new一個物件;
- 字串賦值,如string s1=”abc”;
- 值型別裝箱;
物件“釘”住(pin),讓它的記憶體地址固定,而不被垃圾回收掉,然後最後我們自己管理,自己釋放記憶體,這時候就需要GCHandle
Freachable 終結佇列
垃圾回收很複雜,不要隨意呼叫gc.collect,gc會選擇合適的方式和時機呼叫
分代 generation 是垃圾回收採用的一種機制,提高效能
物件型別可以理解為指標變數,指標變數儲存的是地址,32位機定址就是32位,所以是4位元組,不過這是線上程棧中佔用4個位元組,用來指向託管堆中實際內容的首地址,
任何型別的指標變數都是佔用4個位元組。