1. 程式人生 > >淺談C#的垃圾回收----關於GC、解構函式、Dispose、and Finalize

淺談C#的垃圾回收----關於GC、解構函式、Dispose、and Finalize

    對於.Net CLR的垃圾自動回收,這兩日有興致小小研究了一下。查閱資料,寫程式碼測試,發現不研究還罷,越研究越不明白了。在這裡sban寫下自己的心得以拋磚引玉,望各路高手多多指教。

    近日瀏覽Msdn2,有一段很是費解,引於此處:

實現 Finalize 方法或解構函式對效能可能會有負面影響,因此應避免不必要地使用它們。用 Finalize 方法回收物件使用的記憶體需要至少兩次垃圾回收。當垃圾回收器執行回收時,它只回收沒有終結器的不可訪問物件的記憶體。這時,它不能回收具有終結器的不可訪問物件。它改為將這些物件的項從終止佇列中移除並將它們放置在標為準備終止的物件列表中。該列表中的項指向託管堆中準備被呼叫其終止程式碼的物件。垃圾回收器為此列表中的物件呼叫 Finalize 方法,然後,將這些項從列表中移除。後來的垃圾回收將確定終止的物件確實是垃圾,因為標為準備終止物件的列表中的項不再指向它們。在後來的垃圾回收中,實際上回收了物件的記憶體。

    原文:http://msdn2.microsoft.com/zh-cn/library/0s71x931(VS.80).aspx
    英文:把zh-cn替成en-us。此文件對應.net2.0,把VS.80替成VS.90可檢視.net3.5最新文件。兩者無甚差別,可見自.net1.1之後,垃圾回收機制沒有改變。

    據上文引用,關於GC的二次回收,sban作圖一張,如下:


    為了驗證GC對含有終結器物件的兩次回收機制,我寫了一個例子測試,程式碼如下:

using System;
using System.Threading;
using System.IO;
using System.Data.SqlClient;
using System.Net;

namespace Lab
{
    
class Log
    {
        
public static readonly string logFilePath = @"d:/log.txt";

        
public static void Write(string s)
        {
            Thread.Sleep(
10);
            
using (StreamWriter sw = File.AppendText(logFilePath))
            //此處有可能丟擲檔案正在使用的異常,但不影響測試
            {
                sw.WriteLine(
"{0}/tTotalMilliseconds:{1}/tTotalMemory:{2}", s,
                    DateTime.Now.TimeOfDay.TotalMilliseconds, GC.GetTotalMemory(
false));
                sw.Close();
            }
        }
    }

    
class World
    {
        
protected FileStream fs = null;
        
protected SqlConnection conn = null;

        
public World()
        {
            fs 
= new FileStream(Log.logFilePath, FileMode.Open);
            conn 
= new SqlConnection();
        }

       
protectedvoid Finalize()
        {
            fs.Dispose();
            conn.Dispose();
            Log.Write(
"World's destructor is called");
        }
    }

    
class China : World
    {
        
public China()
            : 
base()
        {

        }

        
~China()
        {
            Log.Write(
"China's destructor is called");
        }

    }

    
class Beijing : China
    {
        
public Beijing()
            : 
base()
        {

        }

        
~Beijing()
        {
            Log.Write(
"Beijing's destructor is called");
        }
    }
}

using System;
using System.Collections.Generic;
using System.Text;
using System.Data.SqlClient;

namespace Lab
{
    
class Program
    {
        
static void Main(string[] args)
        {
            TestOne();
            Log.Write(
"In Main../t/t");
        }

        
static void TestOne()
        {
            
new Beijing();

            GC.Collect();
            GC.WaitForPendingFinalizers();
            Log.Write(
"In TestOne../t/t");
        }
    }
}


    F5執行一下,返回如下結果:

Beijing's destructor is called  TotalMilliseconds:53009847.4384    TotalMemory:701044China's destructor is called    TotalMilliseconds:53009857.4528    TotalMemory:717428World's destructor is called    TotalMilliseconds:53009867.4672    TotalMemory:733812In TestOne..                    TotalMilliseconds:53009877.4816    TotalMemory:758388
In Main..                       TotalMilliseconds:
53009887.496     TotalMemory:783056

Beijing's destructor is called  TotalMilliseconds:53589020.248     TotalMemory:697744
China's destructor is called    TotalMilliseconds:53589030.2624    TotalMemory:714128
World's destructor is called    TotalMilliseconds:53589040.2768    TotalMemory:738704
In TestOne..                    TotalMilliseconds:53589050.2912    TotalMemory:763280
In Main..                       TotalMilliseconds:53589060.3056    TotalMemory:779664

    注:重點看時間與記憶體變化,以下同。

    WaitForPendingFinalizers()相當於join了終結器佇列執行執行緒。派生類Beijing及其父類的終結器確實已經成功執行,但是為什麼記憶體佔用卻不降反升?結合兩次結果來看,垃圾回收器真正釋放記憶體應該是在退出當前應用程式域之後發生的。msdn2中有云:

在應用程式域的關閉過程中,對沒有免除終結的物件將自動呼叫Finalize,即使那些物件仍是可訪問的。

    看來msdn此言非虛

    但是為什麼記憶體沒有真正被GC回收呢?World的終結器既已執行,其中fs.Dispose()與conn.Dispose()也得以成功執行,為什麼就連微軟鼓勵使用的Dispose()也不好使了呢?是fs與conn物件不佔記憶體,差別微乎其微嗎?為了驗證是與不是,把上文例碼中的fs與conn的相關定義及初始化程式碼一併去掉。再執行一下:

Beijing's destructor is called  TotalMilliseconds:54514090.4336    TotalMemory:566124
China's destructor is called    TotalMilliseconds:54514100.448     TotalMemory:582508
World's destructor is called    TotalMilliseconds:54514110.4624    TotalMemory:598892
In TestOne..                    TotalMilliseconds:54514120.4768    TotalMemory:623468
In Main..                       TotalMilliseconds:54514130.4912    TotalMemory:639852

Beijing's destructor is called  TotalMilliseconds:56343741.3424    TotalMemory:563252
China's destructor is called    TotalMilliseconds:56343751.3568    TotalMemory:579636
World's destructor is called    TotalMilliseconds:56343761.3712    TotalMemory:596020
In TestOne..                    TotalMilliseconds:56343771.3856    TotalMemory:620596
In Main..                       TotalMilliseconds:56343781.4       TotalMemory:636980

    記憶體佔用明顯減少,看樣子沒有冤枉GC。讓它回收,它確實沒有給我幹活啊。

    在C#中,如果一個自定義類沒有構造器,編譯器會新增一個隱藏的無參構造器。但是解構函式不會自動建立。一旦解構函式建立了,終結器也便自動產生了。構構函式其實等同於如下程式碼:

try{
    Finalize();
}finally{
    base.Finalize();
}

    在上文程式碼中,World類雖沒有解構函式,也被派生類China的析構觸發得以執行。

    如果在派生類中不存在析造函式,卻過載了基類的終結器,如下:

protected override void Finalize(){...}

    垃圾回收時,GC找不到建構函式,會直接呼叫終結器。因終結器已重寫,如果在該終結器中不得呼叫基類的終結器,那麼GC將忽略基類。可以利用這個特性寫一個不受垃圾回收器管轄的類,以實現某種特殊的效果。此乃旁邊左道,與高手見笑了。

    對於上文程式碼,如果把TestOne函式改成如下:

static void TestOne()
{
     Beijing bj =
new Beijing();
     GC.Collect();
     Log.Write(
"In TestOne../t/t");
}

    執行一下,GC貌似無用,bj及其父類的解構函式依然在Log.Write("In TestOne...")之後執行,有無WaitForPendingFinalizers()無甚差別。但如果只new一下物件,並不賦值於變數,code如下:

static void TestOne()
{
    
new Beijing();
     GC.Collect();
     Log.Write(
"In TestOne../t/t");
}

     
    執行結果如下:

Beijing's destructor is called  TotalMilliseconds:59773883.6448    TotalMemory:562036
In TestOne..                    TotalMilliseconds:59773883.6448    TotalMemory:586612
China's destructor is called    TotalMilliseconds:59773893.6592    TotalMemory:602996
In Main..                       TotalMilliseconds:59773893.6592    TotalMemory:619380
World's destructor is called    TotalMilliseconds:59773903.6736    TotalMemory:635764

Beijing's destructor is called  TotalMilliseconds:59775696.2512    TotalMemory:561080
China's destructor is called    TotalMilliseconds:59775706.2656    TotalMemory:577464
In TestOne..                    TotalMilliseconds:59775706.2656    TotalMemory:602040
World's destructor is called    TotalMilliseconds:59775716.28      TotalMemory:618424
In Main..


    不用WaitForPendingFinalizers()也觸發了Beijing及其父類的解構函式。GC.Collect是非同步呼叫,該問一代而過。至於Log.Write執行的先後,要看誰能獲得log檔案操作控制代碼。上文寫log檔案的程式碼有問題,多執行緒應用中可能引發檔案正在使用的異常,實際應用中應先申請檔案控制代碼,申請成功lock之後方可操作。由於Write方法中讓執行緒沉睡了10毫秒,故GC在此空檔內有機會獲得了檔案操作控制代碼。

    有兩種情況GC都是可以觸發物件的解構函式的:
    1,如前面所說,在退出當前應用程式域時。
    2,當物件不能再被訪問時。若只是new一個物件,轉行便滿足條件。

    對於GC.Collect,有兩個版本:
    1,GC.Collect();
    2,GC.Collect(int32);引數為Generatio。什麼是Generation?

在.Net中,建立物件所用記憶體在託管堆中分配,垃圾管理器也只管理這個區域。在堆中可配.Net分配的記憶體,被CLR以塊劃分,以代[Gemeration]命名,初始分為256k、2M和10M三個代(0、1和2)。並且CLR可以動態調整代的大小,至於如何調整,策略如何不甚清楚。在堆建立的每一個物件都有一個Generation的屬性。.Net約定,最近建立的物件,其Generation其值為0。建立時間越遠代數越高,下面的程式碼可以說明這一點: using System;
using System.Collections.Generic;
using System.Text;
using System.Data.SqlClient;

namespace Lab
{
    
class Program
    {
        
static void Main(string[] args)
        {
            TestObject obj 
= new TestObject();
            
int generation = 0;

            
for (int j = 0; j < 6; j++)
            {
                generation 
= GC.GetGeneration(obj);
                Console.WriteLine(j.ToString());
                Console.WriteLine(
"TotalMemory:{0}", GC.GetTotalMemory(false));
                Console.WriteLine(
"MaxGeneration:{0}", GC.MaxGeneration);
                Console.WriteLine(
"Value:{0},String:{1}", obj.Value, obj.String.Length);
                Console.WriteLine(
"Generation:{0}", generation);
                Console.WriteLine();
                GC.Collect();
                GC.WaitForPendingFinalizers();
            }
            Console.Read();
        }

        
class TestObject
        {
            
public int Value = 0;
            
public string String = "0";

            
public TestObject()
            {
                
for (int j = 0; j < 100; j++)
                {
                    Value
++;
                    String 
+= j.ToString();
                }
            }
        }
    }
}
執行一個,結果如下:


    GC回收記憶體從0代開始,打掃0代中所有可以清除的物件。暫時不可清除的物件移到1代中。依此類推,清除1代物件時,尚用物件則移至2代。第一次回收之後,可回收記憶體空間已經很小,回收效果已不明顯。故平常強制垃圾回收用函式GC.Collect()不如用GC.Collect(0)。

在AS3中,有垃圾自動回收機制,但是沒有提供介面給使用者,是不可操控的。但可以通過丟擲某些物件的異常,來激發垃圾回收執行。程式碼如下: public class GC
{
     
private function GC(){};

     
public static function Collect():void
      {
           
try{
                 
new LocalConnection .connect("GC1");
                 
new LocalConnection .connect("GC2");
            }
catch(e:*){}
       }
}
對於比較耗費資源的物件,如LocalConnection,如果它們丟擲異常,一般垃圾回收器不會坐視不理。那麼,這個不怎麼正宗的方法在.Net也可以嗎?答案是肯定的。在.Net中,如果檔案控制代碼、資料庫連線等物件操作出錯時,GC會嘗試強制回收記憶體。修改上文Main函式程式碼如下,以作測試: static void Main(string[] args)
        {
            TestObject obj 
= new TestObject();
            
int generation = 0;

            generation 
= GC.GetGeneration(obj);
            Console.WriteLine(
0);
            Console.WriteLine(
"TotalMemory:{0}", GC.GetTotalMemory(false));
            Console.WriteLine(
"MaxGeneration:{0}", GC.MaxGeneration);
            Console.WriteLine(
"Value:{0},String:{1}", obj.Value, obj.String.Length);
            Console.WriteLine(
"Generation:{0}", generation);
            Console.WriteLine();

            
try
            {
                
new SqlConnection("Null").Open();
            }
            
catch (Exception e) { }

            
for (int j = 1; j < 6; j++)
            {
                generation 
= GC.GetGeneration(obj);
                Console.WriteLine(j.ToString());
                Console.WriteLine(
"TotalMemory:{0}", GC.GetTotalMemory(false));
                Console.WriteLine(
"MaxGeneration:{0}", GC.MaxGeneration);
                Console.WriteLine(
"Value:{0},String:{1}", obj.Value, obj.String.Length);
                Console.WriteLine(
"Generation:{0}", generation);
                Console.WriteLine();
                GC.Collect();
                GC.WaitForPendingFinalizers();
            }
            Console.Read();
        }
    執行一下,結果如圖所示:

    可見,SqlConnection丟擲異常時,GC果真進行了回收。再執行一下,結果卻變了:

唏!怎麼沒有回收,記憶體反而升高了。可見,GC確實有點智慧,第一次回收了,第二次似乎做了點別的動作,致使記憶體反而升高。Msdn2中有云,GC自己可以確定回收垃圾的最好時機與方法,所以奉勸使用者一般不要手動干預,不然可能會南轅北轍。 那.Net程式設計師在程式設計時應該怎麼做,有沒有一種既簡單又有有效的方法來處理內在回收。愚人作以下建議,望各路高手不吝賜教:        1,對於不包涵或沒有引用(直接或間接)非託管資源的類,特別是作用如同Struct的實體類,析構、終結器、Dispose均不採用。        2,對於包涵非託管資源的類,如資料庫連線物件,檔案控制代碼等,應繼承IDispose介面,在Dispose方法中清理非託管物件。客戶程式碼用using(…){}格式顯示呼叫Dispose。如果繼承了IDispose介面,Dispose方法就不要留空,這樣沒有任何意義。除了構造器,任何方法體留空都有害無益。        3,所有自定義類一般均不建議顯式宣告解構函式、Finalize方法。

文章:淺談C#的記憶體回收----關於GC、解構函式、Dispose、and Finalize
作者:sban 2007/12/1首發於部落格園