天天看點

JVM系列之二 分代、垃圾收集算法與垃圾收集器

前言

本文是筆者JVM系列的第二篇文章,盡我所能将Java堆的分代、垃圾收集算法與垃圾收集器講出來,如果閱讀過程中遇到突然出現的術語,請先參考【七、術語參考】,如文中有錯誤或表述不準确的地方,感謝評論指出。

一、垃圾回收概念

對象配置設定記憶體在堆上,當對象不再使用,它們就變成了垃圾,需要被清理以釋放記憶體,這個過程稱作垃圾收集(簡稱GC,Garbage Collection)。

二、對象是否死亡

垃圾回收需要确定哪些對象是不可用的,是以需要确定對象已“死”,針對對象判活有以下兩種算法:

  • 引用計數算法

    :一個對象每被其他方法或對象引用,就為它的引用數+1,不再引用時引用數-1,當對象引用數為0時,對象已死。
  • 可達性分析

    :基本思想是通過一系列可以作為GC Roots的對象作為起始點,向下搜尋被關聯的對象,當可達性分析完成、還未被關聯的對象已死。
  • 引用計數算法很難解決對象之間循環引用的問題,是以沒有Java虛拟機使用此算法。
  • 可作為GC Roots的對象有虛拟機棧中引用的對象、方法區靜态引用的對象、方法區常量引用的對象、本地方法棧中JNI(Native方法)引用的對象

三、分代概念

了解了可達性分析就可以知道哪些對象已死,當GC發生時就需要在整個堆中所有對象開始可達性分析,随着堆的容量變大,每次GC要回收的對象就越多,分析的時間就越長,回收效率将大幅降低,而且很多對象常駐記憶體每次都被分析也會浪費時間。

于是,分代概念出現了,分代将整個堆分為年輕代與老年代,将存活周期短的對象放入年輕代,将存活周期長的對象放入老年代,當記憶體不足時,回收單個代比回收整個堆效率要高。

JVM系列之二 分代、垃圾收集算法與垃圾收集器
上圖所示即堆記憶體劃分,其中年輕代、老年代、永久代在Java堆實作,在Oracle JDK1.8後廢棄了永久代的概念,将永久代移動到非堆記憶體中,改名為元空間。綠色表示已廢棄,用作對比。

1、年輕代

年輕代回收頻率高,為了進一步縮小回收範圍,将年輕代又分為:

  • Eden:新生對象首先會配置設定到這裡
  • Survivor:初生區回收後存活對象轉移到幸存區,S0與S1使用時每次隻用其中一塊,回收時交換。
    • S0(From Survior):發生回收的區域,存活對象複制到S1,下次運作時暫不使用
    • S1(To Survior):接收S0的存活對象,作為下次運作時使用的區域

當年輕代發生GC後記憶體不足,則将Survivor區存活的對象按一定規則進入老年代,由老年代作年輕代的後備。年輕代記憶體不足還有我老年代嘛~

2、老年代

老年代處于堆中,活得久的對象都在這裡。當年輕代放不下大對象時,将大對象和活得久的對象放到老年代,老年代的特點是回收頻率低、這裡的對象都長壽,沒有其他記憶體對此代做後備。

3、永久代

永久代是 HotSpot 虛拟機中的概念,在堆中實作了方法區,與年輕代和老年代分堆記憶體。由于方法區中存有大量類中繼資料與字面量常量池,類中繼資料解除安裝條件苛刻等原因常駐此代,幾乎不發生GC,永久代由此得名。同時也因為類中繼資料難以解除安裝,當永久代記憶體到達設定值時,容易發生OOM。

在Oracle JDK 1.8之後,将永久代移出了 JVM 堆記憶體,進入直接記憶體并改名為元空間,按直接記憶體回收記憶體的方式管理(不設定元空間大小時,隻受限于剩餘記憶體的餘量;設定元空間最大值時,則在接近滿時,由 JVM 進行一次垃圾回收,不再像堆中回收規則那麼多。可以說是永久代從親娘家搬到了後娘家,不隻改了姓,還變成了後娘養的😆)。

4、元空間

元空間是JDK 1.8後,使用直接記憶體(或稱 堆外記憶體 或 非堆記憶體)實作了方法區,作為永久代的替代品。元空間不再配置設定在堆上,是以年輕代與老年代的可用記憶體會更多,也避免了因永久代 OOM 出現的 Full GC,減少了STW(Stop The World)與 GC(垃圾回收)的頻率,提高了性能。

4.1. 元空間與永久代差別

  • 配置設定位置:永久代配置設定在堆上;元空間配置設定在堆外記憶體。
  • 回收時刻:永久代與老年代幾乎同時進行GC,回收頻率與;元空間使用量到達設定最大值前才會進行GC,預設此值無限,受實體記憶體限制。
  • 設定最大記憶體值:永久代在劃分最大值時就直接占用最大值空間;而元空間則依舊按原來的方式,用一些就申請稍大一些的記憶體,直到到達最大值後不再申請新記憶體。

四、垃圾收集算法

有了分代和對象可達性分析算法還不夠,回收性能高低需要通過合适的垃圾回收算法決定。常見的垃圾回收算法有四種:标記-清除算法、複制算法、标記-整理算法、分代回收算法。

1、标記-清除算法

正如其名,先通過對象判活、标記已死對象,然後再統一回收的算法。

JVM系列之二 分代、垃圾收集算法與垃圾收集器

缺點:

  1. 效率不高
  2. 标記清除後會産生大量不連續的記憶體碎片,空間使用率低。

2、複制算法

複制算法将一塊記憶體劃分為兩塊,每次隻使用其中一塊,先将存活對象複制到另一塊記憶體中,再回收已死對象,這樣就解決了标記-清除算法回收後空間碎片的問題,實作簡單、運作高效。

适用于年輕代,死亡對象多複制對象少的場景。

JVM系列之二 分代、垃圾收集算法與垃圾收集器
  1. 代價高,需要将記憶體縮小為原來的一半。不适用于老年代,這種對象存活率較高的複制操作效率低。
  2. 需要其它記憶體作擔保,防止出現GC後Survivor區仍無法配置設定記憶體的情況。

3、标記-整理算法

标記-整理算法算是對标記-清除算法的一個改進,但标記後并不是直接清理,而是将存活對象向一端移動,然後清理掉端邊界以外的記憶體,其實可以視為“标記-移動-清除”。适用于老年代,存活對象多,死亡對象少的場景。

JVM系列之二 分代、垃圾收集算法與垃圾收集器

缺點:不适用于年輕代,存活對象少,死亡對象多的場景。

4、分代回收算法

有了分代的概念并了解了以上幾種算法的優缺點,就可以針對不同分代的特點選用适合的GC算法。

  • 年輕代對象朝生夕死,死亡對象遠多于存活對象,且有老年代擔保,使用複制算法更合适。
  • 老年代對象存活率高,存活對象遠多于死亡對象,沒有記憶體擔保,使用标記-清理或标記-整理更适合。

五、垃圾收集器

垃圾收集器是垃圾回收算法的具體實作,以下是JVM設計團隊給出的各垃圾收集器适用的分代情況

JVM系列之二 分代、垃圾收集算法與垃圾收集器

以上圖示,相連的垃圾回收器可配合使用。

1、Serial 收集器

Serial 收集器 是最早的垃圾收集器,使用複制算法,特點是啟動單線程去清理,需暫停所有工作線程,有STW,直到收集完成。用于年輕代,是Client模式下預設的年輕代收集器。

1.1. Serial 執行流程

  • 停止使用者線程,STW
  • 開啟單線程使用複制算法,複制存活對象到To Survivor區
  • 交換使用Survivor區指針
  • 清理From Survivor區垃圾
  • 恢複使用者線程

1.2. VM 參數

  • -XX:+UseSerialGC

    :使用 Serial 收集器,Client模式預設開啟,其他模式關閉,打開此開關預設同時開啟 Serial + Serial Old 收集器配合回收堆記憶體。

1.3. 舉個例子

你媽(單線程)打掃衛生時,她會讓你不要動(STW),把你弄亂的東西搬到不礙事(S1)的地方擺好(複制),接着她告訴你一會先去那個收拾好的地方玩(S1),然後開始掃地(清理S0)。掃完她退出房間,你又可以玩耍了 😄

2、ParNew 收集器

ParNew 收集器 是對 Serial 收集器的多線程版本,使用複制算法,特點是暫停所有工作線程,STW,開啟多個GC線程并行垃圾收集,直到收集完成。用于年輕代,是Server模式下首選的收集器。原因是除Serial外,隻有它能與CMS收集器配合工作。

2.1. ParNew 執行流程

  • 開啟多線程并行将存活對象複制到S0(To Survivor)區
  • 交換使用Survivor區指針(原來使用S0換成S1,反之亦然)
  • 并行清理S1(From Survivor)區垃圾

2.2. VM 參數

  • -XX:+UseParNewGC

    :預設關閉,打開後使用ParNew + Serial Old收集器組合進行記憶體回收。

2.3. 舉個例子

一家人(多線程)沖進你的房間,警告你不要動(STW),他們要一起打掃衛生(并行),把除垃圾外的東西移動到另一半房間中(複制存活對象到S1),然後再一起掃地髒的半個房間,完事後才讓你動,并且隻能去另一半還沒使用的房間玩……

3、Parallel Scavenge 收集器

Parallel Scavenge 收集器與ParNew 收集器很類似,也是多線程并行、年輕代收集器,使用複制算法,用在 Server 模式下。與 ParNew 的差別在于,Parallel Scavenge更關注吞吐量,能實作自适應GC調優。

3.1. Parallel Scavenge 執行流程

  • 開啟多線程,并行複制存活對象到 S1 區
  • 交換使用 Survivor 區指針
  • 并行清理 S0 區垃圾
清理過程中PS收集器會計算停頓時間與吞吐量,停頓時間與吞吐量即将不滿足前會提前停止垃圾收集,恢複使用者線程。

3.2. VM 參數

  • -XX:+UseParallelGC

    :Server模式預設開啟,其他模式預設關閉,打開後使用Parallel Scavenge + Serial Old 收集器組合記憶體回收。
  • -XX:MaxGCPauseMillis

    : 控制最大垃圾收集停頓時間,機關毫秒,僅在使用Parallel Scavenge 收集器時生效。
  • -XX:GCTimeRatio

    : 直接控制吞吐量大小,預設值99,表示允許1%的時間用來GC,僅在Parallel Scavenge 收集器時生效。
  • -XX:+UseAdaptiveSizePolicy

    :自适應GC調優開關,預設開啟,使用此參數則無需指定年輕代大小(

    -Xmn

    )、Eden 與 Survivor 區的比例(

    -XX:SurvivorRatio

    )、晉升老年代對象大小(

    -XX:PretenureSizeThreshold

    )等參數,由虛拟機自動設定。

3.3. 舉個例子

一家人(多線程)沖進你的房間要打掃衛生,你和他們約定好“隻給他們3分鐘時間打掃衛生”(制定停頓時間或吞吐量要求),打掃衛生的時候你不會動(STW),就算時間到了沒清理完,也别打掃了你還要學習呢(時間片用完退出GC,恢複使用者線程)。于是可能出現他們隔一會進來打掃3分鐘,過了一會又進來打掃了3分鐘……由于打掃時間不是很長,你感覺還可以接受 🤔

4、Serial Old 收集器

Serial Old 收集器是Serial 收集器的老年代版本,也是單線程垃圾收集,使用标記-整理算法,主要用在Client模式下,是CMS收集器出現

Concurrent Mode Failure

錯誤時的後備方案。可與 Parallel Scavenge收集器配合使用。

4.1. Serial Old 執行流程

  • 開啟單線程使用标記-整理清理垃圾
  • 清理完成,恢複使用者線程

4.2. VM 參數

-XX:+UseSerialGC

或 開啟CMS時作為備用方案連帶使用。

4.3. 舉個例子

你媽(單線程)過來掃地,先讓你不要動(STW),這次媽媽看地上東西比較多(老年代存活對象多),決定不搬到桌子上了,隻把地上有用的東西搬到地面的一個幹淨的角落(标記-整理),然後把其餘的地方都掃了一遍(清理整理好的對象邊界外的記憶體),然後她出了門,你又可以玩耍了(退出GC線程,恢複使用者線程)。

5、Parallel Old 收集器

Parallel Old 收集器是Parallel Scavenge 收集器的老年代版本,使用多線程并行和标記-整理算法。注重吞吐量和CPU敏感的場合,優先考慮Parallel Scavenge與Parallel Old收集器配合使用。

5.1. Parallel Old 執行流程

  • 開啟多線程并行使用标記-整理清理垃圾

5.2. VM 參數

  • -XX:+UseParallelOldGC

    :預設關閉,開啟後使用Parallel Scavenge + Parallel Old 收集器組合記憶體回收。

5.3. 舉個例子

一家人(多線程)又沖進來幫忙打掃衛生,警告你不要動(STW),你說“我不動可以,但你們要3分鐘後出去下,我要和女朋友打電話”(制定吞吐量或停頓時間計劃),家人們露出會心的微笑并同意了你的建議,他們定睛一看屋子裡垃圾不多,能用的東西很多但是亂(老年代存活對象多,垃圾少),為了省事把那些能用的東西一起(并行)搬到一個角落(标記-整理),然後把垃圾清理幹淨,讓你從桌子上下來(恢複使用者線程),出了門 😆

6、CMS 收集器

CMS(Concurrent Mark Sweep) 收集器目标是最短GC的停頓時間,特點是并發收集、GC停頓時間短,使用标記-清除算法。網際網路應用與B/S系統服務端使用較多。

6.1. CMS 執行流程

此收集器工作流程相對複雜,大體分四個步驟:

  • 初始标記(initial mark):STW停止工作線程,單标記線程标記與GCRoots直接關聯的對象,标記完成工作線程繼續執行。
  • 并發标記(concurrent mark):标記與GCRoots間接關聯的對象,并發标記不停止工作線程,标記線程與工作線程同時運作。
  • 重新标記(remark):STW停止工作線程,開啟多線程标記 并發标記 後工作線程産生的新垃圾,标記完成恢複工作線程運作。
  • 并發清除(concurrent sweep):開始清理線程與工作線程并發執行。

工作流程步驟執行時間比較:

并發清理 = 并發标記 > 重新标記 > 初始标記

筆者注:并發清理與并發标記時間不一定相等,兩者都比較慢可視為約等于,沒必要較真。

6.2. CMS收集器缺點

  • 使用标記-清除算法,會出現大量不連續的記憶體碎片而導緻Full GC。當出現

    Concurrent Mode Failure

    ,則需要Serial Old收集器墊底,可能出現新的Full GC。
  • CMS收集器對CPU敏感,當CPU數量少于4個時,對應用程式性能影響較大。
  • 無法處理浮動垃圾,主要是并發清理時工作線程産生的垃圾需要下次GC時才能标記清理掉。

6.3. VM 參數

  • -XX:+UseConcMarkSweepGC

    :預設關閉,使用ParNew + CMS + Serial Old組合記憶體回收,如果CMS收集器出現

    Concurrent Mode Failure

    ,則使用 Serial Old收集器作後備收集器。
  • -XX:CMSInitiatingOccupancyFraction

    :提高CMS啟動門檻值(老年代已用記憶體占比),降低回收頻次。
  • -XX:+UseCMSCompactAtFullCollection

    :開啟此參數,CMS将在即将Full GC前進行記憶體碎片的合并。
  • -XX:CMSFullGCsBeforeCompaction

    :設定執行多少次不整理碎片的Full GC後來一次帶整理的,預設值為0,即每次Full GC時都進行碎片整理。

6.4. 舉個例子

  1. 一開始,你媽(單線程)進來看看大體上哪些東西(GC Roots直接關聯)需要要收拾,記到本子上(初始标記),但不讓你動(STW)
  2. 你站累了,央求你媽,她讓你下來玩,她接着仔細看還有哪些東西需要收拾記在小本子上(并發标記,使用者線程仍在執行)
  3. 你下來後弄亂了點其它東西,你媽讓你停下來(STW),把家人也叫過來幫忙看看本子上沒有的新弄亂的東西(重新标記,記錄新增的垃圾)
  4. 你媽熬不過你的央求,放你下來了(使用者線程恢複運作),按着小本子上記的該收拾的東西移到一個幹淨的角落擺整齊(标記-整理),因為你也要動,會妨礙到她,她動作很慢(并發執行與使用者線程搶時間片),這次清理完時,隻是把她最後一次記錄的東西收拾完了,你在她記錄完後弄亂的東西隻能她下次再來收拾。(CMS無法清理浮動垃圾)

7、G1 收集器

G1(Garbage First)是面向服務端應用的GC收集器,保留分代概念的同時,将堆分成多塊同樣大小的記憶體塊Region,回收整個堆的記憶體,關注停頓時間。

7.1. G1 的特點

  • 有價值的垃圾優先回收(最短時間内釋放最多的記憶體的記憶體塊)
  • 回收範圍是整個堆,保留年輕代與老年代的概念,将整個堆分成多個大小相等的獨立區域(Region),年輕代與老年代實體不再隔離。
  • 充分發揮多核CPU優勢,采用并行與并發縮短STW時間。
  • 沒有空間碎片。整體可視作使用标記-整理算法,局部(兩個Region間複制)可看作是複制算法,兩種算法不會産生碎片。
  • 可以準确設定在M毫秒内,停頓時間不得超過N毫秒。

7.2. G1 為什麼能縮短停頓時間?

G1縮短停頓時間的秘決,是建立在通過 維護 Region垃圾價值 優先回收清單上的,根據允許的收集時間優先回收價值最大的Region,同時記錄各Region中建立對象的引用關系集合(Remembered Set)來避免全堆掃描,以提高引用可達性分析與标記的時間。

7.3. G1 執行流程

G1收集器大緻回收步驟:

  • 初始标記:單線程标記GC Roots直接關聯的對象,并且修改 TAMS(Next Top at Mark Start)的值,讓下一階段與使用者程式并發執行時,能在可用的Region中建立新對象,此階段需要STW,耗時很短。
  • 并發标記:與使用者線程并發執行标記,從GC Roots開始進行可達性分析,标記存活對象,此階段耗時較長。
  • 最終标記:停頓使用者線程,STW,多線程并行标記修正 并發标記 階段使用者線程運作産生的新垃圾。同時将記錄資訊合并到 Remembered Set 中,用作篩選回收的依據。
  • 篩選回收:停頓使用者線程,STW,根據Remembered Set 對各個Region回收價值進行排序,根據使用者設定的停頓時間,制定回收計劃,并多線程并行回收最有價值的Region。回收完成恢複使用者線程。

G1 與 PS/PS Old相比,最大的好處是停頓時間更加可控,可預測。

7.4. VM 參數

  • -XX:+UseG1GC

    :預設關閉,使用G1收集器回收堆記憶體。

7.5. 舉個例子

  1. 房間在沒住進來前,地面就已經按大理石地磚分成了好多等塊的位置(Region)
  2. 你媽進來,讓你别動(STW),大體上看看屋子哪些東西一眼就看得出亂,小本本記下來(初始标記,直接關聯GC Roots),你和她約法三章,一會要寫作業。(設定停頓時間)
  3. 她說你可以動了(恢複使用者線程),你在各個地磚上玩,她在其他地磚上記哪些地磚上垃圾比較多,由于你也在動,她動作很慢,怕傷到你。(并發執行)
  4. 時間久了,你媽也累了,不讓你動了(STW),叫家人進來一起幫忙記(多線程并行,最終标記)。
  5. 最後,家人一起把地磚上相鄰的有用的東西放在一個地磚上,清理原來的地磚(相鄰Region複制),最後把這些有用的東西移到一個角落擺整齊(整體看作标記-整理),看約定的時間到了,退出了你的房間(GC停頓時間到達,退出GC程序,恢複使用者線程)。

7.6. 是否該選用 G1

如果目前使用的收集器滿足需要,就不必使用 G1;如果應用追求低停頓,可以嘗試 G1;如果你的應用追求吞吐量,那麼使用PS/PS Old 收集器組合想必會更适合,G1對吞吐量沒有什麼提升。

六、記憶體溢出是什麼?

記憶體溢出在Java中用

OutOfMemeryError

表示,當堆記憶體中無法為新生對象申請記憶體後,GC(垃圾回收)無效時,則報出記憶體溢出錯誤,簡稱 OOM 或 OOME。

1、OOM 出現的原因

  • 老年代記憶體不足:java.lang.OutOfMemoryError:Javaheapspace
  • 永久代記憶體不足:java.lang.OutOfMemoryError:PermGenspace (JDK 1.7及以前)
  • 元空間記憶體設定較小:java.lang.OutOfMemoryError: Metaspace
  • 代碼bug,占用記憶體無法及時回收

2、出現OOM簡單處理

  • 如服務已設定

    -XX:+HeapDumpOnOutMemoryError

    -Xloggc

    訓示出現OOM時進行生成堆Dump(堆快照檔案),取堆Dump檔案分析問題改正問題,重新部署新程式
  • 如未設定,設定堆溢出生成Dump參數,重新開機服務,伺機問題複現時收集并分析堆Dump
  • 設定堆記憶體最小值

    -Xms

    和最大值

    -Xmx

    ,最大值參考曆史使用率設定
  • 設定GC垃圾收集器為G1
  • 啟用GC日志

    -XX:+PrintGCDetails -XX:+PrintGCTimeStamps

    ,友善後期分析
  • 如果是元空間記憶體溢出,調大

    -XX:MaxMetaspaceSize

    的值,如

    -XX:MaxMetaspaceSize=100m

七、術語參考

  • 垃圾收集

    (簡稱GC,Garbage Collection):垃圾收集,清理JVM中無用的對象,釋放其占用的記憶體。
  • STW

    :垃圾收集過程中會出現Stop The World(STW)即停頓使用者線程執行清理,清理完成後恢複使用者線程繼續運作。随着垃圾收集器越來越優秀,使用者線程停頓的時間會越來越短,但仍未完全消除。
  • 安全點

    (Safepoint):代碼執行過程中特殊的位置,程式可暫停的點。發生GC時,需要使用者線程跑到最近的安全點上才可以開始清理。
  • GC時使用者線程未到達安全點解決方案:
    1. 搶先式中斷 :發現需要中斷時,線程未在安全點上,則讓線程跑到最近安全點再中斷。幾乎沒有虛拟機使用此方案。
    2. 主動式中斷:當GC需要暫停線程時,在安全點處設定中斷标記,使用者線程主動去軟體輪詢這個标志,如果有中斷标記即中斷線程執行。
  • 安全區域

    :指在一段代碼片段中,引用關系不會發生變化,在該區域的任何地方發生gc都是安全的。為了解決使用者線程準備GC時不在安全點的問題。
  • 并行收集

    :指的是多線程收集,但工作線程仍處于暫停狀态。
  • 并發收集

    :工作線程與垃圾收集線程同時執行。
  • 吞吐量

    (Throughput):即工作線程執行的時間占程式總運作時間的比值。即

    吞吐量=運作使用者代碼時間 / (運作使用者代碼時間 + 垃圾收集時間)

  • Full GC

    :老年代GC,又名 Major GC。
  • Minor GC

    :年輕代GC。

八、如何選擇GC收集器:

  • 如果堆比較小,隻有大約100m左右,使用

    -XX:+UseSerialGC

  • 如果應用運作在單核心CPU上,并且對停頓時間沒要求,使用

    XX:+UseSerialGC

  • 追求最巅峰的性能(高吞吐量),并且對停頓時間要求不多或能滿足要求時,使用

    -XX:+UseParallelGC

  • 響應時間比總吞吐量更重要,垃圾收集暫停必須保持最短時,使用

    -XX:+UseG1GC

  • 響應時間優先級高,且有巨大的堆,考慮更新jdk到11,并使用

    -XX:+UseZGC

    (OracleJDK)或

    -XX:+UseShenandoahGC

    (OpenJDK)
  • 以上判斷都建立在應用程式代碼對新版本JDK的相容性上,如果是舊版本項目依賴舊版本JDK,堆大概在6G及以下,使用CMS或許會更好,如果堆更大,可嘗試G1。

總結

了解JVM虛拟機垃圾回收機制與垃圾收集器的實作适用場景,有助于解決線上出現Java程式記憶體溢出的問題。

本文系參考了《深入了解Java虛拟機 第2版》第3章内容整理的學習筆記,侵權删。

最後,由于本人水準有限,行文中難免出現錯誤,希望看官可以評論指出,感激不盡!對于各版本JDK的JVM調優參數會略有不同,請以使用版本文檔為準!

參考

  • 《深入了解Java虛拟機 第2版》周志明著
  • https://blogs.oracle.com/jonthecollector/our-collectors
  • https://blog.51cto.com/lizhenliang/2164876
  • https://docs.oracle.com/en/java/javase/14/gctuning/available-collectors.html#GUID-9E4A6B11-BB94-424F-90EF-401287A1C333

本文同步于本人CSDN