淺談GC 垃圾回收機制

其實作爲小白的我寫程序中根本不用管內存的分配和垃圾回收。但我想了解了解垃圾回收機制,於是稍微花了點時間研究了一下Microsoft .NET的垃圾回收機制。看了一篇英文的介紹,好不容易翻譯了一點(我是文章的搬運工,只是翻譯了一遍,沒有原創性)。如有理解錯誤的地方,敬請提出。

如果讓我們自己寫程序實現合理的資源管理,我想肯定很多人都寫不出來(包括我)。資源管理對我們來說是一個困難、乏味的工作。也有可能干擾我們的注意點,以至於弄混我們寫程序的主要目的,導致本末倒置。幸好很多語言都有垃圾回收機制,使開發人員不用實時的注意內存管理。那麼資源是如何去分配和管理的?垃圾回收算法是如何工作的?垃圾回收器如何決定去釋放一個內存資源時?資源釋放的方式?以及如何強制清理一個象?


在公共語言運行時(Common Language Runtime),垃圾回收器用作自動內存管理器時,優點如下:

  • 開發人員不用把精力過多投入到內存管理中
  • 有效分配託管堆上的對象(後邊詳細介紹託管堆)
  • 自動回收一些不再使用的對象的內存。
  • 通過確保對象不能使用另一個對象的內容來提供內存安全。

下面我將簡單的介紹我所理解的Microsoft .NET公共語言的垃圾回收機制:

首先使用資源要遵循以下幾個步驟:

  1. 根據所要使用的資源類型分配內存。(如int型分配4個字節)。
  2. 內存分配後還不能直接用,要對內存進行初始化,並初始化資源的狀態,讓資源可用。
  3. 通過訪問類型的實例成員來使用資源。
  4. 改變要清理資源的狀態。
  5. 最後一步,終於可以釋放內存了。

這五個步驟看起來很簡單,卻是大部分程序出錯的主要原因。畢竟我們會經常在不需要內存時忘記釋放內存,或者在內存已經被釋放後又去訪問內存。這兩種Bug會比其它類型的Bug更煩人,因爲這是一個不定時的炸彈,指不定什麼時間就以讓人意想不到的方式爆炸了,而其他的Bug,至少你能找到大概的位置。而GC讓開發者再也不用關注內存的使用情況,什麼時間去釋放內存。但是垃圾回收器是不知道資源在內存中的類型的,意味着垃圾回收器不能做上邊的第4步來改變資源的狀態。

在.NET框架中,開發者可以調用一些Close,Dispose,Finalize方法,這個我會在後邊描述。垃圾回收器可以自動的調用這些方法,是不是很神奇?其實許多類型的資源是不需要清理的,如只需銷燬保存在類型內存中的左、右、長、寬就可以完全清楚矩形資源。另一方面,文件資源、網絡連接資源的類型在銷燬資源時需要執行一些顯式的清理代碼。下邊會簡單介紹如何完成這些任務,現在開始描述內存分配和資源初始化。

內存分配

Microsoft .NET公共語言運行時(Common Language RunTime簡稱CLR)要求所有資源已經被託管堆分配好了,有點類似於C-runtime堆,只在應用程序不需要時自動釋放對象,永遠不會在託管堆中釋放對象。當然,這又提出了一個新的問題,託管堆如何知道應用程序不需要此對象了哪?

現在有很多GC算法,每種算法都有它所適用的環境,不同環境下性能會有所不同。這篇文章主要介紹被CLR使用的GC算法。說明一下託管堆的概念,當一個進程被初始化,運行時會保留一個連續區域的地址空間,該區域最初未被分配存儲空間。這個地址空間就被稱爲託管堆。不懂?沒關係,繼續往下看。託管堆會包含一個指針,命名爲NextObjPtr,此指針指向在堆中分配下一個對象的位置。最初這個指針指向地址保留區的基地址。

應用程序使用new運算符創建一個對象,此運算符首先確保新對象所需的字節適合保留區域。如果合適,NextObjPtr指向堆中的對象,調用該對象的構造函數,new運算符返回對象的地址。

                                                                      圖片1:託管堆

此時,NextObjPtr將被遞增指向下一個對象在堆中的位置,圖一顯示的託管堆中有三個對象A、B、C,下一個被分配的對象位於NextObjPtr所指向的位置。先說一下C-runtime堆如何分配內存的。在C-runtime堆中,爲一個對象分配內存需要遍歷一個結構體組成的鏈表。一旦找到足夠大的塊,就必須拆分塊,並且必須修改鏈接列表節點中的指針,以保持所有內容的完整性。對託管堆來說,分配一個對象僅僅意味着給指針增加一個值。相比之下,這非常快。實際上從託管堆分配對象的速度幾乎與從線程堆棧分配內存的速度一樣快!

聽起來託管堆優於C-runtime堆,速度快還簡單。當然託管堆有這些優勢是因爲它做了一個大的假設:地址空間和存儲是無限的。這一假設(毫無疑問)是不可能出現的,並且託管堆必須使用一種機制,允許堆做出這一假設。這個機制稱爲垃圾回收器,讓我們看看它是如何工作的。

當應用程序調用new運算符來創建對象時,該區域中可能沒有足夠的地址空間分配給該對象。堆通過將新對象的大小添加到NextObjPtr來檢測這一點。 如果nextobjptr超出地址空間區域的末尾,則堆已滿,必須執行回收了。 實際上,當第0代完全滿時,回收就發生了。(堆按代進行組織,因此它可以處理長生存期的對象和短生存期的對象。 垃圾回收主要在回收通常只佔用一小部分堆的短生存期對象時發生。 堆上的對象有三代,垃圾回收常常發生在0代上)簡單的說,就是通過垃圾回收器提高性能的一種機制。 新創建的對象是年輕一代的一部分,在應用程序生命週期早期創建的對象是舊一代的。 將對象分代可以允許垃圾收集器收集特定的代,而不是收集託管堆中的所有對象。後邊會詳細介紹代。

垃圾回收算法

垃圾回收器檢查堆中是否有應用程序將不再使用的對象。 如果有這樣的對象,那麼回收這些對象使用的內存(如果堆得不到更多的內存,new運算符就拋出OutOfMemoryException錯誤)。  那麼垃圾回收器是怎麼知道應用程序是否還使用某個對象?這個問題有點難,彆着急,往下看。

每一個程序都是有開始的第一句代碼(也就是根)。根標識存儲位置,所指的對象在託管堆中或者對象被置爲空。比如,在程序中所有的靜態全局變量(或對象)就會被認爲是程序的根。此外,任何局部變量/對象指針參數在線程的堆棧被認爲是應用程序的根的一部分。最後,任何包含指向託管堆中對象的指針的CPU寄存器也被視爲應用程序根的一部分。活動根的列表由實時(JIT)編譯器和公共語言運行時維護,並可供垃圾收集器的算法訪問。當垃圾收集器開始運行時,它假設堆中的所有對象都是垃圾。換句話說,它假定應用程序的根沒有引用堆中的任何對象。現在,垃圾收集器開始遍歷根並構建從根可以訪問的所有對象的圖表。例如,垃圾收集器可以定位指向堆中對象的全局變量。

圖片2顯示了一個具有多個已分配對象的堆,其中應用程序的根直接指向對象A、C、D和F。所有這些對象都成爲圖形的一部分。添加對象D時,收集器注意到該對象引用對象H,並且對象H也添加到圖形中。收集器繼續遞歸地遍歷所有可到達的對象。

                                                                                 圖2 堆中分配的對象

一旦圖表的這一部分完成,垃圾收集器將檢查下一個根目錄,並再次遍歷對象。當垃圾收集器從一個對象移動到另一個對象時,如果它試圖將一個對象添加到先前添加的圖形中,那麼垃圾收集器可以停止沿着該路徑移動。這有兩個目的。首先,它有助於顯著提高性能,因爲它不會多次遍歷一組對象。其次,如果有對象的循環鏈接列表,它可以防止無限循環。

檢查完所有根之後,垃圾收集器的圖形包含從應用程序根可以訪問的所有對象的集合;應用程序無法訪問圖形中沒有的任何對象,因此被視爲垃圾。垃圾收集器現在線性地遍歷堆,查找連續的垃圾對象塊(現在被認爲是可用空間)。然後垃圾收集器將非垃圾對象在內存中向下移動(使用多年來已知的標準memcpy函數),消除堆中的所有間隙。當然,在內存中移動對象會使指向對象的所有指針失效。所以垃圾收集器必須修改應用程序的根,以便指針指向對象的新位置。此外,如果任何對象包含指向另一個對象的指針,垃圾收集器也負責更正這些指針。圖3顯示了集合之後的託管堆。

                                                                圖3  收集後的託管堆

在識別出所有垃圾、壓縮所有非垃圾、修復所有非垃圾指針之後,nextobjptr就位於最後一個非垃圾對象之後。此時,將再次嘗試新操作,併成功創建應用程序請求的資源。

如您所見,GC會產生顯著的性能損失,這是使用託管堆的主要缺點。但是,請記住,只有當堆已滿時纔會發生GCS,在此之前,託管堆比C運行時堆快得多。運行時的垃圾收集器還提供了一些優化,這些優化大大提高了垃圾收集的性能。在本文的第2部分中,當我討論幾代人時,我將討論這些優化。

在這一點上有一些重要的事情需要注意。您不再需要實現任何代碼來管理應用程序使用的任何資源的生命週期。注意我在本文開頭討論的兩個bug是如何消失的。首先,不可能泄漏資源,因爲從應用程序的根目錄不可訪問的任何資源都可以在某個時刻收集。第二,訪問已釋放的資源是不可能的,因爲如果可以訪問,資源將不會被釋放。如果無法訪問,那麼您的應用程序就無法訪問它。圖4中的代碼演示瞭如何分配和管理資源。

如果GC如此之大,您可能會想知道爲什麼它不在ANSI C++中。原因是垃圾收集器必須能夠識別應用程序的根,並且必須能夠找到所有對象指針。C++的問題在於它允許將指針從一種類型轉換爲另一種類型,並且無法知道指針所指的是什麼。在公共語言運行時中,託管堆始終知道對象的實際類型,並且元數據信息用於確定對象的哪些成員引用其他對象。

定稿:

垃圾收集器提供了一個您可能想要利用的附加功能:終結。完成允許資源在被收集時在其自身之後進行適當的清理。通過使用終結,當垃圾收集器決定釋放資源的內存時,表示文件或網絡連接的資源能夠正確地清理自己。

這裏對發生的事情過於簡單化:當垃圾收集器檢測到對象是垃圾時,垃圾收集器調用對象的Finalize方法(如果存在),然後回收對象的內存。例如,假設您有以下類型(在C中):

public class BaseObj {
    public BaseObj() {
    }

    protected override void Finalize() {
        // Perform resource cleanup code here... 
        // Example: Close file/Close network connection
        Console.WriteLine("In Finalize."); 
    }
}

現在可以通過調用以下命令創建此對象的實例:

BaseObj bo = new BaseObj();

將來某個時候,垃圾收集器將確定此對象是垃圾。當發生這種情況時,垃圾收集器將看到該類型具有Finalize方法,並將調用該方法,從而導致“In Finalize”出現在控制檯窗口中,並回收該對象使用的內存塊。

許多使用C++編程的開發人員在析構函數和終結方法之間繪製了直接的相關性。但是,現在我要警告您:對象終結和析構函數有非常不同的語義,在考慮終結時最好忘記您所知道的關於析構函數的一切。託管對象從來沒有析構函數期間。

在設計類型時,最好避免使用Finalize方法。這有幾個原因:

  1. 可完成的對象將升級到較舊的代,這會增加內存壓力,並防止在垃圾收集器確定對象是垃圾時收集對象的內存。此外,該對象直接或間接引用的所有對象也將被提升。
  2. 可完成對象的分配時間較長。
  3. 強制垃圾收集器執行Finalize方法可能會嚴重損害性能。記住,每個對象都是最終確定的。因此,如果我有一個由10000個對象組成的數組,那麼每個對象都必須調用其finalize方法。
  4. 可終結對象可能引用其他(不可終結)對象,從而不必要地延長其生存期。實際上,您可能需要考慮將一個類型分成兩種不同的類型:一種輕量級類型,具有不引用任何其他對象的Finalize方法,另一種類型沒有引用其他對象的Finalize方法。
  5. 您無法控制何時執行Finalize方法。對象可能會保留資源,直到下次運行垃圾收集器。
  6. 當應用程序終止時,一些對象仍然可以訪問,並且不會調用它們的Finalize方法。如果後臺線程正在使用對象,或者在應用程序關閉或AppDomain卸載期間創建了對象,則可能發生這種情況。此外,默認情況下,當應用程序退出時,不會爲無法訪問的對象調用Finalize方法,以便應用程序可以快速終止。當然,所有操作系統資源都將被回收,但託管堆中的任何對象都無法正常清理。可以通過調用system.gc類型的requestFinalizeOnShutdown方法來更改此默認行爲。但是,您應該小心使用此方法,因爲調用它意味着您的類型正在控制整個應用程序的策略。
  7. 當應用程序終止時,一些對象仍然可以訪問,並且不會調用它們的Finalize方法。如果後臺線程正在使用對象,或者在應用程序關閉或AppDomain卸載期間創建了對象,則可能發生這種情況。此外,默認情況下,當應用程序退出時,不會爲無法訪問的對象調用Finalize方法,以便應用程序可以快速終止。當然,所有操作系統資源都將被回收,但託管堆中的任何對象都無法正常清理。可以通過調用system.gc類型的requestFinalizeOnShutdown方法來更改此默認行爲。但是,您應該小心使用此方法,因爲調用它意味着您的類型正在控制整個應用程序的策略。

如果確定類型必須實現Finalize方法,請確保代碼儘可能快地執行。避免所有會阻止Finalize方法的操作,包括任何線程同步操作。此外,如果讓任何異常退出Finalize方法,系統只假設Finalize方法返回並繼續調用其他對象的Finalize方法。

當編譯器爲構造函數生成代碼時,編譯器會自動插入對基類型的構造函數的調用。同樣,當C++編譯器生成析構函數的代碼時,編譯器會自動調用基類的析構函數。然而,正如我之前所說,終結方法不同於析構函數。編譯器對Finalize方法沒有特殊的知識,因此編譯器不會自動生成代碼來調用基類型的Finalize方法。如果需要此行爲並且經常這樣做,則必須從類型的Finalize方法顯式調用基類型的Finalize方法:

public class BaseObj {
    public BaseObj() {
    }

    protected override void Finalize() {
        Console.WriteLine("In Finalize."); 
        base.Finalize();    // Call base type's Finalize
    }
}

請注意,通常將基類型的Finalize方法調用爲派生類型的Finalize方法中的最後一條語句。這將使基本對象儘可能長時間保持活動狀態。由於調用基類型finalize方法很常見,C的語法可以簡化您的工作。在C中,以下代碼

class MyObject {
    ~MyObject() {
        •••
    }
}

使編譯器生成此代碼:

class MyObject {
    protected override void Finalize() {
        •••
        base.Finalize();
    }
}

請注意,這個C++語法看起來與C++語言定義析構函數的語法完全一樣。但是記住,C不支持析構函數。不要讓相同的語法愚弄你。

(未完待續)

總結:

垃圾收集環境的主要目的是爲開發人員簡化內存管理。這裏只介紹了一些通用的GC概念和內部結構。

 

參考資料:  https://docs.microsoft.com/zh-cn/dotnet/standard/garbage-collection/fundamentals

                               Garbage Collection: Automatic Memory Management in the Microsoft .NET Framework--------Jeffrey Richter

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