1. 程式人生 > >深入理解Java中的Garbage Collection

深入理解Java中的Garbage Collection

前提

最近由於系統業務量比較大,從生產的GC日誌(結合Pinpoint)來看,需要對部分系統進行GC調優。但是鑑於以往不是專門做這一塊,但是一直都有零散的積累,這裡做一個相對全面的總結。本文只針對HotSpot VM也就是Oracle Hotspot VM或者OpenJDK Hotspot VM,版本為Java8,其他VM不一定適用。

什麼是GC(Garbage Collection)

Garbage Collection可以翻譯為“垃圾收集” -- 一般主觀上會認為做法是:找到垃圾,然後把垃圾扔掉。在VM中,GC的實現過程恰恰相反,GC的目的是為了追蹤所有正在使用的物件,並且將剩餘的物件標記為垃圾,隨後標記為垃圾的物件會被清除,回收這些垃圾物件佔據的記憶體,從而實現記憶體的自動管理。

分代假說(Generational Hypothesis)

名稱 具體內容
弱分代假說(Weak Generational Hypothesis) 大多數物件在年輕時死亡
強分代假說(Strong Generational Hypothesis) 越老的物件越不容易死亡

弱分代假說已經在各種不同型別的程式設計正規化或者程式語言中得到證實,而強分代假說目前提供的證據並不充足,觀點還存在爭論。

分代垃圾回收器的主要設計目的是減少回收過程的停頓時間,同時提升空間吞吐量。如果採用複製演算法對年輕代物件進行回收,那麼期望的停頓時間很大程度取決於次級回收(Minor Collection

)之後存活的物件總量,而這一數值又取決於年輕代的整體空間。

如果年輕代的整體空間太小,雖然一次回收的過程比較快,但是由於兩次回收之間的間隔太短,年輕代物件有可能沒有足夠的時間“到達死亡”,因而導致回收的記憶體不多,有可能引發下面的情況:

  • 年輕代的物件回收過於頻繁並且存活下來需要複製的物件數量變多,增大垃圾回收器停頓執行緒和掃描其棧上資料的開銷。
  • 將較大比例的年輕代物件提升到老年代會導致老年代被快速填充,會影響整個堆的垃圾回收速率。
  • 許多證據表明,對新生代物件的修改會比老年代物件的修改更加頻繁,如果過早將年輕代物件晉升到老年代,那麼大量的更新操作(mutation)會給賦值器的寫屏障帶來比較大的壓力。
  • 物件的晉升會使得程式的工作集合變得稀疏。

分代垃圾回收器的設計師對上面幾個方面進行平衡的一門藝術:

  1. 要儘量加快次級回收的速度。
  2. 要儘量減少次級回收的成本。
  3. 要減少回收成本更高的主回收(Major Collection)。
  4. 要適當減少賦值器的記憶體管理開銷。

基於弱分代假說,JVM中把堆記憶體分為年輕代(Young Generation)和老年代(Old Generation),而老年代有些時候也稱為Tenured。

JVM對不同分代提供了不同的垃圾回收演算法。實際上,不同分代之間的物件有可能相互引用,這些被引用的物件在分代垃圾回收的時候也會被視為GC Roots(見下一節分析)。弱分代假說有可能在特定場景中對某些應用是不適用的;而GC演算法針對年輕代或者老年代的物件進行了優化,對於具備“中等”預期壽命的物件,JVM的垃圾回收表現是相對劣勢的。

物件判活演算法

JVM中是通過可達性演算法(Reachability Analysis)來判定物件是否存活的。這個演算法的基本思路就是:通過一些列的稱為GC Roots(GC根集合)的活躍引用為起始點,從這些集合節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連時,說明該物件是不可達的。

GC Roots具體是指什麼?這一點可以從HotSpot VMParallel Scavenge原始碼實現總結出來,參考jdk9分支的psTasks.hpppsTasks.cpp

// psTasks.hpp
class ScavengeRootsTask : public GCTask {
 public:
  enum RootType {
    universe              = 1,
    jni_handles           = 2,
    threads               = 3,
    object_synchronizer   = 4,
    flat_profiler         = 5,
    system_dictionary     = 6,
    class_loader_data     = 7,
    management            = 8,
    jvmti                 = 9,
    code_cache            = 10
  };
 private:
  RootType _root_type;
 public:
  ScavengeRootsTask(RootType value) : _root_type(value) {}

  char* name() { return (char *)"scavenge-roots-task"; }

  virtual void do_it(GCTaskManager* manager, uint which);
};

// psTasks.cpp
void ScavengeRootsTask::do_it(GCTaskManager* manager, uint which) {
  assert(ParallelScavengeHeap::heap()->is_gc_active(), "called outside gc");

  PSPromotionManager* pm = PSPromotionManager::gc_thread_promotion_manager(which);
  PSScavengeRootsClosure roots_closure(pm);
  PSPromoteRootsClosure  roots_to_old_closure(pm);

  switch (_root_type) {
    case universe:
      Universe::oops_do(&roots_closure);
      break;

    case jni_handles:
      JNIHandles::oops_do(&roots_closure);
      break;

    case threads:
    {
      ResourceMark rm;
      Threads::oops_do(&roots_closure, NULL);
    }
    break;

    case object_synchronizer:
      ObjectSynchronizer::oops_do(&roots_closure);
      break;

    case flat_profiler:
      FlatProfiler::oops_do(&roots_closure);
      break;

    case system_dictionary:
      SystemDictionary::oops_do(&roots_closure);
      break;

    case class_loader_data:
    {
      PSScavengeKlassClosure klass_closure(pm);
      ClassLoaderDataGraph::oops_do(&roots_closure, &klass_closure, false);
    }
    break;

    case management:
      Management::oops_do(&roots_closure);
      break;

    case jvmti:
      JvmtiExport::oops_do(&roots_closure);
      break;


    case code_cache:
      {
        MarkingCodeBlobClosure each_scavengable_code_blob(&roots_to_old_closure, CodeBlobToOopClosure::FixRelocations);
        CodeCache::scavenge_root_nmethods_do(&each_scavengable_code_blob);
        AOTLoader::oops_do(&roots_closure);
      }
      break;

    default:
      fatal("Unknown root type");
  }

  // Do the real work
  pm->drain_stacks(false);
}

由於HotSpot VM的原始碼裡面註釋比較少,所以只能參考一些資料和原始碼方法的具體實現猜測GC Roots的具體組成:

  • Universe::oops_do:VM的一些靜態資料結構裡指向GC堆裡的物件的活躍引用等等。
  • JNIHandles::oops_do:所有的JNI handle,包括所有的global handle和local handle。
  • Threads::oops_do:所有執行緒的虛擬機器棧,具體應該是所有Java執行緒當前活躍的棧幀裡指向GC堆裡的物件的引用,或者換句話說,當前所有正在被呼叫的方法的引用型別的引數/區域性變數/臨時值。
  • ObjectSynchronizer::oops_do:所有被物件同步器關聯的物件,看原始碼應該是ObjectMonitor中處於Block狀態的物件,從Java程式碼層面應該是通過synchronized關鍵字加鎖或者等待加鎖的物件。
  • FlatProfiler::oops_do:所有執行緒的中的ThreadProfiler
  • SystemDictionary::oops_doSystem Dictionary,也就是系統字典,是記錄了指向Klass,KEY是一個Entry,由KalssNameClassloader組成,實際上,YGC不會處理System Dictionary,但是會掃描System Dictionary,某些GC可能觸發類解除安裝功能,可以這樣理解:System Dictionary包含了所有的類載入器。
  • ClassLoaderDataGraph::oops_do:所有已載入的類或者已載入的系統類。
  • Management::oops_doMBean所持有的物件。
  • JvmtiExport::oops_doJVMTI匯出的物件,斷點或者物件分配事件收集器相關的物件。
  • CodeCache::scavenge_root_nmethods_do:程式碼快取(Code Cache)。
  • AOTLoader::oops_do:AOT載入器相關,包括了AOT相關程式碼快取。

還有其他有可能的引用:

StringTable::oops_do:所有駐留的字串(StringTable中的)。

JVM中的記憶體池

JVM把記憶體池劃分為多個區域,下面分別介紹每個區域的組成和基本功能,方便下面介紹GC演算法的時候去理解垃圾收集如何在不同的記憶體池空間中發揮其職責。

  • 年輕代(Young Generation):包括Eden和Survivor Spaces,而Survivor Spaces又等分為Survivor 0和Survivor 1,有時候也稱為from和to兩個區。
  • 老年代(Old Generation):一般稱為Tenured。
  • 元空間:稱為Metaspace,在Java8中VM已經移除了永久代Permanent Generation。

Eden

伊甸園是地上的樂園,根據《聖經·舊約·創世紀》記載,神·耶和華照自己的形像造了人類的祖先男人亞當,再用亞當的一個肋骨創造了女人夏娃,並安置第一對男女住在伊甸園中。

Eden,也就是伊甸園,是一塊普通的在建立物件的時候進行物件分配的記憶體區域。而Eden進一步劃分為駐留在Eden空間中的一個或者多個Thread Local Allocation Buffer(執行緒本地分配緩衝區,簡稱TLAB),TLAB是執行緒獨佔的。JVM允許執行緒在建立大多數物件的時候直接在相應的TLAB中進行分配,這樣可以避免多執行緒之間進行同步帶來的效能開銷。

當無法在TLAB中進行物件分配的時候(一般是緩衝區沒有足夠的空間),那麼物件分配操作將會在Eden中共享的空間(Common Area)中進行。如果整個Eden都沒有足夠的空間,則會觸發YGC(Young Generation Garbage Collection),以釋放更多的Eden中的空間。觸發YGC後依然沒有足夠的記憶體,那麼物件就會在老年代中分配(一般這種情況稱為分配擔保(Handle Promotion),是有前置條件的)。

當垃圾回收器收集Eden的時候,會遍歷所有相對於GC Roots可達的物件,並且標記它們是活物件,這一階段稱為標記階段。

這裡還有一點需要注意的是:堆中的物件有可能跨代連結,也就是有可能年輕代中的物件被老年代中的物件持有(注:老年代中的物件被年輕代中的物件持有這種情況在YGC中不需要考慮),這個時候如果不遍歷老年代的物件,那麼就無法通過可達性演算法分析這種被被老年代中的物件持有的年輕代物件是否可達。JVM中採用了Card Marking(卡片標記)的方式解決了這個問題,這裡不對卡片標記的細節實現進行展開。

標記階段完成後,Eden中所有存活的物件會被複制到倖存者空間(Survivor Spaces) 的其中一塊空間。複製階段完成後,整個Eden被認為是空的,可以重新用於分配更多其他的物件。這裡採用的GC演算法稱為標記-複製(Mark and Copy) 演算法:標記存活的物件,然後複製它們到倖存者空間(Survivor Spaces) 的其中一塊空間,注意這裡是複製,不是移動。

關於Eden就介紹這麼多,其中TLAB和Card Marking是JVM中的相對底層實現,大概知道即可。

Survivor Spaces

Survivor Spaces也就是倖存者空間,倖存者空間最常用的名稱是from和to。最重要的一點是:倖存者空間中的兩個區域總有一個區域是空的。

下一次YGC觸發之後,空閒的那一塊倖存者空間才會入駐物件。年輕代的所有存活的物件(包括Eden和非空的from倖存者區域中的存活物件),都會被複制到to倖存者區域,這個過程完成之後,to倖存者區域會存放著活躍的物件,而from倖存者區域會被清空。接下來,from倖存者區域和to倖存者區域的角色會交換,也就是下一輪YGC觸發之後存活的物件會複製到from倖存者區域,而to倖存者區域會被清空,如此迴圈往復。

上面提到的存活物件的複製過程在兩個倖存者空間之間多次往復之後,某些存活的物件“年齡足夠大”(經過多次複製還存活下來),則這些“年紀大的”物件就會晉升到老年代中,這些物件會從倖存者空間移動到老年代空間中,然後它們就駐留在老年代中,直到自身變為不可達。

如果物件在Eden中出生並且經過了第一次YGC之後依然存活,並且能夠被Survivor Spaces容納的話,物件將會被複制到Survivor Spaces並且物件年齡被設定為1。物件在Survivor Spaces中每經歷一次YGC之後還能存活下來,則物件年齡就會增加1,當它的年齡增加到晉升老年代的年齡閾值,那麼它就會晉升到老年代也就是被移動到老年代中。晉升老年代的年齡閾值的JVM引數是-XX:MaxTenuringThreshold=n

VM引數 功能 可選值 預設值
-XX:MaxTenuringThreshold=n Survivor Spaces存活物件晉升老年代的年齡閾值 1<= n <= 15 15

值得注意的是:JVM中設定-XX:MaxTenuringThreshold的預設值為最大可選值,也就是15。

JVM還具備動態物件年齡判斷的功能,JVM並不是永遠地要求存活物件的年齡必須達到MaxTenuringThreshold才能晉升到老年代,如果在Survivor Spaces中相同年齡的所有物件的大小總和大於Survivor Spaces的一半,那麼年齡大於或者等於該年齡的物件可以直接晉升到老年代,不需要等待物件年齡到達MaxTenuringThreshold,例如:

型別 佔比 年齡 動作(MaxTenuringThreshold=15)
ObjectType-1 60% 5 下一次YGC如果存活直接晉升到老年代
ObjectType-2 1% 6 下一次YGC如果存活直接晉升到老年代
ObjectType-3 10% 4 下一次YGC如果存活物件年齡增加1

可以簡單總結一下物件進入老年代的幾種情況:

  • 多次YGC物件存活下來並且年齡到達設定的-XX:MaxTenuringThreshold=n導致物件晉升。
  • 因為動態物件年齡判斷導致物件晉升。
  • 大物件直接進入老年代,這裡大物件通常指需要大量連續記憶體的Java物件,最常見的就是大型的陣列物件或者長度很大的字串,因為年輕代完全有可能裝不下這類大物件。
  • 年輕代空間不足的時候,老年代會進行空間分配擔保,這種情況下物件也是直接在老年代分配。

Tenured

老年代(Old Generation)更多時候被稱為Tenured,它的記憶體空間的實現一般會更加複雜。老年代空間一般要比年輕大大得多,它裡面承載的物件一般不會是“記憶體垃圾”,側面也說明老年代中的物件的回收率一般比較低。

老年代發生GC的頻率一般情況下會比年輕代低,並且老年代中的大多數物件都被期望為存活的物件(也就是物件經歷GC之後存活率比較高),因此標記和複製演算法並不適用於老年代。老年代的GC演算法一般是移動物件以最小化記憶體碎片。老年代的GC演算法一般規則如下:

  • 通過GC Roots遍歷和標記所有可達的物件。
  • 刪除所有相對於GC Roots不可達的物件。
  • 通過把存活的物件連續地複製到老年代記憶體空間的開頭(也就是起始地址的一端)以壓縮老年代記憶體空間的內容,這個過程主要包括顯式的記憶體壓縮從而避免過多的記憶體碎片。

Metaspace

在Java8之前JVM記憶體池中還定義了一塊空間叫永久代(Permanent Generation),這塊記憶體空間主要用於存放元資料例如Class資訊等等,它還存放其他資料內容,例如駐留的字串(字串常量池)。實際上永久代曾經給Java開發者帶來了很多麻煩,因為大多數情況下很難預測永久代需要設定多大的空間,因為開發者也很難預測元資料或者字串常量池的具體大小,一旦分配的元資料等內容出現了失敗就會遇到java.lang.OutOfMemoryError: Permgen space異常。排除記憶體溢位導致的java.lang.OutOfMemoryError異常,如果是正常情況下導致的異常,唯一的解決手段就是通過VM引數-XX:MaxPermSize=XXXXm增大永久代的記憶體,不過這樣也是治標不治本。

因為元資料等內容是難以預測的,Java8中已經移除了永久代,新增了一塊記憶體區域Metaspace(元空間),很多其他雜項(例如字串常量池)都移動了Java堆中。Class定義資訊等元資料目前是直接載入到元空間中。元空間是一片分配在機器本地記憶體(native memory)的記憶體區,它和承載Java物件的堆記憶體是隔離的。預設情況下,元空間的大小僅僅受限於機器本地記憶體可以分配給Java程式的極限值,這樣基本可以避免因為新增新的類導致java.lang.OutOfMemoryError: Permgen space異常發生的場景。

VM引數 功能 可選值 預設值
XX:MetaspaceSize=Xm Metaspace擴容時觸發FullGC的初始化閾值 - -
XX:MaxMetaspaceSize=Ym Metaspace的記憶體上限 - 接近於無窮大

常用記憶體池相關的VM引數

  • -Xmx 和 -Xms
VM引數 功能 可選值 預設值
-Xmx 設定最大堆記憶體大小 有下限控制,視VM版本 -
-Xms 設定最小堆記憶體大小 有下限控制,視VM版本 -


  • -Xmn、-XX:NewRatio 和 -XX:SurvivorRatio
VM引數 功能 可選值 預設值
-Xmn 設定年輕代記憶體大小 - -
-XX:NewRatio= 設定老年代和年輕代的記憶體大小比值,設定為4表示年輕代佔堆記憶體的1/5 - 4
-XX:SurvivorRatio= 設定Eden和倖存者區域的記憶體大小比值,設定為8表示from:to:Eden=1:1:8 - 8

GC型別

參考R大(RednaxelaFX)的知乎回答,其實在HotSpot VM的GC分類只有兩大種:

  • Partial GC:也就是部分GC,不收集整個GC堆。
    • Young GC:只收集young gen的GC。
    • Old GC:只收集old gen的GC,目前只有CMS的concurrent collection是這個模式。
    • Mixed GC:收集整個young gen以及部分old gen的GC,目前只有G1有這個模式。
  • Full GC:收集整個堆,包括young gen、old gen、perm gen(如果存在的話)等所有部分的模式。

因為HotSpot VM發展多年,外界對GC的名詞解讀已經混亂,所以才出現了Minor GCMajor GCFull GC

Minor GC

Minor GC,也就是Minor Garbage Collection,直譯為次級垃圾回收,它的定義相對清晰:發生在年輕代的垃圾回收就叫做Minor GC。Minor Garbage Collection處理過程中會發生:

  1. 當JVM無法為新的物件分配記憶體空間的時候,始終會觸發Minor GC,常見的情況如Eden的記憶體已經滿了,並且物件分配的發生率越高,Minor GC發生的頻率越高。
  2. Minor GC期間,老年代中的物件會被忽略。老年代中的物件引用的年輕代的物件會被認為是GC Roots的一部分,在標記階段會簡單忽略年輕代物件中引用的老年代物件。
  3. Minor GC會導致Stop The World,表現為暫停應用執行緒。大多數情況下,Eden中的大多數物件都可以視為垃圾並且這些垃圾不會被複制到倖存者空間,這個時候Minor GC的停頓時間會十分短暫,甚至可以忽略不計。相反,如果Eden中有大量存活物件需要複製到倖存者空間,那麼Minor GC的停頓時間會顯著增加。

Major GC和Full GC

Major GC(Major Garbage Collection,可以直譯為主垃圾收集)和Full GC目前是兩個沒有正式定義的術語,具體來說就是:JVM規範中或者垃圾收集研究論文中都沒有明確定義Major GC或者Full GC。不過按照民間或者約定俗成,兩者區別如下:

  • Major GC:對老年代進行垃圾收集。
  • Full GC:對整個堆進行垃圾收集 -- 包括年輕代和老年代。

實際上,GC過程是十分複雜的,而且很多Major GC都是由Minor GC觸發的,所以要嚴格分割Major GC或者Minor GC幾乎是不可能的。另一方面,現在垃圾收集演算法像G1收集演算法提供部分垃圾回收功能,側面說明並不能單純按照收集什麼區域來劃分GC的型別。

上面的一些理論或者資料指明:與其討論或者擔心GC到底是Major GC或者是Minor GC,不如花更多精力去關注GC過程是否會導致應用的執行緒停頓或者GC過程是否能夠和應用執行緒併發執行。

常用的GC演算法

下面分析一下目前Hotspot VM中比較常見的GC演算法,因為G1演算法相對複雜,這裡暫時沒有能力分析。

GC演算法的目的

GC演算法的目的主要有兩個:

  1. 找出所有存活的物件,對它們進行標記。
  2. 移除所有無用的物件。

尋找存活的物件主要是基於GC Roots的可達性演算法,關於標記階段有幾點注意事項:

  1. 標記階段所有應用執行緒將會停頓(也就是Stop The World),應用執行緒暫時停頓儲存其資訊在還原點中(Safepoint)。
  2. 標記階段的持續時間並不取決於堆中的物件總數或者是堆的大小,而是取決於存活物件的總數,因此增加堆的大小並不會顯著影響標記階段的持續時間。

標記階段完成後的下一個階段就是移除所有無用的物件,按照處理方式分為三種常見的演算法:

  • Sweep -- 清理,也就是Mark and Sweep,標記-清理。
  • Compact -- 壓縮,也就是Mark-Sweep-Compact,標記-清理-壓縮。
  • Copy -- 複製,也就是Mark and Copy,標記-複製。

Mark-Sweep演算法

Mark-Sweep演算法,也就是標記-清理演算法,是一種間接回收演算法(Indirect Collection),它並非直接檢測垃圾物件本身,而是先確定所有存活的物件,然後反過來判斷其他物件是垃圾物件。主要包括標記和清理兩個階段,它是最簡單和最基礎的收集演算法,主要包括兩個階段:

  • 第一階段為追蹤(trace)階段:收集器從GC Roots開始遍歷所有可達物件,並且對這些存活的物件進行標記(mark)。
  • 第二階段為清理(sweep)階段:收集器把所有未標記的物件進行清理和回收。

Mark-Sweep-Compact演算法

記憶體碎片化是非移動式收集演算法無法解決的一個問題之一:儘管堆中有可用空間,但是記憶體管理器卻無法找到一塊連續記憶體塊來滿足較大物件的分配需求,或者花費較長時間才能找到合適的空閒記憶體空間。

Mark-Sweep-Compact演算法,也就是標記-清理-壓縮演算法,也是一種間接回收演算法(Indirect Collection),它主要包括三個階段:

  • 標記階段:收集器從GC Roots開始遍歷所有可達物件,並且對這些存活的物件進行標記。
  • 清理階段:收集器把所有未標記的物件進行清理和回收。
  • 壓縮階段:收集器把所有存活的物件移動到堆記憶體的起始端,然後清理掉端邊界之外的記憶體空間。

對堆記憶體進行壓縮整理可以有效地降低記憶體外部碎片化(External Fragmentation)問題,這個是標記-清理-壓縮演算法的一個優勢。

Mark-Copy演算法

Mark-Copy演算法,也就是標記-複製演算法,和標記-清理-壓縮演算法十分相似,重要的區別在於:標記-複製演算法在標記和清理完成之後,所有存活的物件會被複制到一個不同的記憶體區域 -- 倖存者空間。主要包括三個階段:

  • 標記階段:收集器從GC Roots開始遍歷所有可達物件,並且對這些存活的物件進行標記。
  • 清理階段:收集器把所有未標記的物件進行清理和回收 --- 實際上這一步可能是不存在的,因為存活物件指標被複制之後,原來指標所在的位置已經可以重新分配新的物件,可以不進行清理。
  • 複製階段:把所有存活的物件複製到Survivor Spaces中的某一塊空間中。

標記-複製演算法可以避免記憶體碎片化的問題,但是它的代價比較大,因為用的是半區複製回收,區域可用記憶體為原來的一半。

小結

JVM和GC是Java開發者必須掌握的內容,包含的知識其實還是挺多的,本文也只是簡單介紹了一些基本概念:

  • 分代假說。
  • Minor GC、Major GC和Full GC。
  • 記憶體池組成。
  • 常用的GC演算法。

後面會分析一下GC收集器搭配和GC日誌檢視、JVM提供的工具等等。

參考資料:

  • 《深入理解Java虛擬機器-2nd》
  • 《The Garbage Collection Handbook》
  • 知乎-RednaxelaFX部分回答
  • Java Garbage Collection handbook
  • OpenJDK HotSpot VM部分原始碼

原文連結

  • Github Page:http://www.throwable.club/2019/06/09/java-jvm-garbage-collection-summary/
  • Coding Page:http://throwable.coding.me/2019/06/09/java-jvm-garbage-collection-summary