本文內容是本人參考多本經典C 書籍和一些前輩的博文做的總結 儘管.NET運行庫負責處理大部分記憶體管理工作,但C 程式員仍然必須理解記憶體管理的工作原理,瞭解如何高效地處理非托管的資源,才能在非常註重性能的系統中高效地處理記憶體。 C 編程的一個優點就是程式員不必擔心具體的記憶體管理,垃圾回收器會自動處理所 ...
本文內容是本人參考多本經典C#書籍和一些前輩的博文做的總結
儘管.NET運行庫負責處理大部分記憶體管理工作,但C#程式員仍然必須理解記憶體管理的工作原理,瞭解如何高效地處理非托管的資源,才能在非常註重性能的系統中高效地處理記憶體。
C#編程的一個優點就是程式員不必擔心具體的記憶體管理,垃圾回收器會自動處理所有的記憶體清理工作。用戶可以得到近乎像C++語言那樣的效率,而不必考慮像C++中複雜的記憶體管理工作。但我們仍需要理解程式在後臺如何處理記憶體,才有助於提高應用程式的速度和性能。
先瞭解一下Windows系統中的虛擬定址系統:
該系統把程式可用的記憶體地址映射到硬體記憶體中的實際地址上,在32位處理器上的每個進程都可以使用4GB的硬體記憶體(64位處理器更大),這個4GB的記憶體包含了程式的所有部分(包括可執行代碼、代碼載入的所有DLL、程式運行時使用的所有變數的內容)
這個4GB的記憶體稱為虛擬地址空間,或虛擬記憶體。其中的每個存儲單元都是從0開始排序的。要訪問存儲在記憶體的某個空間中的一個值,就需要提供表示該存儲單元的數字。編譯器負責把變數名轉換為處理器可以理解的記憶體地址。
值類型和引用類型在C#中的數據類型分為值類型和引用類型,對他們使用了不同但又相似的記憶體管理機制。
1.值數據類型的記憶體管理
在進程的虛擬記憶體中,有一個區域稱為棧。C#的值類型數據、傳遞給方法的參數副本都存儲在這個棧中。在棧中存儲數據時,是從高記憶體地址向低記憶體地址填充的。
操作系統維護一個變數,稱為棧指針。棧指針為當前變數所占記憶體的最後一個位元組地址,棧指針會根據需要隨時調整,它總是會調整為指向棧中下一個空閑存儲單元的地址。當有新的記憶體需求時,就根據當前棧指針的值開始往下來為該需求分配足夠的記憶體單元,分配完後,棧指針更新為當前變數所占記憶體的最後一個位元組地址,它將在下一次分配記憶體時調整為指向下一個空閑單元。
如:int a= 10;
聲明一個整型的變數需要32位,也就是4個位元組記憶體,假設當前棧指針為89999,則系統就會為變數a分配4個記憶體單元,分別為89996~89999,之後,棧指針更新為89995
double d = 20.13;
//需要64位,也就是8個位元組記憶體,存儲在89988~89995
棧的工作方式是先進後出(FIFO):在釋放變數時,總是先釋放後面聲明的變數(後面分配記憶體)。
2.引用數據類型的記憶體管理
引用類型對象的引用存儲在棧中(占4個位元組的空間),而它的實際數據存儲在主托管堆或大對象堆上,托管堆是可用的4GB虛擬記憶體中的另一個記憶體區域。
大對象堆:在.NET下,因為壓縮較大對象(大於85000個位元組)很影響性能,所以為它們分配了自己的托管堆。.NET垃圾回收器不對大對象堆執行壓縮過程。
如:Person arabel= new Person();
聲明變數arabel時,在棧上為該變數分配4個位元組的空間以存儲一個引用,new運算符為對象Person對象在堆上分配空間,然後把該空間的地址賦給變數arabel,而構造函數則用來初始化。
.NET運行庫為了給對象arabel分配空間,需要搜索堆,選取第一個未使用的且足夠容納對象所有數據的連續塊。但垃圾回收器程式在回收堆中所有無引用的對象後,會執行壓縮操作,即:把剩下的有用對象移動到堆的端部,挨在一起形成一個連續的記憶體塊,並更新所有對象的引用為新地址,同時更新堆指針,方便為下一個新對象分配堆空間。
一般情況下,垃圾回收器在.NET運行庫認為需要它時運行。
System.GC
類是一個表示垃圾回收器的.NET類,可以調用System.GC.Collect()
方法,強迫垃圾回收器在代碼的某個地方運行。
當代碼中有大量的對象剛剛取消引用,就比較適合調用垃圾回收器,但不能保證所有未引用的對象都能從堆中刪除。
垃圾回收器運行時,它實際上會降低程式的性能,因為在它執行期間,將會暫停應用程式的其它所有線程。
但.NET垃圾回收器使用了"世代垃圾回收器(generational)":
托管堆分為幾個部分:第0代,第1代,第2代,第3代,....
所有新對象都被分配在第0代部分,在給新對象分配堆空間時,如果超出了第0代對應的部分的容量(),或者調用了GC.Collect()方法,就會開始進行垃圾回收。
每當垃圾回收器執行壓縮時,第0代部分留下來的對象將會被移動到第1代上,此時第0代部分就變成空,用來放置下一個新對象。
類似的,當第一代滿時,也會進行壓縮,剩下對象移到下一代。
托管堆有一個堆指針,功能和棧指針類似。
3.總結:
使用.Net框架開發程式的時候,我們無需關心記憶體分配問題,因為有GC這個大管家給我們料理一切。C#中棧是編譯期間就分配好的記憶體空間,因此你的代碼中必須就棧的大小有明確的定義;堆是程式運行期間動態分配的記憶體空間,你可以根據程式的運行情況確定要分配的堆記憶體的大小
C#程式在CLR上運行的時候,記憶體從邏輯上劃分兩大塊:棧,堆。這倆基本元素組成我們C#程式的運行環境
棧通常保存著我們代碼執行的步驟,如 AddFive()
方法,int pValue
變數,int result
變數等。而堆上存放的則多是對象,數據等。我們可以把棧想象成一個接著一個疊放在一起的盒子。當我們使用的時候,每次從最頂部取走一個盒子。棧也是如此,當一個方法(或類型)被調用完成的時候,就從棧頂取走(called a Frame:調用幀),接著下一個。
堆則不然,像是一個倉庫,儲存著我們使用的各種對象等信息,跟棧不同的是他們被調用完畢不會立即被清理掉(等待垃圾回收器來清理)。
棧記憶體無需我們管理,也不受GC管理。當棧頂元素使用完畢,立馬釋放。而堆則需要GC(Garbage collection:垃圾收集器)清理。
當我們的程式執行的時候,在棧和堆中分配有四種主要的類型:值類型,引用類型,指針,指令。
- 值類型:在C#中,繼承自
System.ValueType
的類型被稱為值類型,bool byte char decimal double enum float int long sbyte short struct uint ulong ushort` - 引用類型:繼承自
System.Object
,class interface delegate object string
指針:在記憶體區中,指向一個類型的引用,通常被稱為“指針”,它是受CLR( Common Language Runtime:公共語言運行時)管理,我們不能顯式使用。指針在記憶體中占一塊記憶體區,它本身只代表一個記憶體地址(或者null),它所指向的另一塊記憶體區才是我們真正的數據或者類型。
值類型、引用類型的記憶體分配:
- 引用類型總是被分配在堆上
值類型和指針總是分配在被定義的地方,他們不一定被分配到棧上,如果一個值類型被聲明在一個方法體外並且在一個引用類型中,那它就會在堆上進行分配。
棧(Stack),在程式運行的時候,每個線程(Thread)都會維護一個自己的專屬線程堆棧。
當一個方法被調用的時候,主線程開始在所屬程式集的元數據中,查找被調用方法,然後通過JIT即時編譯並把結果(一般是本地CPU指令)放在棧頂。CPU通過匯流排從棧頂取指令,驅動程式以執行下去。
當程式需要更多的堆空間時,GC需要進行垃圾清理工作,暫停所有線程,找出所有不可達到對象,即無被引用的對象,進行清理、壓縮。並通知棧中的指針重新指向地址排序後的對象。
4.釋放非托管的資源
有了垃圾回收器,意味著我們只要讓不再需要的對象的所有引用都超出作用域,並允許垃圾回收器在需要時釋放記憶體即可。
原則:在.net中,沒有必要調用Dispose的時候,你就不要調用它(垃圾回收器運行時會占用/阻塞主線程)。
但是,垃圾回收器不知道如何釋放非托管的資源(如文件句柄、網路連接、資料庫連接)。
在定義一個類時,有兩種機制來自動釋放非托管的資源:(更保險的做法是同時使用兩種機制,防止忘記調用Dispose()
方法)
- 聲明一個析構函數(終結器);
- 為類實現
System.IDiposable
介面,實現Dispose()
方法;
5.析構函數:
C#編譯器在編譯析構函數時,它會隱式地把析構函數編譯為等價於Finalize()
方法,從而確保執行父類的Finalize()
方法。
定義方式如下:析構函數無返回值、無參數、無訪問修飾符
class MyClass
{
~MyClass()
{
}
}
//以下版本是編譯析構函數實際調用的等價代碼:
protected override void Finalize()
{
try
{ //釋放自身資源 }
finally
{ base.Finalize(); }
}
析構函數的缺點:
由於C#使用垃圾回收器的工作方式,無法確定C#對象的析構函數何時執行。
定義了析構函數的對象需要經過兩次垃圾回收處理才能被銷毀(第二次調用析構函數時才真正刪除對象),而沒有定義析構函數的對象反而只需要一次處理即可刪除。
如果頻繁使用析構函數,而且執行長時間的清理任務,會嚴重影響性能。
6.IDiposable介面:
所以,推薦通過為類實現System.IDisposable
介面,實現Dispose()
方法,來替代析構函數。IDisposable
介面定義的模式為釋放非托管資源提供了確定的機制,並避免了對垃圾回收器依賴的問題。
IDisposable
介面聲明瞭Dispose()
方法,無參數,無返回值。可以為Dispose()方法實現代碼來顯式地釋放由對象直接使用的所有非托管資源,併在所有也實現IDisposable
介面的封裝對象中調用Dispose()
方法。這樣,該方法可以可以精確地控制非托管資源的釋放。
註意:如果在Dispose()
方法調用之前的運行代碼拋出了異常,則該方法就執行不到了,所以應該使用try...finally
,並把Dispose()
方法放在finally
塊內,以確保它的執行。如下:
Person person = null; //假設Person類實現了IDisposable介面
try
{
person = new Person();
}
finally
{
if(person != null)
{
person.Dispose();
}
}
C#提供了using
關鍵字語法,可以確保在實現了IDisposable
介面的對象的引用超出作用域時,在該對象上自動調用Dispose()
方法,如下:
using ( Person person = new Person() )
{ ..... }
using語句後面是一對"()",其中是引用變數的聲明和實例化,該語句是其中的變數放在隨後的語句塊中,並且在變數超出作用域時,即使拋出異常,也會自動調用Dispose()
方法。
然後,在需要捕獲其它異常時,使用try...finally
的方式就會比較清晰。而常常為Dispose()
方法定義一個包裝方法Close()
,這樣顯得更清晰明瞭(Close()方法內僅調用Dispose()
方法)
為了防止忘記調用Dispose()
方法,更保險的做法是同時實現兩種機制:即實現IDisposable
介面的Dispose()
方法,也定義析構函數。
7.C#中標準Dispose模式的實現
摘要:C#程式中的Dispose方法,一旦被調用了該方法的對象,雖然還沒有垃圾回收,但實際上已經不能再使用了。
先瞭解一下C#程式(或者說.NET)中的資源分類。簡單的說來,C#中的每一個類型都代表一種資源,而資源又分為兩類:
- 托管資源:由CLR管理分配和釋放的資源,即由CLR里new出來的對象;
非托管資源:不受CLR管理的對象,windows內核對象,如文件、資料庫連接、套接字、COM對象等;
毫無例外地,如果我們的類型使用到了非托管資源,或者需要顯式釋放的托管資源,那麼,就需要讓類型繼承介面IDisposable
。這相當於是告訴調用者,該類型是需要顯式釋放資源的,你需要調用我的Dispose
方法。
不過,這一切並不這麼簡單,一個標準的繼承了IDisposable
介面的類型應該像下麵這樣去實現。這種實現我們稱之為Dispose模式:public class SampleClass : IDisposable { //演示創建一個非托管資源 private IntPtr nativeResource = Marshal.AllocHGlobal(100); //演示創建一個托管資源 private AnotherResource managedResource = new AnotherResource(); private bool disposed = false; /// <summary> /// 實現IDisposable中的Dispose方法,用於手動調用 /// </summary> public void Dispose() { //必須為true Dispose(true); //通知垃圾回收機制不再調用終結器(析構器)因為我們已經自己清理了,沒必要繼續浪費系統資源 //即:從等待終結的Finalize隊列中移除this GC.SuppressFinalize(this); } /// <summary> /// 不是必要的,提供一個Close方法僅僅是為了更符合其他語言(如C++)的規範 /// </summary> public void Close() { Dispose(); } /// <summary> /// 必須,以備程式員忘記了顯式調用Dispose方法 /// </summary> ~SampleClass() { //必須為false,跳過托管資源的清理,只手動清理非托管的資源,垃圾回收器會自動清理托管資源 Dispose(false); } /// <summary> /// 非密封類修飾用protected virtual /// 密封類修飾用private /// </summary> /// <param name="disposing"></param> protected virtual void Dispose(bool disposing) { if (disposed) { return; } if (disposing) { // 清理托管資源 if (managedResource != null) { managedResource.Dispose(); managedResource = null; } } // 清理非托管資源 if (nativeResource != IntPtr.Zero) { Marshal.FreeHGlobal(nativeResource); nativeResource = IntPtr.Zero; } //讓類型知道自己已經被釋放 disposed = true; } public void SamplePublicMethod() { //確保在執行對象的任何方法之前,該對象可用(未被釋放) if (disposed) { throw new ObjectDisposedException("SampleClass", "SampleClass is disposed"); } //在這裡可以使用對象 } }
在Dispose模式中,幾乎每一行都有特殊的含義。
在標準的Dispose模式中,我們註意到一個以~開頭的方法:/// <summary> /// 必須,以備程式員忘記了顯式調用Dispose方法 /// </summary> ~SampleClass() { //必須為false Dispose(false); }
這個方法叫做類型的終結器。提供終結器的全部意義在於:我們不能奢望類型的調用者肯定會主動調用Dispose方法,基於終結器會被垃圾回收器調用這個特點,終結器被用做資源釋放的補救措施。
一個類型的Dispose方法應該允許被多次調用而不拋異常。鑒於這個原因,類型內部維護了一個私有的布爾型變數disposed:
private bool disposed = false;
在實際處理代碼清理的方法中,加入瞭如下的判斷語句:if (disposed) { return; } //省略清理部分的代碼,併在方法的最後為disposed賦值為true disposed = true;
這意味著類型如果被清理過一次,則清理工作將不再進行。
應該註意到:在標準的Dispose模式中,真正實現IDisposable
介面的Dispose方法,並沒有實際的清理工作,它實際調用的是下麵這個帶布爾參數的受保護的虛方法:/// <summary> /// 非密封類修飾用protected virtual /// 密封類修飾用private /// </summary> /// <param name="disposing"></param> protected virtual void Dispose(bool disposing) { //省略代碼 }
之所以提供這樣一個受保護的虛方法,是為了考慮到這個類型會被其他類繼承的情況。如果類型存在一個子類,子類也許會實現自己的Dispose模式。受保護的虛方法用來提醒子類必須在實現自己的清理方法的時候註意到父類的清理工作,即子類需要在自己的釋放方法中調用base.Dispose方法。
還有,我們應該已經註意到了真正撰寫資源釋放代碼的那個虛方法是帶有一個布爾參數的。之所以提供這個參數,是因為我們在資源釋放時要區別對待托管資源和非托管資源。
在供調用者調用的顯式釋放資源的無參Dispose方法中,調用參數是true:public void Dispose() { //必須為true Dispose(true); //其他省略 }
這表明,這個時候代碼要同時處理托管資源和非托管資源。
在供垃圾回收器調用的隱式清理資源的終結器中,調用參數是false:~SampleClass() { //必須為false Dispose(false); }
這表明,隱式清理時,只要處理非托管資源就可以了。
那麼,為什麼要區別對待托管資源和非托管資源。在認真闡述這個問題之前,我們需要首先弄明白:托管資源需要手動清理嗎?不妨先將C#中的類型分為兩類,一類繼承了IDisposable介面,一類則沒有繼承。前者,我們暫時稱之為非普通類型,後者我們稱之為普通類型。
非普通類型因為包含非托管資源,所以它需要繼承IDisposable介面,但是,這個包含非托管資源的類型本身,它是一個托管資源。所以說,托管資源需要手動清理嗎?這個問題的答案是:托管資源中的普通類型,不需要手動清理,而非普通類型,是需要手動清理的(即調用Dispose方法)。
Dispose模式設計的思路基於:如果調用者顯式調用了Dispose方法,那麼類型就該按部就班為自己的所以資源全部釋放掉。如果調用者忘記調用Dispose方法,那麼類型就假定自己的所有托管資源(哪怕是那些上段中闡述的非普通類型)全部交給垃圾回收器去回收,而不進行手工清理。理解了這一點,我們就理解了為什麼Dispose方法中,虛方法傳入的參數是true,而終結器中,虛方法傳入的參數是false。
8.及時讓不再需要的靜態欄位的引用等於null:
在CLR托管應用程式中,存在一個根的概念,類型的靜態欄位、方法參數以及局部變數都可以作為根存在(值類型不能作為根,只有引用類型的指針才能作為根)。垃圾回收器會沿著線程棧上行檢查根,如果發現該根的引用為空,則標記該根為可被釋放。
而JIT編譯器是一個經過優化的編譯器,無論我們是否為變數賦值為null,該語句都會被忽略掉,在我們將項目設置為Release模式下,該語句將根本不會被編譯進運行時內。
但是,在另外一種情況下,卻要註意及時為變數賦值為null。那就是類型的靜態欄位。而且,為類型對象賦值為null,並不意味著同時為該類型的靜態欄位賦值為null:當執行垃圾回收時,當類型的對象被回收的時候,該類型的靜態欄位並沒有被回收(因為靜態欄位是屬於類的,它日後可能會被該類型的其它實例繼續使用)。
實際工作中,一旦我們感覺到自己的靜態引用類型參數占用記憶體空間比較大,並且使用完畢後不再使用,則可以立刻將其賦值為null。這也許並不必要,但這絕對是一個好習慣。