標籤:大小 記憶體回收 c++語言 返回 最佳化 obj inter delegate new
前言:對於很多的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()
方法,強迫記憶體回收行程在代碼的某個地方運行。
當代碼中有大量的對象剛剛取值 (Dereference),就比較適合調用記憶體回收行程,但不能保證所有未引用的對象都能從堆中刪除。
記憶體回收行程運行時,它實際上會降低程式的效能,因為在它執行期間,將會暫停應用程式的其它所有線程。
但.NET記憶體回收行程使用了"世代記憶體回收行程(generational)":
託管堆分為幾個部分:第0代,第1代,第2代
所有新對象都被分配在第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。這也許並不必要,但這絕對是一個好習慣。
C#記憶體管理解析