不可不知的垃圾收集演算法

帥帥的Max發表於2020-10-12

在C,C++語言中,程式的記憶體使用空間都是靠程式設計師手動進行分配和回收的。但是在Java語言中,垃圾回收都是交給虛擬機器自動完成。

1.理解垃圾收集

對於垃圾收集(Garbage Collection,GC),我們必須要提出靈魂三問:

  1. 哪些記憶體需要回收?
  2. 什麼時候回收?
  3. 如何回收?

雖然說記憶體的動態分配與記憶體回收技術已經相當成熟,一切看起來都進入了“自動化”時代,那麼為什麼還要去了解GC和記憶體分配呢?答案是:當需要排查各種記憶體溢位,記憶體洩漏問題時,當垃圾收整合為系統達到更高併發量的瓶頸時,那麼我們就需要對這些所謂“自動化”的技術實施必要的監控和調節。

當然我們瞭解到程式計數器,虛擬機器棧,本地方法棧這3個區域都是跟隨著執行緒的生命週期,執行緒生則生,執行緒死則死。這幾塊記憶體分配基本上都是在類結構確定下來時就已經知道了,所以這幾個區域記憶體的分配和回收都具有確定性,無需過多考慮。但是Java堆和方法區就不一樣,這部分記憶體是動態分配和回收的,後續我們討論的垃圾收集器關注的就是這方面的記憶體區域。

2.如何判斷GC垃圾

2.1引用計數法

演算法描述:給物件新增一個引用計數器,每當有一個地方引用它的時候,計數器值+1;當引用失效時,計數器值-1;任何時刻計數器的值為0的時候,就是不能被再使用,就可以判定為垃圾。
演算法優點:實現簡單,判定效率高。
演算法缺點:很難解決物件之間的迴圈引用。
演算法說明:大部分情況下,還是非常nice的演算法,我們熟悉的Python語言就是用這種演算法。但是主流的Java虛擬機器裡面並沒有選用引用計數演算法來管理記憶體。

2.2可達性分析

演算法描述:基本思路就是通過一系列“GC Roots”的物件作為起點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連時(就是從GC Roots到這個物件不可達)則認為物件是不可用的。
演算法說明:目前在主流的商用語言中(Java,C#)都使用可達性分析來判定物件是否存活的。

可達性分析演算法判定物件是否可回收

在Java語言中,可作為GC Roots的物件包括以下幾種:

  • 虛擬機器棧(棧幀的本地變數表)中引用的物件。
  • 方法區中類靜態屬性引用的物件。
  • 方法區中常量一你用的物件
  • 本地方法棧中JNI(就是Native方法)引用的物件

3.理解四種引用

無論是引用計數器演算法還是可達性分析演算法,判定物件是否存活都與“引用”有關。在JDK1.2之後,Java對引用概念進行了擴充,將引用分為了以下4種:
1)強引用
程式程式碼中普通存在的,通過new關鍵詞建立的物件。類似“Object obj = new Object()”這類引用,只要強引用還存在,垃圾收集器永遠都不會回收掉被引用的物件。
2)軟引用
用來描述一些還引用但非必須的物件。對於軟引用關聯的物件,在系統將要發生記憶體溢位異常之前,將會把這些物件列入回收範圍之中進行二次回收。如果這次回收還沒有足夠的空間,則丟擲OOM。通過SoftReference來實現軟引用。
3)弱引用
也是用來描述非必需的物件的,但是它的強度比軟引用更弱些。被弱引用關聯的物件只能生存到下一次發生垃圾收集之前,當垃圾收集進行時,無論當前記憶體是否足夠,都會回收掉只被弱引用關聯的物件。通過WeakReference類來實現弱引用。
4)虛引用
也被稱為幽靈引用或者幻影引用,是最弱的一種引用關係。一個物件有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來獲取物件例項。為一個物件設定虛引用的目的就是在這個物件被收集器回收時收到一個系統通知。通過PhantomReference來實現虛引用。

4.方法區垃圾回收

現在這裡說明一下,方法區與堆一樣也是執行緒共享的記憶體區域,但是在JDK1.7中與永久代關聯,在JDK1.8中永久代被廢棄,改稱為元空間(MetaSpace)。
方法區主要用於儲存類資訊、常量池、方法資料、方法程式碼、符號引用等。
方法區的垃圾回收主要針對兩部分內容:廢棄常量和無用的類
舉個例子:以常量池中字面量的回收為例

假如一個字串“abc”已經進入了常量池中,但當前系統中沒有任何一個String物件叫做“abc”,也就是說沒有任何String物件應用常量池中的“abc”常量,換言之,沒有其他地方引用這個字面量,如果這時候發生了記憶體回收,而且必要的話,這個“abc”常量就會被系統清理出常量池。常量池中的其他類(介面),方法,欄位的符號引用與此類似。

判定一個常量是否為“廢棄常量”比較簡單,但是要判斷一個類是否為“無用類”則條件比較苛刻。什麼條件下才能算是“無用類”呢?

  • 該類所有的例項都已經被回收,也就是Java堆中不存在該類的任何例項。
  • 載入該類的ClassLoader已經被回收。
  • 該類對應的java.lang.Class物件沒有再任何地方被引用,也無法通過反射訪問該類方法。

虛擬機器可以對滿足上述3個條件的無用類進行回收,這裡僅僅是“可以”,而不像是物件一樣,不使用了就一定會被回收。

在大量使用反射,動態代理,CGLib等ByteCode框架,動態生成JSP以及OSGI這類頻繁自定義ClassLoader的場景都需要虛擬機器具有類解除安裝功能,以保證方法區不會溢位。

5垃圾收集演算法

5.1標記-清除演算法

標記清除(Mark-Sweep)演算法可謂是最基礎的垃圾手機演算法。為什麼說是最基礎的垃圾收集演算法呢?是因為後續的垃圾收集演算法都是在其基礎之上改進發展而來。
該演算法分為兩個階段:標記清除。首先標記處所有需要回收的物件,在標記完成之後再統一回收所有標記的物件。
缺點:主要有兩個不足之處,一是效率問題,標記和清除的過程效率都不高;二是空間問題,標記清除之後會產生大量不連續的記憶體空間,這會導致後續程式執行過程中申請不到較大記憶體空間而又提前觸發另一次的垃圾收集動作。

標記清除演算法執行過程如圖所示:
標記清除演算法

5.2複製演算法

為了解決效率問題,“複製”演算法出現了。它將記憶體劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊記憶體使用完了之後,就將還存在的物件統一複製到另一塊記憶體上面,然後把使用過的記憶體空間進行一次清理。這樣每次只對半區記憶體進行垃圾回收,也不用考慮記憶體碎片的問題。只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。

複製演算法的執行示意圖如下:
複製演算法示意圖

我們們所知的新生代的記憶體回收就是採用這種演算法。IBM公司專門研究表明,新生代中98%的物件都是“朝生夕死”,所以並不需要將記憶體劃分為1:1比例的記憶體空間,而是將記憶體劃分為一塊較大的Eden區和兩塊較小的Survivor區,每次只是用Eden區和其中一塊Survivor區。當進行垃圾回收時,將Eden區和剛才使用的Survivor區中存活的物件一次性複製到另一個未使用的Survivor區,最後清理掉Eden區和剛才使用過的Survivor區。

5.3標記-整理演算法

我們說,複製演算法一般收集“朝生夕死”的垃圾物件,但是當物件的存活率較高的時候,複製演算法的回收效率就會大打折扣,而且更為關鍵的是,如果不想浪費50%的空間,還必須有額外的空間來提供分配擔保,以應對物件都100%的存活的極端情況。所以老年代就不能使用複製演算法,而是需要接下來介紹的:標記-整理演算法
標記過程與與標記-清除演算法一樣,但後續步驟不是直接對可回收的物件進行回收而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。
標記整理的演算法示意圖如下:

標記整理演算法示意圖

5.4分代收集演算法

目前商用的虛擬機器的垃圾收集演算法都採用分代收集(Generational Collection)演算法,這種演算法並沒有什麼新鮮的思想,只是根據物件的存活週期將記憶體劃分為不同的幾塊。
Java堆記憶體的劃分新生代,老年代。而新生代中由包含了Eden區和兩個大小相等的Survivor區。新生代中的物件都是高死亡率的物件,而且有一個Survivor區做分配擔保,目前來說採用複製演算法最適合不過;而老年代呢,物件的死亡率較低,而且沒有額外的記憶體做分配擔保,故只能採用標記-清除或者標記-整理演算法。

相關文章