深刻理解Java中的Garbage Collection

前提

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

什麼是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多線程

j-v-m-g-c-s-2.png

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

對象判活算法

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

j-v-m-g-c-s-1.png

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算法的時候去理解垃圾收集如何在不一樣的內存池空間中發揮其職責。

j-v-m-g-c-s-3.png

  • 年輕代(Young Generation):包括EdenSurvivor Spaces,而Survivor Spaces又等分爲Survivor 0Survivor 1,有時候也稱爲fromto兩個區。
  • 老年代(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卡片標記)的方式解決了這個問題,這裏不對卡片標記的細節實現進行展開。

j-v-m-g-c-s-4.png

標記階段完成後,Eden中全部存活的對象會被複制到倖存者空間(Survivor Spaces) 的其中一塊空間。複製階段完成後,整個Eden被認爲是空的,能夠從新用於分配更多其餘的對象。這裏採用的GC算法稱爲標記-複製(Mark and Copy) 算法:標記存活的對象,而後複製它們到倖存者空間(Survivor Spaces) 的其中一塊空間,注意這裏是複製,不是移動

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

Survivor Spaces

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

下一次YGC觸發以後,空閒的那一塊倖存者空間纔會入駐對象。年輕代的全部存活的對象(包括Eden和非空的from倖存者區域中的存活對象),都會被複制到to倖存者區域,這個過程完成以後,to倖存者區域會存放着活躍的對象,而from倖存者區域會被清空。接下來,from倖存者區域和to倖存者區域的角色會交換,也就是下一輪YGC觸發以後存活的對象會複製到from倖存者區域,而to倖存者區域會被清空,如此循環往復。

j-v-m-g-c-s-5.png

上面提到的存活對象的複製過程在兩個倖存者空間之間屢次往復以後,某些存活的對象「年齡足夠大」(通過屢次複製還存活下來),則這些「年紀大的」對象就會晉升到老年代中,這些對象會從倖存者空間移動到老年代空間中,而後它們就駐留在老年代中,直到自身變爲不可達。

若是對象在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不可達的對象。
  • 經過把存活的對象連續地複製到老年代內存空間的開頭(也就是起始地址的一端)以壓縮老年代內存空間的內容,這個過程主要包括顯式的內存壓縮從而避免過多的內存碎片。

j-v-m-g-c-s-6.png

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版本 -

j-v-m-g-c-s-7.png


  • -Xmn-XX:NewRatio-XX:SurvivorRatio
VM參數 功能 可選值 默認值
-Xmn 設置年輕代內存大小 - -
-XX:NewRatio= 設置老年代和年輕代的內存大小比值,設置爲4表示年輕代佔堆內存的1/5 - 4
-XX:SurvivorRatio= 設置Eden和倖存者區域的內存大小比值,設置爲8表示from:to:Eden=1:1:8 - 8

j-v-m-g-c-s-8.png

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)階段:收集器把全部未標記的對象進行清理和回收。

j-v-m-g-c-s-9.png

Mark-Sweep-Compact算法

內存碎片化是非移動式收集算法沒法解決的一個問題之一:儘管堆中有可用空間,可是內存管理器卻沒法找到一塊連續內存塊來知足較大對象的分配需求,或者花費較長時間才能找到合適的空閒內存空間。

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

  • 標記階段:收集器從GC Roots開始遍歷全部可達對象,而且對這些存活的對象進行標記。
  • 清理階段:收集器把全部未標記的對象進行清理和回收。
  • 壓縮階段:收集器把全部存活的對象移動到堆內存的起始端,而後清理掉端邊界以外的內存空間。

j-v-m-g-c-s-10.png

對堆內存進行壓縮整理能夠有效地下降內存外部碎片化(External Fragmentation)問題,這個是標記-清理-壓縮算法的一個優點。

Mark-Copy算法

Mark-Copy算法,也就是標記-複製算法,和標記-清理-壓縮算法十分類似,重要的區別在於:標記-複製算法在標記和清理完成以後,全部存活的對象會被複制到一個不一樣的內存區域 -- 倖存者空間。主要包括三個階段:

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

j-v-m-g-c-s-11.png

標記-複製算法能夠避免內存碎片化的問題,可是它的代價比較大,由於用的是半區複製回收,區域可用內存爲原來的一半。

小結

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部分源碼

原文連接

相關文章
相關標籤/搜索