英文原文:Jeffrey Richter
編譯:趙玉開
連結:http://www.php.cn/
有了Microsoft.Net clr中的垃圾回收機製程式設計師不需要再關注什麼時候釋放內存,釋放內存這件事兒完全由GC做了,對程式設計師來說是透明的。儘管如此,作為一個.Net程式設計師很有必要理解垃圾回收是如何運作的。這篇文章我們就來看下.Net是如何分配和管理託管記憶體的,之後再一步一步描述垃圾回收器工作的演算法機制。
為程式設計一個適當的記憶體管理策略是困難的也是乏味的,這個工作還會影響你專注於解決程式本身要解決的問題。有沒有一種內建的方法可以幫助開發人員解決記憶體管理的問題呢?當然有了,在.Net中就是GC,垃圾回收。
讓我們想一下,每個程式都要使用記憶體資源:例如螢幕顯示,網路連接,資料庫資源等等。實際上,在一個物件導向環境中,每一種類型都需要佔用一點記憶體資源來存放他的數據,物件需要按照如下的步驟使用記憶體:
1. 為類型分配記憶體空間
2.初始化內存,將內存設定為可用狀態
3. 訪問對象的成員
4. 銷毀對象,使內存變成清空狀態
5. 釋放內存
5.模式導致過很多的程式問題,有時程式設計師可能會忘記釋放不再使用的對象,有時又會試圖存取已經釋放的對象。這兩種bug通常都有一定的隱藏性,不容易發現,他們不像邏輯錯誤,發現了就可以修改掉。他們可能會在程式運行一段時間之後記憶體洩漏導致意外的崩潰。事實上,有許多工具可以幫助開發人員偵測記憶體問題,例如:任務管理器,System
Monitor AcitvieX Control, 以及Rational的Purify。
而GC可以完全不需要開發人員去關注什麼時候釋放記憶體。然而,垃圾回收器並不是可以管理記憶體中的所有資源。有些資源垃圾回收器不知道該如何回收他們,這部分資源需要開發人員自己寫程式碼來實現回收。在.Net
在framework中,開發人員通常會把清理這類資源的程式碼寫到Close、Dispose或Finalize方法中,稍後我們會看下Finalize方法,這個方法垃圾回收器會自動呼叫。 🎜🎜不過,有很多物件是不需要自己實作釋放資源的程式碼的,例如:Rectangle,清空它只需要清空它的left,right,width,height欄位就可以了,這垃圾回收器完全可以做。下面讓我們來看看記憶體是如何分配給物件使用的。
物件分配:
.Net clr把所有的引用物件都分配到託管堆上。這點很像c-runtime堆,不過你不需要注意什麼時候釋放對象,而對象會在不用時自動釋放。這樣,就出現一個問題,垃圾回收器是怎麼知道一個物件不再使用該回收了呢?我們稍後解釋這個問題。
現在有幾種垃圾回收演算法,每一種演算法都為一種特定的環境做了效能優化,這篇文章我們關注的是clr的垃圾回收演算法。讓我們從一個基礎概念談起。
當一個進程初始化之後,運行時會保留一段連續的空白記憶體空間,這塊記憶體空間就是託管堆。託管堆會記錄一個指針,我們叫它NextObjPtr,這個指針指向下一個物件的分配位址,最初的時候,這個指針指向託管堆的起始位置。
應用程式使用new運算元建立一個新對象,這個運算子首先要確認託管堆剩餘空間能放得下這個對象,如果能放得下,就把NextObjPtr指針指向這個對象,然後呼叫對象的構造函數,new操作符傳回物件的位址。
圖1代管堆
這時候,NextObjPtr指向託管堆上下一個物件分配的位置,圖1顯示一個託管堆中有三個物件A、B和C。下一個物件會放在NextObjPtr指向的位置(緊鄰C物件)
現在讓我們再看一下c-runtime堆如何分配記憶體。在c-runtime堆,分配記憶體需要遍歷一個鍊錶的資料結構,直到找到一個足夠大的記憶體區塊,這個記憶體區塊有可能會被拆分,拆分後鍊錶中的指標要指向剩餘記憶體空間,要確保鍊錶的完好。對於託管堆,分配一個物件只是修改NextObjPtr指標的指向,這個速度是非常快的。事實上,在託管堆上分配一個物件和在線程棧上分配記憶體的速度很接近。
到目前為止,託管堆上分配記憶體的速度似乎比在c-runtime堆上的更快,實作上也更簡單一些。當然,託管堆獲得這個優勢是因為做了一個假設:位址空間是無限的。很顯然這個假設是錯的。一定有一種機制來保證這個假設成立。這個機制就是垃圾回收器。讓我們看下它如何工作。
當應用程式呼叫new操作符建立物件時,有可能已經沒有記憶體來存放這個物件了。託管堆可以偵測到NextObjPtr指向的空間是否超過了堆的大小,如果超過了就表示託管堆滿了,就需要做一次垃圾回收了。
在現實中,在0代堆滿了之後就會觸發一次垃圾回收。 「代」是垃圾回收器提升效能的實現機制。 「代」的意思是:新建立的物件是年輕一代,而在回收作業發生之前沒有被回收的物件是較老的物件。將物件分成幾個世代可以允許垃圾回收器只回收某一代的對象,而不是回收所有物件。
垃圾回收演算法:
垃圾回收器檢查是否有應用程式不再使用的物件。如果這樣的物件存在,那麼這些物件佔用的空間就可以被回收(如果堆上沒有足夠的記憶體可用,那麼new運算元就會拋出OutofMemoryException)。你可能會問垃圾回收器是怎麼判斷一個物件是否還在用呢?這個問題不太容易得到答案。
每個應用程式都有一組根對象,根是一些儲存位置,他們可能指向託管堆上的某個位址,也可能是null。例如,所有的全域和靜態對象指標都是應用程式的根對象,另外在執行緒棧上的局部變數/參數也是應用程式的根對象,還有CPU暫存器中的指向託管堆的對像也是根對象。存活的根物件清單由JIT(just-in-time)編譯器和clr維護,垃圾回收器可以存取這些根物件的。
當垃圾回收器開始運行,它會假設託管堆上的所有物件都是垃圾。也就是說,假定沒有根對象,也沒有根對象所引用的對象。然後垃圾回收器開始遍歷根物件並建構一個由所有和根物件之間有引用關係物件構成的圖。
圖2顯示,託管堆上應用程式的根物件是A,C,D和F,這幾個物件就是圖的一部分,然後物件D引用了物件H,那麼物件H也被加入圖中;垃圾回收器會循環遍歷所有可達物件。
圖2
託管堆上的物件
垃圾回收器會挨個遍歷根物件和引用物件。如果垃圾回收器發現一個物件已經在圖中就會換一條路徑繼續遍歷。這樣做有兩個目的:一是提高效能,二是避免無限循環。
所有的根物件都檢查完之後,垃圾回收器的圖中就有了應用程式中所有的可達物件。託管堆上所有不在這個圖上的物件就是要做回收的垃圾物件了。建構可達物件圖之後垃圾回收器開始線性的遍歷託管堆,找到連續垃圾物件區塊(可以認為是空閒記憶體)。然後垃圾回收器將非垃圾物件移到一起(使用c語言中的memcpy函數),覆蓋所有的記憶體碎片。當然,移動物件時要停用所有物件的指標(因為他們都可能是錯誤的了)。因此垃圾回收器必須修改應用程式的根物件使他們指向物件的新記憶體位址。此外,如果某個物件包含另一個物件的指針,垃圾回收器也要負責修改參考。圖3顯示了一次回收之後的託管堆。
圖3
回收之後的託管堆
如圖3所示在回收之後,所有的垃圾物件都被標識出來,而所有的非垃圾物件都被移動在一起。所有的非垃圾物件的指標也都被修改成移動後的記憶體位址,NextObjPtr指向最後一個非垃圾物件的後面。這時候new操作符就可以繼續成功的建立物件了。
如你所看到的,垃圾回收會有顯著的效能損失,這是使用託管堆的一個明顯的缺點。 不過,要記著記憶體回收操作旨在託管堆慢了之後才會執行。在滿之前託管堆的效能比c-runtime堆的效能好。運行時垃圾回收器還會做一些效能優化,我們在下一篇文章中談論這個。
下面的程式碼說明了物件是如何被建立管理的:
class Application { public static int Main(String[] args) { // ArrayList object created in heap, myArray is now a root ArrayList myArray = new ArrayList(); // Create 10000 objects in the heap for (int x = 0; x < 10000; x++) { myArray.Add(new Object()); // Object object created in heap } // Right now, myArray is a root (on the thread's stack). So, // myArray is reachable and the 10000 objects it points to are also // reachable. Console.WriteLine(a.Length); // After the last reference to myArray in the code, myArray is not // a root. // Note that the method doesn't have to return, the JIT compiler // knows // to make myArray not a root after the last reference to it in the // code. // Since myArray is not a root, all 10001 objects are not reachable // and are considered garbage. However, the objects are not // collected until a GC is performed. } }
也許你會問,GC這麼好,為什麼ANSI C++中沒有它? 原因是垃圾回收器必須能找到應用程式的根物件列表,必須找到物件的指標。而在C++中物件的指標之間是可以互相轉換的,沒有辦法知道指標指向的是一個什麼物件的指標。在CLR中,託管堆知道物件的實際類型。而元資料(metadata)資訊可以用來判斷物件引用了什麼成員物件。
垃圾回收和Finalization
垃圾回收器提供了一個額外的功能,它可以在物件被標識為垃圾後自動呼叫其Finalize方法(前提是物件重寫了object的Finalize方法)。
Finalize方法是object物件的一個虛擬方法,如果需要你可以重寫這個方法,但是這個方法只能透過類似c++析構函式的方式來重寫。例如:
{ ~Foo(){ Console.WriteLine(“Foo Finalize”); } }
這裡用過C++的程式設計師要特別注意,Finalize方法的寫法和C++的析構函數完全一樣,但是,.Net 中的Finalize方法和析構函數的卻是不一樣的,託管對象是不能被析構的,只能透過垃圾回收回收。
當你設計一個類別時,最好避免重寫Finalize方法,原因如下:
1. 實作Finalize的物件會被提升到更老的“代”,這會增加記憶體壓力,使物件和此物件的關聯物件不能在成為垃圾的第一時間回收掉。
2. 這些物件分配時間會更長
3. 讓垃圾回收器執行Finalize方法會明顯的損耗效能。請記住,每一個實作了Finalize方法的對像都需要執行Finalize方法,如果有一個長度為10000的數組對象,每個對像都需要執行Finalize方法
4. 重寫Finalize方法的對象可能會引用其他沒有實作Finalize方法的對象,這些對像也會延遲回收
5. 你沒有辦法控制什麼時候執行Finalize方法。如果要在Finalize方法中釋放類似資料庫連線之類的資源,就有可能導致資料庫資源在時候後很久才得以釋放
6. 當程式崩潰時,有些物件還被引用,他們的Finalize方法就沒有機會執行了。這種情況會在後台執行緒使用對象,或當對像在程式退出時,或當AppDomain卸載時。另外,預設情況下,當應用程式被強制結束時Finalize方法也不會執行。當然所有的作業系統資源都會被回收;但是在託管堆上的物件不會回收。你可以透過呼叫GC的RequestFinalizeOnShutdown方法來改變這種行為。
7. 運行時不能控制多個物件Finalize方法執行的順序。而有時候物件的銷毀可能有順序性
如果你定義的物件必須實作Finalize方法,那麼要確保Finalize方法盡可能快速的執行,要避免所有可能引起阻塞的操作,包括任何執行緒同步操作。另外,要確保Finalize方法不會造成任何異常,如果有異常垃圾回收器會繼續執行其他物件的Finalize方法直接忽略掉異常。
當編譯器產生程式碼時會自動在建構函式上呼叫基底類別的建構子。同樣C++的編譯器也會為析構函式自動加入基類析構函式的呼叫。但是,.Net中的Finalize函數不是這樣子,編譯器不會對Finalize方法做特殊處理。如果你想在Finalize方法中呼叫父類別的Finalize方法,必須自己顯示新增呼叫程式碼。
請注意在C#中Finalize方法的寫法和c++中的析構函數一樣,但是C#不支援析構函數,不要讓這種寫法欺騙你。
GC呼叫Finalize方法的內部實作
表面看,垃圾回收器嗲用Finalize方法很簡單,你創建一個對象,當對象回收時調用它的Finalize方法。但是事實上要複雜一些。
當應用程式建立一個新物件時,new操作符在堆上分配記憶體。如果物件實作了Finalize方法。物件的指標會放到終結佇列中。終結佇列是由垃圾回收器控制的內部資料結構。在佇列中每一個物件在回收時都需要呼叫它們的Finalize方法。
下圖顯示的堆上包含幾個對象,其中一些對像是跟對象,有些對像不是。當物件C、E、F、I和J創建時,系統會偵測這些物件實作了Finalize方法,並將它們的指標放到終結佇列中。
Finalize方法要做的事情通常是回收垃圾回收器不能回收的資源,例如文件句柄,資料庫連接等等。
當垃圾回收時,物件B、E、G、H、I和J被標記為垃圾。垃圾回收器掃描終結佇列找到這些物件的指標。當發現物件指標時,指標會被移到Freachable佇列。 Freachable佇列是另一個由垃圾回收器控制的內部資料結構。在Freachable佇列中的每一個物件的Finalize方法將會執行。
垃圾回收之後,託管堆如圖6所示。你可以看到物件B、G、H已經回收了,因為這幾個物件沒有Finalize方法。然而物件E、I、J還沒有被回收掉,因為他們的Finalize方法還沒有執行。
垃圾回收後的寄存堆 程式運作時會有一個專門的執行緒負責呼叫Freachable佇列中物件的Finalize方法。當Freachable隊列為空時,這個執行緒會休眠,當佇列中有物件時,執行緒被喚醒,移除佇列中的對象,並呼叫它們的Finalize方法。因此在執行Finalize方法時不要企圖存取執行緒的local
storage。 圖6 再次執行垃圾回收後的託管堆 再次出发垃圾回收之后,实现Finalize方法的对象才被真正的回收。这些对象的Finalize方法已经执行过了,Freachable队列清空了。 垃圾回收让对象复活 在前面部分我们已经说了,当程序不使用某个对象时,这个对象会被回收。然而,如果对象实现了Finalize方法,只有当对象的Finalize方法执行之后才会认为这个对象是可回收对象并真正回收其内存。换句话说,这类对象会先被标识为垃圾,然后放到freachable队列中复活,然后执行Finalize之后才被回收。正是Finalize方法的调用,让这种对象有机会复活,我们可以在Finalize方法中让某个对象强引用这个对象;那么垃圾回收器就认为这个对象不再是垃圾了,对象就复活了。 在这种情况下,当对象的Finalize方法执行之后,对象被Application的静态字段ObjHolder强引用,成为根对象。这个对象就复活了,而这个对象引用的对象也就复活了,但是这些对象的Finalize方法可能已经执行过了,可能会有意想不到的错误发生。 如下代码: 当对象复活时,重新将对象添加到复活队列中。需要注意的时如果一个对象已经在终结队列中,然后又调用了GC.ReRegisterForFinalize(obj)方法会导致此对象的Finalize方法重复执行。
以上就是.Net 垃圾回收机制原理(一)的内容,更多相关内容请关注PHP中文网(www.php.cn)!
終結隊列(finalization queue)和Freachable隊列之間的交互作用很巧妙。首先讓我告訴你freachable的名字是怎麼來的。 F顯然是finalization;在此佇列中的每一個物件都在等待執行他們的Finalize方法;reachable意思是這些物件來了。另一種說法,Freachable佇列中的物件被認為是跟對象,就像是全域變數或靜態變數。因此,如果一個物件在freachable隊列中,那麼這個物件就不是垃圾。
簡短點說,當一個物件是不可達的,垃圾回收器會認為這個物件是垃圾。那麼,當垃圾回收器將物件從終結隊列移動到Freachable隊列中,這些物件就不再是垃圾了,它們的記憶體也不會回收。從這一點上來講,垃圾回收器已經完成標識垃圾,有些物件被標識成垃圾又被重新認為成非垃圾物件。垃圾回收器回收壓縮內存,清空freachable隊列,執行隊列中每個物件的Finalize方法。
如下复活演示代码:public class Foo {
~Foo(){
Application.ObjHolder = this;
}
}
class Application{
static public Object ObjHolder = null;
}
事实上,当你设计自己的类型时,对象的终结和复活有可能完全不可控制。这不是一个好现象;处理这种情况的常用做法是在类中定义一个bool变量来表示对象是否执行过了Finalize方法,如果执行过Finalize方法,再执行其他方法时就抛出异常。
现在,如果有其他的代码片段又将Application.ObjHolder设置为null,这个对象变成不可达对象。最终垃圾回收器会把对象当成垃圾并回收对象内存。请注意这一次对象不会出现在finalization队列中,它的Finalize方法也不会再执行了。
复活只有有限的几种用处,你应该尽可能避免使用复活。尽管如此,当使用复活时,最好重新将对象添加到终结队列中,GC提供了静态方法ReRegisterForFinalize方法做这件事:public class Foo{
~Foo(){
Application.ObjHolder = this;
GC.ReRegisterForFinalize(this);
}
}
垃圾回收机制的目的是为开发人员简化内存管理。
下一篇我们谈一下弱引用的作用,垃圾回收中的“代”,多线程中的垃圾回收和与垃圾回收相关的性能计数器。