1. 程式人生 > 程式設計 >Java GC:基礎原理

Java GC:基礎原理

Java 使用了垃圾收集器來代替手動管理記憶體,對於垃圾收集器來說,無論哪種,其核心思想都是做兩件事:

  1. 找到哪些物件是存活的(還在使用)
  2. 清除死掉的(不再使用)的物件

標記存活物件:

引用計數法

最直接,最容易想到的標記方法是引用計數法,顧明思議,記錄每個物件被引用的個數,如果為0,則為死亡物件。該方法實現簡單,判斷效率高,但很難解決物件之間相互迴圈引用的問題。

可達性分析計算

在JVM中使用了可達性分析計算的方式來標記存活物件,GC 定義了一些特殊的物件作為 GC Roots:

  • 棧幀中的本地變數和引數
  • 活躍執行緒
  • 已載入的靜態變數
  • JNI 引用

以 GC Roots 作為起始點,沿著引用路徑不斷搜尋,同時標記搜尋到的物件為存活。

注意,在標記階段,需要停止應用執行緒(Stop the World),因為沒有辦法在應用程式不斷改變引用關係的同時一邊標記。暫停的時間取決於存活物件的多少,存活的物件越多,需要標記的時間越長。

清除死亡物件

Sweep and Compact

在學術上,標記清除演演算法是最具代表性的演演算法:

Marking: 通過 GC Roots 開始搜尋標記可達的物件

Sweeping: 使得被未標記的物件佔用的記憶體空間可以被之後分配使用

但是這樣直接 Sweep 會存在兩個問題:

  • 寫操作時需要尋找可用塊,會更加費時
  • 空間碎片太多會導致分配較大物件時無法得到足夠連續記憶體

為了避免這個問題,還需要做一次碎片整理

Copy

還有一種更簡單的方法,將記憶體分為兩塊,每次只使用其中的一塊區域,發生 GC 時,將存活的物件複製到另一個區域中,這樣也不會出現碎片的問題,此外,複製可以在標記的同時進行,更加高效。缺點也很明顯,需要更多的記憶體。這種演演算法被稱為標記-複製演演算法。

分代假說

研究人員觀察到,應用程式內的大多數分配分為兩類:

  • 大部分物件建立後很快就不再使用
  • 有一些物件會存活很長一段時間

基於這個假設,虛擬機器器將記憶體分為了兩個代,分別為 新生代(Young Generation), 和老年代(Old Generation or Tenured)。

那麼針對不同代的特點,可以有針對性的進行演演算法優化,一般來說將演演算法分為 Mionr GC(只回收新生代物件)和 Full GC(全域性回收)。 這個假設也存在兩個問題:

  • 兩個代之間的物件可能存在引用,即使只進行 Mionr gc,也需要掃描一遍老年代物件檢查是否存在老年代物件引用新生代物件,違背了分代的初衷
  • 分代假設可能不適用於某些應用。由於GC對演演算法是專門針對快速死亡的物件和存活長時間的物件進行了優化,因此對有“中等”壽命的物件的處理,JVM 表現的不太好

記憶體劃分

通常情況下 Eden 是物件建立時被分配的區域。由於涉及到多個執行緒同時建立物件,Eden 被劃分成了一個或多個 Thread Local Allocation Buffer (TLAB) ,簡單來說,每個執行緒都被分配了一塊區域用於本執行緒的物件分配(避免的執行緒同步代價),如果分配的記憶體不夠使用了,則使用共有的部分(申請新的TLAB),如果再不夠,則觸發一次新生代 GC(Minor GC) ,如果清理後的記憶體仍然不夠,則將物件分配在老年代。

在 Mionr GC 時,首先通過 GC Roots 掃描標記所有存活的物件,需要注意之前提到過,老年代的物件也有可能引用新生代物件。對於這個問題,JVM 使用了 card-marking 來避免老年代的掃描。HotSpot 使用了卡表(Card Table)的技術,將整個堆劃分為一個個大小為512位元組的卡,如果卡中的物件可能指向新生代物件引用,那麼這張卡是髒的,同時 JVM 維護了一個卡表,每張卡都有一個對應的標識位來表示是否是髒卡。那麼在進行 Minor GC 時,只需將髒卡中的物件將入到 GC Roots 裡,而不用掃描整個老年代。

完成標記後,將所有存活的物件複製到其中一個 Survivor 區中,此後整個 Eden 區的記憶體都可以重新被使用了。這個演演算法也叫做“標記-複製“演演算法(Mark and Copy)。 Survivor 分為 from 和 to 兩個區域(每次GC後身份互換),其中 to 區域永遠是空的,當GC完成標記後,Eden 和 from 區域的存活物件都複製到 to 區域中,from區域清空,兩個區域身份互換。

物件可能在兩個 Survivor 中不斷的來回複製,當複製達到一定次數時(預設15次),將被認為足夠老,晉升到老年代中。此外,如果 Survivor 區域大小不夠存放所有存活物件,則會將較老的物件提前晉升到老年代中。

老年代記憶體要大的多,並且大多數物件都不會是垃圾,並且發生GC的頻率要相對小的多,所以複製演演算法不適用。一般來說,使用“標記-清除-整理“的演演算法對老年代進行回收。

至於 JVM 是如如何使用這些演演算法實現具體的回收器的,請看這篇 通俗易懂 JVM 中的 GC 實現

圖片來源及參考資料:《Plumbr Handbook Java Garbage Collection》