1. 程式人生 > >.NET垃圾回收:原理淺析

.NET垃圾回收:原理淺析

在開發.NET程式過程中,由於CLR中的垃圾回收(garbage collection)機制會管理已分配的物件,所以程式設計師就可以不用關注物件什麼時候釋放記憶體空間了。但是,瞭解垃圾回收機制還是很有必要的,下面我們就看看.NET垃圾回收機制的相關內容。

建立物件

在C#中,我們可以通過new關鍵字建立一個引用型別的物件,比如下面一條語句。New關鍵字建立了一個Student型別的物件,這個新建的物件會被存放在託管堆中,而這個物件的引用會存放在呼叫棧中。(對於引用型別可以檢視,C#中值型別和引用型別)

C#

Student s1 = new Student();

1

Student s1 = new Student();

在C#中,當上面的Student物件被建立後,程式設計師就可以不用關心這個物件什麼時候被銷燬了,垃圾回收器將會在該物件不再需要時將其銷燬。

當一個程序初始化後,CLR就保留一塊連續的記憶體空間,這段連續的記憶體空間就是我們說的託管堆。.NET垃圾回收器會管理並清理託管堆,它會在必要的時候壓縮空的記憶體塊來實現優化,為了輔助垃圾回收器的這一行為,託管堆儲存著一個指標,這個指標準確地只是下一個物件將被分配的位置,被稱為下一個物件的指標(NextObjPtr)。為了下面介紹垃圾回收機制,我們先詳細看看new關鍵字都做了什麼。

new關鍵字

當C#編譯器遇到new關鍵字時,它會在方法的實現中加入一條CIL newobj命令,下面是通過ILSpy看到的IL程式碼。

IL_0001: newobj instance void GCTest.Student::.ctor()

1

IL_0001: newobj instance void GCTest.Student::.ctor()

其實,newobj指令就是告訴CLR去執行下列操作:

  • 計算新建物件所需要的記憶體總數
  • 檢查託管堆,確保有足夠的空間來存放新建的物件

    • 如果空間足夠,呼叫型別的建構函式,將物件存放在NextObjPtr指向的記憶體地址
    • 如果空間不夠,就會執行一次垃圾回收來清理託管堆(如果空間依然不夠,就會報出OutofMemoryException)
  • 最後,移動NextObjPtr指向託管堆下一個可用地址,然後將物件引用返回給呼叫者

按照上面的分析,當我們建立兩個Student物件的時候,託管堆就應該跟下圖一致,NextObjPtr指向託管堆新的可用地址。

託管堆的大小不是無限制的,如果我們一直使用new關鍵字來建立新的物件,託管堆就可能被耗盡,這時託管堆可以檢測到NextObjPtr指向的空間超過了託管堆的地址空間,就需要做一次垃圾回收了,垃圾回收器會從託管堆中刪除不可訪問的物件

應用程式的根

垃圾回收器是如何確定一個物件不再需要,可以被安全的銷燬?

這裡就要看一個應用程式根(application root)的概念。根(root)就是一個儲存位置其中儲存著對託管堆上一個物件的引用,根可以屬性下面任何一個類別:

  • 全域性物件和靜態物件的引用
  • 應用程式程式碼庫中區域性物件的引用
  • 傳遞進一個方法的物件引數的引用
  • 等待被終結(finalize,後面介紹)物件的引用
  • 任何引用物件的CPU暫存器

垃圾回收可以分為兩個步驟:

  1. 標記物件
  2. 壓縮託管堆

下面結合應用程式的根的概念,我們來看看垃圾回收這兩個步驟。

標記物件

在垃圾回收的過程中,垃圾回收器會認為託管堆中的所有物件都是垃圾,然後垃圾回收器會檢查所有的根。為此,CLR會建立一個物件圖,代表託管堆上所有可達物件。

假設託管堆中有A-G七個物件,垃圾回收過程中垃圾回收器會檢查所有的物件是否有活動根。這個例子的垃圾回收過程可以描述如下(灰色表示不可達物件):

  1. 當發現有根引用了託管堆中的物件A時,垃圾回收器會對此物件A進行標記
  2. 對一個根檢測完畢後會接著檢測下一個根,執行步驟一種同樣的標記過程,標記物件B,在標記B時,檢測到物件B內又引用了另一個物件E,則也對E進行標記;由於E引用了G,同樣的方式G也會被標記
  3. 重複步驟二,檢測Globales根,這次標記物件D

程式碼中很有可能多個物件中引用了同一個物件E,垃圾回收器只要檢測到物件E已經被標記過,則不再對物件E內所引用的物件進行檢測,這樣做有兩個目的:一是提高效能,二是避免無限迴圈

所有的根物件都檢查完之後,有標記的物件就是可達物件,未標記的物件就是不可達物件。

壓縮託管堆

繼續上面的例子,垃圾回收器將銷燬所有未被標記的物件,釋放這些垃圾物件所佔的記憶體,再把可達物件移動到這裡以壓縮堆。

注意,在移動可達物件之後,所有引用這些物件的變數將無效,接著垃圾回收器要重新遍歷應用程式的所有根來修改它們的引用。在這個過程中如果各個執行緒正在執行,很可能導致變數引用到無效的物件地址,所以整個程序的正在執行託管程式碼的執行緒是被掛起的。

經過了垃圾回收之後,所有的非垃圾物件被移動到一起,並且所有的非垃圾物件的指標也被修改成移動後的記憶體地址,NextObjPtr指向最後一個非垃圾物件的後面。

物件的代

當CLR試圖尋找不可達物件的時候,它需要遍歷託管堆上的物件。隨著程式的持續執行,託管堆可能越來越大,如果要對整個託管堆進行垃圾回收,勢必會嚴重影響效能。所以,為了優化這個過程,CLR中使用了”代”的概念,託管堆上的每一個物件都被指定屬於某個”代”(generation)。

“代”這個概念的基本思想就是,一個物件在託管堆上存在的時間越長,那麼它就更可能應該保留。託管堆中的物件可以被分為0、1、2三個代:

  • 0代:從沒有被標記為回收的新分配的物件
  • 1代:在上一次垃圾回收中沒有被回收的物件
  • 2代:在一次以上的垃圾回收後仍然沒有被回收的物件

下面還是通過一個例子看看代這個概念(灰色代表不可達物件):

  1. 在程式初始化時,託管堆上沒有物件,這時候新添到託管堆上的物件是的代是0,這些物件從來沒有經過垃圾回收器檢查。假設現在託管堆上有A-G七個物件,託管堆空間將要耗盡。

  2. 如果現在需要更多的託管堆空間來存放新建的物件(H、I、J),CLR就會觸發一次垃圾回收。垃圾回收器就會檢查所有的0代物件,所有的不可達物件都會被清理,所有沒有被回收掉的物件就成為了1代物件。

  3. 假設現在需要更多的託管堆空間來存放新建的物件(K、L、M),CLR會再觸發一次垃圾回收。垃圾回收器會先檢查所有的0代物件,但是仍需要更多的空間,那麼垃圾回收器會繼續檢查所有 的1代物件,整理出足夠的空間。這時,沒有被回收的1代物件將成為2代物件。2代物件是目前垃圾回收器的最高代,當再次垃圾回收時,沒有回收的物件的代數依然保持2。

通過前面的描述可以看到,分代可以避免每次垃圾回收都遍歷整個託管堆,這樣可以提高垃圾回收的效能

System.GC

.NET類庫中提供了System.GC型別,通過該型別的一些靜態方法,可以通過程式設計的方式與垃圾回收器進行互動。

看一個簡單的例子:

C#

class Student { public int Id { get; set; } public string Name { get; set; } public int Age { get; set; } public string Gender { get; set; } } class Program { static void Main(string[] args) { Console.WriteLine("Estimated bytes on heap: {0}", GC.GetTotalMemory(false)); Console.WriteLine("This OS has {0} object generations", GC.MaxGeneration); Student s = new Student { Id = 1, Name = "Will", Age = 28, Gender = "Male"}; Console.WriteLine(s.ToString()); Console.WriteLine("Generation of s is: {0}", GC.GetGeneration(s)); GC.Collect(); Console.WriteLine("Generation of s is: {0}", GC.GetGeneration(s)); GC.Collect(); Console.WriteLine("Generation of s is: {0}", GC.GetGeneration(s)); Console.Read(); } }

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

class Student

{

    public int Id { get; set; }

    public string Name { get; set; }

    public int Age { get; set; }

    public string Gender { get; set; }

}

 

class Program

{

    static void Main(string[] args)

    {

        Console.WriteLine("Estimated bytes on heap: {0}", GC.GetTotalMemory(false));

 

        Console.WriteLine("This OS has {0} object generations", GC.MaxGeneration);

 

        Student s = new Student { Id = 1, Name = "Will", Age = 28, Gender = "Male"};

        Console.WriteLine(s.ToString());

 

        Console.WriteLine("Generation of s is: {0}", GC.GetGeneration(s));

 

        GC.Collect();

        Console.WriteLine("Generation of s is: {0}", GC.GetGeneration(s));

 

        GC.Collect();

        Console.WriteLine("Generation of s is: {0}", GC.GetGeneration(s));

 

        Console.Read();

    }

}

程式的輸出為:

從這個輸出,我們也可以驗證代的概念,每次垃圾清理後,如果一個物件沒有被清理,那麼它的代就會提高。

強制垃圾回收

由於託管堆上的物件由垃圾管理器幫我們管理,所有我們不需要關心託管堆上物件的銷燬以及記憶體空間的回收。

但是,有些特殊的情況下,我們可能需要通過GC.Collect()強制垃圾回收:

  1. 應用程式將要進入一段程式碼,這段程式碼不希望被可能的垃圾回收中斷
  2. 應用程式剛剛分配非常多的物件,程式想在使用完這些物件後儘快的回收記憶體空間

使用強制垃圾回收時,建議同時呼叫”GC.WaitForPendingFinalizers();”,這樣可以確定在程式繼續執行之前,所有的可終結物件都必須執行必要的清除工作。但是要注意,GC.WaitForPendingFinalizers()會在回收過程中掛起呼叫的執行緒

C#

static void Main(string[] args) { â¦â¦ GC.Collect(); GC.WaitForPendingFinalizers(); â¦â¦ }

1

2

3

4

5

6

7

static void Main(string[] args)

{

    ……

    GC.Collect();

    GC.WaitForPendingFinalizers();

    ……

}

每一次垃圾回收過程都會損耗效能,所以要儘量避免通過GC.Collect()進行強制垃圾回收,除非遇到了真的需要強制垃圾回收的情況。

總結

本文介紹了.NET垃圾回收機制的基本工作過程,垃圾回收器通過遍歷託管堆上的物件進行標記,然後清除所有的不可達物件;在託管堆上的物件都被設定了一個代,通過了代這個概念,垃圾回收的效能得到了優化。

下一篇我們看看可終結物件(Finalize)和可處置物件(IDisposable)。