.NET中的內存管理,GC機制,內存釋放過程

引言
作爲一個.NET程序員,我們知道託管代碼的內存管理是自動的。.NET可以保證我們的託管程序在結束時全部釋放,這爲我們編程人員省去了不少麻煩,我們可以連想都不想怎麼去管理內存,反正.NET自己會保證一切。好吧,有道理,有一定的道理。問題是,當我們用到非託管資源時.NET就不能自動管理了。這是因爲非託管代碼不受CLR(Common Language Runtime)控制,超出CLR的管理範圍。那麼如何處理這些非託管資源呢,.NET又是如何管理並釋放託管資源的呢?

自動內存管理和GC
在原始程序中堆的內存分配是這樣的:找到第一個有足夠空間的內存地址(沒被佔用的),然後將該內存分配。當程序不再需要此內存中的信息時程序員需要手動將此內存釋放。堆的內存是公用的,也就是說所有進程都有可能覆蓋另一進程的內存內容,這就是爲什麼很多設計不當的程序甚至會讓操作系統本身都down掉。我們有時碰到的程序莫名其妙的死掉了(隨機現象),也是因爲內存管理不當引起的(可能由於本身程序的內存問題或是外來程序造成的)。另一個常見的實例就是大家經常看到的遊戲的Trainer,他們通過直接修改遊戲的內存達到"無敵"的效果。明白了這些我們可以想象如果內存地址被用混亂了的話會多麼危險,我們也可以想象爲什麼C++程序員(某些)一提起指針就頭疼的原因了。另外,如果程序中的內存不被程序員手動釋放的話那麼這個內存就不會被重新分配,直到電腦重起爲止,也就是我們所說的內存泄漏。所說的這些是在非託管代碼中,CLR通過AppDomain實現代碼間的隔離避免了這些內存管理問題,也就是說一個AppDomain在一般情況下不能讀/寫另一AppDomain的內存。託管內存釋放就由GC(Garbage Collector)來負責。我們要進一步講述的就是這個GC,但是在這之前要先講一下託管代碼中內存的分配,託管堆中內存的分配是順序的,也就是說一個挨着一個的分配。這樣內存分配的速度就要比原始程序高,但是高出的速度會被GC找回去。爲什麼?看過GC的工作方式後你就會知道答案了。

GC工作方式
首先我們要知道託管代碼中的對象什麼時候回收我們管不了(除非用GC.Collect強迫GC回收,這不推薦,後面會說明爲什麼)。GC會在它"高興"的時候執行一次回收(這有許多原因,比如內存不夠用時。這樣做是爲了提高內存分配、回收的效率)。那麼如果我們用Destructor呢?同樣不行,因爲.NET中Destructor的概念已經不存在了,它變成了Finalizer,這會在後面講到。目前請記住一個對象只有在沒有任何引用的情況下才能夠被回收。爲了說明這一點請看下面這一段代碼:
[C#]
object objA = new object();
object objB = objA;
objA = null;
// 強迫回收。
GC.Collect();
objB.ToString(); 

[Visual Basic]
Dim objA As New Object()
Dim objB As Object = objA
objA = Nothing
' 強迫回收。 
GC.Collect()
objB.ToString() 
這裏objA引用的對象並沒有被回收,因爲這個對象還有另一個引用,ObjB。
對象在沒有任何引用後就有條件被回收了。當GC回收時,它會做以下幾步:
確定對象沒有任何引用。
檢查對象是否在Finalizer表上有記錄。
如果在Finalizer表上有記錄,那麼將記錄移到另外的一張表上,在這裏我們叫它Finalizer2。
如果不在Finalizer2表上有記錄,那麼釋放內存。
在Finalizer2表上的對象的Finalizer會在另外一個low priority的線程上執行後從表上刪除。當對象被創建時GC會檢查對象是否有Finalizer,如果有就會在Finalizer表中添加紀錄。我們這裏所說的記錄其實就是指針。如果仔細看這幾個步驟,我們就會發現有Finalizer的對象第一次不會被回收,也就是,有Finalizer的對象要一次以上的Collect操作纔會被回收,這樣就要慢一步,所以作者推薦除非是絕對需要不要創建Finalizer。爲了證明GC確實這麼工作而不是作者胡說,我們將在對象的復活一章中給出一個示例,眼見爲實,耳聽爲虛嘛!^_^
GC爲了提高回收的效率使用了Generation的概念,原理是這樣的,第一次回收之前創建的對象屬於Generation 0,之後,每次回收時這個Generation的號碼就會向後挪一,也就是說,第二次回收時原來的Generation 0變成了Generation 1,而在第一次回收後和第二次回收前創建的對象將屬於Generation 0。GC會先試着在屬於Generation 0的對象中回收,因爲這些是最新的,所以最有可能會被回收,比如一些函數中的局部變量在退出函數時就沒有引用了(可被回收)。如果在Generation 0中回收了足夠的內存,那麼GC就不會再接着回收了,如果回收的還不夠,那麼GC就試着在Generation 1中回收,如果還不夠就在Generation  2中回收,以此類推。Generation也有個最大限制,根據Framework版本而定,可以用GC.MaxGeneration獲得。在回收了內存之後GC會重新排整內存,讓數據間沒有空格,這樣是因爲CLR順序分配內存,所以內存之間不能有空着的內存。現在我們知道每次回收時都會浪費一定的CPU時間,這就是我說的一般不要手動GC.Collect的原因(除非你也像我一樣,寫一些有關GC的示例!^_^)。

Destructor的沒落,Finalizer的誕生
對於Visual Basic程序員來說這是個新概念,所以前一部分講述將着重對C++程序員。我們知道在C++中當對象被刪除時(delete),Destructor中的代碼會馬上執行來做一些內存釋放工作(或其他)。不過在.NET中由於GC的特殊工作方式,Destructor並不實際存在,事實上,當我們用Destructor的語法時,編譯器會自動將它寫爲protected virtual void Finalize(),這個方法就是我所說的Finalizer。就象它的名字所說,它用來結束某些事物,不是用來摧毀(Destruct)事物。在Visual Basic中它就是以Finalize方法的形式出現的,所以Visual Basic程序員就不用操心了。C#程序員得用Destructor的語法寫Finalizer,不過千萬不要弄混了,.NET中已經沒有Destructor了。C++中我們可以準確的知道什麼時候會執行Destructor,不過在.NET中我們不能知道什麼時候會執行Finalizer,因爲它是在第一次對象回收操作後才執行的。我們也不能知道Finalizer的執行順序,也就是說同樣的情況下,A的Finalize可能先被執行,B的後執行,也可能A的後執行而B的先執行。也就是說,在Finalizer中我們的代碼不能有任何的時間邏輯。下面我們以計算一個類有多少個實例爲示例,指出Finalizer與Destructor的不同並指出在Finalizer中有時間邏輯的錯誤,因爲Visual Basic中沒有過Destructor所以示例只有C#版:
[C#]
public class CountObject {
  public static int Count = 0;

  public CountObject() {
    Count++;
  }

  ~CountObject() {
    Count--;
  }
}

static void Main() {
  CountObject obj;
  for (int i = 0; i < 5; i++) {
    obj = null; // 這一步多餘,這麼寫只是爲了更清晰些!
   obj = new CountObject();
  }

  // Count不會是1,因爲Finalizer不會馬上被觸發,要等到有一次回收操作後纔會被觸發。
  Console.WriteLine(CountObject.Count);
  Console.ReadLine();
}
注意以上代碼要是改用C++寫的話會發生內存泄漏,因爲我們沒有用delete操作符手動清理內存,但是在託管代碼中卻不會發生內存泄漏,因爲GC會自動檢測沒有引用了的對象並回收。這裏作者推薦你只在實現IDisposable接口時配合使用Finalizer,在其他的情況下不要使用(可能會有特殊情況)。在非託管資源的釋放一章我們會更好的瞭解IDisposable接口,現在讓我們來做耶穌吧!

對象的復活
什麼?回收的對象也可以"復活"嗎?沒錯,雖然這麼說的定義不準確。讓我們先來看一段代碼:
[C#]
public class Resurrection {
  public int Data;

  public Resurrection(int data) {
    this.Data = data;
  }

  ~Resurrection() {
    Main.Instance = this;
  }
}

public class Main {
  public static Resurrection Instance;

  public static void Main() {
    Instance = new Resurrection(1);

    Instance = null;
    GC.Collect();
    GC.WaitForPendingFinalizers();

    // 看到了嗎,在這裏“復活”了。
    Console.WriteLine(Instance.Data);

    Instance = null;
    GC.Collect();
    Console.ReadLine();
  }
}

[Visual Basic]
Public Class Resurrection
  Public Data As Integer

  Public Sub New(ByVal data As Integer)
    Me.Data = data
  End Sub

  Protected Overrides Sub Finalize()
    Main.Instance = Me
    MyBase.Finalize()
  End Sub
End Class

Public Class Main
  Public Shared Instance As Resurrection

  Sub Main()
    Instance = New Resurrection(1)

    Instance = Nothing
    GC.Collect()
    GC.WaitForPendingFinalizers()

    ' 看到了嗎,在這裏“復活”了。
    Console.WriteLine(Instance.Data)

    Instance = Nothing
    GC.Collect()
    Console.ReadLine()
  End Sub
End Class
你可能會問:"既然這個對象能復活,那麼這個對象在程序結束後會被回收嗎?"。會,"爲什麼?"。讓我們按照GC的工作方式走一遍你就明白是怎麼回事了。
1、執行Collect。檢查引用。沒問題,對象已經沒有引用了。
2、創建新實例時已經在Finalizer表上作了紀錄,所以我們檢查到了對象有Finalizer。
3、因爲查到了Finalizer,所以將記錄移到Finalizer2表上。
4、在Finalizer2表上有記錄,所以不釋放內存。
5、Collect執行完畢。這時我們用了GC.WaitForPendingFinalizers,所以我們將等待所有Finalizer2表上的Finalizers的執行。
6、Finalizer執行後我們的Instance就又引用了我們的對象。(復活了)
7、再一次去除所有的引用。
8、執行Collect。檢查引用。沒問題。
9、由於上次已經將記錄從Finalizer表刪除,所以這次沒有查到對象有Finalizer。
10、在Finalizer2表上也不存在,所以對象的內存被釋放了。
現在你明白原因了,讓我來告訴你"復活"的用處。嗯,這個……好吧,我不知道。其實,復活沒有什麼用處,而且這樣做也非常的危險。看來這隻能說是GC機制的漏洞(請參看GC.ReRegisterForFinalize再動腦筋想一下就知道爲什麼可以說是漏洞了)。作者建議大家忘掉有什麼復活,避免這類的使用。可能你會問:"那你幹嗎還要對我們說這些?"我說這些爲的是讓大家更好的瞭解GC的工作機制!^_^

非託管資源的釋放
到現在爲止,我們說了託管內存的管理,那麼當我們利用如數據庫、文件等非託管資源時呢?這時我們就要用到.NET Framework中的標準:IDisposable接口。按照標準,所有有需要手動釋放非託管資源的類都得實現此接口。這個接口只有一個方法,Dispose(),不過有相對的Guidelines指示如何實現此接口,在這裏我向大家說一說。實現IDisposable這個接口的類需要有這樣的結構:
[C#]
public class Base : IDisposable {
  public void Dispose() {
    this.Dispose(true);
    GC.SupressFinalize(this);
  }

  protected virtual void Dispose(bool disposing) {
    if (disposing) {
      // 託管類
    }
    // 非託管資源釋放
  }

  ~Base() {
    this.Dispose(false);
  }
}

public class Derive : Base {
  protected override void Dispose(bool disposing) {
    if (disposing) {
      // 託管類
    }
    // 非託管資源釋放
    base.Dispose(disposing);
  }
}

[Visual Basic]
Public Class Base
  Implements IDisposable

  Public Overloads Sub Dispose() Implements IDisposable.Dispose
    Me.Dispose(True)
    GC.SuppressFinalize(Me)
  End Sub

  Protected Overloads Overridable Sub Dispose(ByVal disposing As Boolean)
    If disposing Then
      ' 託管類
    End If
    ' 非託管資源釋放
  End Sub

  Protected Overrides Sub Finalize()
    Me.Dispose(False)
    MyBase.Finalize()
  End Sub
End Class

Public Class Derive
  Inherits Base

  Protected Overloads Overrides Sub Dispose(ByVal disposing As Boolean)
    If disposing Then
      ' 託管類
    End If
    ' 非託管資源釋放

    MyBase.Dispose(disposing)
  End Sub
End Class
爲什麼要這樣設計呢?讓我在後面解說一下。現在我們講講實現這個Dispose方法的幾個準則:
它不能扔出任何錯誤,重複的調用也不能扔出錯誤。也就是說,如果我已經調用了一個對象的Dispose,當我第二次調用Dispose的時候程序不應該出錯,簡單地說程序在第二次調用Dispose時不會做任何事。這些可以通過一個flag或多重if判斷實現。
一個對象的Dispose要做到釋放這個對象的所有資源。拿一個繼承類爲例,繼承類中用到了非託管資源所以它實現了IDisposable接口,如果繼承類的基類也用到了非託管資源那麼基類也得被釋放,基類的資源如何在繼承類中釋放呢?當然是通過一個virtual/Overridable方法了,這樣我們能保證每個Dispose都被調用到。這就是爲什麼我們的設計有一個virtual/Overridable的Dispose方法。注意我們首先要釋放繼承類的資源然後再釋放基類的資源。
因爲非託管資源一定要被保障正確釋放所以我們要定義一個Finalizer來避免程序員忘了調用Dispose的情況。上面的設計就採用了這種形式。如果我們手動調用Dispose方法就沒有必要再保留Finalizer了,所以在Dispose中我們用了GC.SupressFinalize將對象從Finalizer表去掉,這樣再回收時速度會更快。
那麼那個disposing和"託管類"是怎麼回事呢?是這樣:在"託管類"中寫所有你想在調用Dispose時讓其處於可釋放狀態的託管代碼。還記得我們說過我們不知道託管代碼是什麼時候釋放的嗎?在這裏我們只是去掉成員對象的引用讓它處於可被回收狀態,並不是直接釋放內存。在"託管類"中這裏我們也要寫上所有實現了IDisposable的成員對象,因爲他們也有Dispose,所以也需要在對象的Dispose中調用他們的Dispose,這樣才能保證第二個準則。disposing是爲了區分Dispose的調用方法,如果我們手動調用那麼爲了第二個準則"託管類"部分當然得執行,但如果是Finalizer調用的Dispose,這時候對象已經沒有任何引用,也就是說對象的成員自然也就不存在了(無引用),也就沒有必要執行"託管類"部分了,因爲他們已經處於可被回收狀態了。好了,這就是IDisposable接口的全部了。現在讓我們來回想一下,以前我們可能認爲有了Dispose內存就會馬上被釋放,這是錯誤的。只有非託管內存纔會被馬上釋放,託管內存的釋放由GC管理,我們不用管。

弱引用的使用
A = B,我們稱這樣的引用叫做強引用,GC就是通過檢查強引用來決定一個對象是否是可以回收的。另外還有一種引用稱作弱引用(WeakReference),這種引用不影響GC回收,這就是它的用處所在。你會問到底有什麼用處。現在我們來假設我們有一個很胖的對象,也就是說它佔用很多內存。我們用過了這個對象,打算將它的引用去掉好讓GC可以回收內存,但是功夫不大我們又需要這個對象了,沒辦法,重新創建實例,怎麼創建這麼慢啊?有什麼辦法解決這樣的問題?有,將對象留在內存中不就快了嘛!不過我們不想這樣胖得對象總佔着內存,而我們也不想總是創建這樣胖的新實例,因爲這樣很耗時。那怎麼辦……?聰明的朋友一定已經猜到了我要說解決方法是弱引用。不錯,就是它。我們可以創建一個這個胖對象的弱引用,這樣在內存不夠時GC可以回收,不影響內存使用,而在沒有被GC回收前我們還可以再次利用該對象。這裏有一個示例:
[C#]
public class Fat {
  public int Data;

  public Fat(int data) {
    this.Data = data;
  }
}

public class Main {
  public static void Main() {
    Fat oFat = new Fat(1);
    WeakReference oFatRef = new WeakReference(oFat);
    // 從這裏開始,Fat對象可以被回收了。
    oFat = null;
    if (oFatRef.IsAlive) {
      Console.WriteLine(((Fat) oFatRef.Target).Data); // 1
    }
    // 強制回收。
    GC.Collect();
    Console.WriteLine(oFatRef.IsAlive); // False
    Console.ReadLine();
  }
}

[Visual Basic]
Public Class Fat
  Public Data As Integer

  Public Sub New(ByVal data As Integer)
    Me.Data = data
  End Sub
End Class

Public Module Main
  Sub Main()
    Dim oFat As New Fat(1)
    Dim oFatRef As New WeakReference(oFat)
    ' 從這裏開始,Fat對象可以被回收了。
    oFat = Nothing
    If oFatRef.IsAlive Then
      Console.WriteLine(DirectCast(oFatRef.Target, Fat).Data) ' 1
    End If
    ' 強制回收。
    GC.Collect()
    Console.WriteLine(oFatRef.IsAlive) ' False
    Console.ReadLine()
  End Sub
End Module
這裏我們的Fat其實並不是很胖,但是可以體現示例的本意:如何使用弱引用。那如果Fat有Finalizer呢,會怎樣?如果Fat有Finalizer那麼我們可能會用到WeakReference的另一個構造函數,當中有一參數叫做TrackResurrection,如果是True,只要Fat的內存沒被釋放我們就可以用它,也就是說Fat的Finalizer執行後我們還是可以恢復Fat(相當於第一次回收操作後還可恢復Fat);如果TrackResurrection是False,那麼第一次回收操作後就不能恢復Fat對象了。

總結
我在這裏寫出了正篇文章的要點:
一個對象只當在沒有任何引用的情況下才會被回收。
一個對象的內存不是馬上釋放的,GC會在任何時候將其回收。
一般情況下不要強制回收工作。
如果沒有特殊的需要不要寫Finalizer。
不要在Finalizer中寫一些有時間邏輯的代碼。
在任何有非託管資源或含有Dispose的成員的類中實現IDisposable接口。
按照給出的Dispose設計寫自己的Dispose代碼。
當用胖對象時可以考慮弱引用的使用。
好了,就說到這裏了,希望對GC的瞭解會讓您的代碼更加穩固,更加簡潔,更加快!更重要的,不再會有內存管理問題,無論是託管還是非託管!
發佈了5 篇原創文章 · 獲贊 5 · 訪問量 25萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章