C#的 GC工作原理基礎

作爲一位C++出身的C#程序員,我最初對垃圾收集(GC)抱有懷疑態度,懷疑它是否能夠穩定高效的運作;而到了現在,我自己不得不說我已經逐漸習慣並依賴GC與我的程序“共同奔跑”了,對“delete”這個習慣於充當罪魁禍首的關鍵字也漸漸產生了陌生感。然而實踐證明,我對GC的過分信賴卻招致了很多意想不到的錯誤,這也激勵了我對GC的運作機制作深入一步的瞭解。隨後我開始翻書,查資料,終於對GC有了一個比較完整的理解(但遠遠算不上深入)。有人也許會說:“研究GC的內部機制有什麼價值嗎?我們是搞應用程序開發的,客戶的機器可以達到很高的配置,內存資源不是問題。”這種說法明顯是認爲“垃圾收集=內存釋放”了,其實在垃圾收集中,造成最多麻煩的往往不是內存量,而是在內存釋放之外,GC暗地裏爲我們做的繁雜事務(例如非託管資源的清理和釋放)。如果你對GC的基本運作還不瞭解,而又沒有時間仔細閱讀衆多技術資料的話,那麼我的這幾篇文章或許對你能有一些幫助。
下面就從資源的分配和釋放入手,先了解一下背景知識。

一. 託管資源的分配

CLR在運行時管理着一段內存地址空間(虛擬地址空間,在運行中會映射到物理內存地址中),分爲“託管堆”和“棧”兩部分,棧用於存儲值類型數據,它會在方法執行結束後自動銷燬其中引用的值類型變量,這一部分不屬於垃圾收集的範圍。託管堆用於引用類型的變量存儲,是垃圾收集的關鍵陣地。

託管堆是一段連續的地址空間,其中所分配出去的空間呈現出類似數組形態的隊列結構:

NextObjPtr是託管堆所維護的一個內存指針,指示下一個對象分配的內存起始地址,它會隨着內存的分配而不斷移動(當然也會隨着內存垃圾回收而發生移動),永遠指向下一個空閒的地址。

到了這裏,我們不妨與C++比較一下內存分配機制的效率(對效率不感興趣的大可以跳過:)),順便讓C++的朋友們打消一些對CLR分配內存效率的疑慮。在查找空閒內存空間時,CLR只需要在NextObjPtr處直接留出指定大小的空間提供給數據初始化,然後計算新的空閒地址並重置NextObjPtr指針即可。而在C/C++中,在分配內存之前先要遍歷一遍內存佔用的鏈表以查找合適大小的內存塊,然後再修改此鏈表,這樣也很容易產生內存碎塊,使得內存分配性能下降。很明顯,.NET的分配方式效率更高。但是這種效率是以GC的勞動爲代價的。

二. 垃圾判定

    要進行垃圾收集,首先要知道什麼是垃圾。GC通過遍歷應用程序中的“根”來尋找垃圾。我們可以認爲根是一個指向引用類型對象內存地址的指針。如果一個對象沒有了根,就是它不再被任何位置所引用,那麼它就是垃圾的候選者了。

    值得注意的一點是,對象可能在其生存期結束之前就被列入垃圾名單,甚至已經被GC所暗殺!那是因爲對象可能在生存期的某一時刻已經不再被引用,如果在這個時候執行垃圾收集,那麼這個不幸的對象極有可能已經被列爲垃圾並被銷燬(爲什麼說是“可能”呢?因爲它不一定在GC的視力範圍內。後面講到“代齡”時會詳細介紹相關細節)。
1 public static void Main()
2              {
3                 string sGarbage = "I'm here";
4 
5                 //下面的代碼沒有再引用s,它已經成爲垃圾對象---當然,這樣的代碼本身也是垃圾;
6                 //此時如果執行垃圾收集,則sGarbage可能已經魂歸西天
7 
8                  Console.WriteLine("Main() is end");
9              }

三. 對象代齡

    儘管GC總是在默默爲我們勞動,但它畢竟是由人創造的,人會偷懶,它也會。爲了減少每次的工作量,它總是希望能夠減少工作的範圍;它堅信,越晚創建的對象往往越短命,因此它會集中精力處理這一部分的內存區域,暫且擱置其他部分。GC引入“代齡”的概念來劃分對象生存級別。

    CLR初始化後的第一批被創建的對象被列爲0代對象。CLR會爲0代對象設定一個容量限制,當創建的對象大小超過這個設定的容量上限時,GC就會開始工作,工作的範圍是0代對象所處的內存區域,然後開始搜尋垃圾對象,並釋放內存。當GC工作結束後,倖存的對象將被列爲第1代對象而保留在第1代對象的區域內。此後新創建的對象將被列爲新的一批0代對象,直到0代的內存區域再次被填滿,然後會針對0代對象區域進行新一輪的垃圾收集,之後這些0代對象又會列爲第1代對象,併入第1代區域內。第1代區域起初也會被設上一個容量限制值,等到第1代對象大小超過了這個限制之後,GC就會擴大戰場,對第1代區域也做一次垃圾收集,之後,又一次倖存下來的對象將會提升一個代齡,成爲第2代對象。


    可見,有一些對象雖然符合垃圾的所有條件,但它們如果是第1代(甚至是第2代老臣)對象,並且第1代的分配量還小於被設定的限制值時,這些垃圾對象就不會被GC發現,並且可以繼續存活下去。

    另外,GC還會在工作過程中汲取經驗,根據應用程序的特點而自動調整每代對象區域的容量,從而可以更高效的工作。

應該瞭解的垃圾收集機制(二) 
對於大多數應用而言,瞭解垃圾收集機制的主要動機並不是爲了對內存“省吃儉用”,而是爲了處理非託管資源的控制問題,這些問題往往跟內存的大小沒有什麼關係。例如對一個文件進行操作,該何時關閉文件,關閉文件時要注意什麼問題,如果忘了關閉會帶來什麼後果?這些都是我們需要認真考慮的,無論你的內存有多大:)

對於這一類的操作,我們不能依賴GC幫我們做,因爲它並不知道我們在釋放時想幹什麼,它甚至不知道自己該幹什麼!我們不得不自己動手來編寫處理代碼。當然,微軟已經爲我們搭好了框架,就是這兩個函數:Finalize和Dispose。它們也代表了非託管清理的兩種方式:自動和手動。

一. Finalize

Finalize很像C++的析構函數,我們在代碼中的實現形式爲這與C++的析構函數在形式上完全一樣,但它的調用過程卻大不相同。

~ClassName() {//釋放你的非託管資源}

比如類A中實現了Finalize函數,在A的一個對象a被創建時(準確的說應該是構造函數被調用之前),它的指針被插入到一個finalization鏈表中;在GC運行時,它將查找finalization鏈表中的對象指針,如果此時a已經是垃圾對象的話,它會被移入一個freachable隊列中,最後GC會調用一個高優先級線程,這個線程專門負責遍歷freachable隊列並調用隊列中所有對象的Finalize方法,至此,對象a中的非託管資源纔得到了釋放(當然前提是你正確實現了它的Finalize方法),而a所佔用的內存資源則必需等到下一次GC才能得到釋放,所以一個實現了Finalize方法的對象必需等兩次GC才能被完全釋放。

由於Finalize是由GC負責調用,所以可以說是一種自動的釋放方式。但是這裏面要注意兩個問題:第一,由於無法確定GC何時會運作,因此可能很長的一段時間裏對象的資源都沒有得到釋放,這對於一些關鍵資源而言是非常要命的。第二,由於負責調用Finalize的線程並不保證各個對象的Finalize的調用順序,這可能會帶來微妙的依賴性問題。如果你在對象a的Finalize中引用了對象b,而a和b兩者都實現了Finalize,那麼如果b的Finalize先被調用的話,隨後在調用a的Finalize時就會出現問題,因爲它引用了一個已經被釋放的資源。因此,在Finalize方法中應該儘量避免引用其他實現了Finalize方法的對象。

可見,這種“自動”釋放資源的方法並不能滿足我們的需要,因爲我們不能顯示的調用它(只能由GC調用),而且會產生依賴型問題。我們需要更準確的控制資源的釋放。

二. Dispose

Dispose是提供給我們顯示調用的方法。由於對Dispose的實現很容易出現問題,所以在一些書籍上(如《Effective C#》和《Applied Microsoft.Net Framework Programming》)給出了一個特定的實現模式:

class DisposePattern :IDisposable
    {
        private System.IO.FileStream fs = new System.IO.FileStream("test.txt", System.IO.FileMode.Create);

        ~DisposePattern()
        {
            Dispose(false);
        }       

        IDisposable Members#region IDisposable Members

        public void Dispose()
        {
            //告訴GC不需要再調用Finalize方法,
            //因爲資源已經被顯示清理
            GC.SupdivssFinalize(this);

            Dispose(true);
        }

        #endregion
                
        protected virtual void Dispose(bool disposing)
        {
            //由於Dispose方法可能被多線程調用,
            //所以加鎖以確保線程安全
            lock (this)
            {
                if (disposing)
                {
                    //說明對象的Finalize方法並沒有被執行,
                    //在這裏可以安全的引用其他實現了Finalize方法的對象
                }

                if (fs != null)
                {
                    fs.Dispose();
                    fs = null; //標識資源已經清理,避免多次釋放
                }
            }
        }
    }

在註釋中已經有了比較清楚的描述,另外還有一點需要說明:如果DisposePattern類是派生自基類B,而B是一個實現了Dispose的類,那麼DisposePattern中只需要override基類B的帶參的Dispose方法即可,而不需要重寫無參的Dispose和Finalize方法,此時Dispose的實現爲:

class DerivedClass : DisposePattern
    {
        protected override void Dispose(bool disposing)
        {
            lock (this)
            {
                try
                {
                    //清理自己的非託管資源,
                    //實現模式與DisposePattern相同
                }
                finally
                {
                    base.Dispose(disposing);
                }
            }
        }
    }
當然,如果DerivedClass本身沒有什麼資源需要清理,那麼就不需要重寫Dispose方法了,正如我們平時做的一些對話框,雖然都是繼承於System.Windows.Forms.Form,但我們常常不需要去重寫基類Form的Dispose方法,因爲本身沒有什麼非託管的咚咚需要釋放。

瞭解GC的脾性在很多時候是非常必要的,起碼在出現資源泄漏問題的時候你不至於手足無措。我寫過一個生成excel報表的控件,其中對excel對象的釋放就讓我忙活了一陣。如果你做過excel開發的話,可能也遇到過結束excel進程之類的問題,特別是包裝成一個供別人調用的庫時,何時釋放excel對象以確保進程結束是一個關鍵問題。當然,GC的內部機制非常複雜,還有許多內容可挖,但瞭解所有細節的成本太高,只需瞭解基礎,夠用就好。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章