老大難的GC原理及調優,這全說清楚了

【51CTO.com原創稿件】本文介紹 GC 基礎原理和理論,GC 調優方法思路和方法,基於 Hotspot jdk1.8,學習之後你將瞭解如何對生產系統出現的 GC 問題進行排查解決。


老大難的GC原理及調優,這全說清楚了


圖片來自 Pexels

內容主要如下:

  • GC 基礎原理,涉及調優目標,GC 事件分類、JVM 內存分配策略、GC 日誌分析等。
  • CMS 原理及調優。
  • G1 原理及調優。
  • GC 問題排查和解決思路。

GC 基礎原理

GC 調優目標

大多數情況下對 Java 程序進行 GC 調優,主要關注兩個目標:

  • 響應速度(Responsiveness):響應速度指程序或系統對一個請求的響應有多迅速。

比如,用戶訂單查詢響應時間,對響應速度要求很高的系統,較大的停頓時間是不可接受的。調優的重點是在短的時間內快速響應。

  • 吞吐量(Throughput):吞吐量關注在一個特定時間段內應用系統的最大工作量。

例如每小時批處理系統能完成的任務數量,在吞吐量方面優化的系統,較長的 GC 停頓時間也是可以接受的,因為高吞吐量應用更關心的是如何儘可能快地完成整個任務,不考慮快速響應用戶請求。

GC 調優中,GC 導致的應用暫停時間影響系統響應速度,GC 處理線程的 CPU 使用率影響系統吞吐量。

GC 分代收集算法

現代的垃圾收集器基本都是採用分代收集算法,其主要思想: 將 Java 的堆內存邏輯上分成兩塊:新生代、老年代,針對不同存活週期、不同大小的對象採取不同的垃圾回收策略。


老大難的GC原理及調優,這全說清楚了


新生代(Young Generation)

新生代又叫年輕代,大多數對象在新生代中被創建,很多對象的生命週期很短。

每次新生代的垃圾回收(又稱 Young GC、Minor GC、YGC)後只有少量對象存活,所以使用複製算法,只需少量的複製操作成本就可以完成回收。

新生代內又分三個區:一個 Eden 區,兩個 Survivor 區(S0、S1,又稱From Survivor、To Survivor),大部分對象在 Eden 區中生成。

當 Eden 區滿時,還存活的對象將被複制到兩個 Survivor 區(中的一個);當這個 Survivor 區滿時,此區的存活且不滿足晉升到老年代條件的對象將被複制到另外一個 Survivor 區。

對象每經歷一次複製,年齡加 1,達到晉升年齡閾值後,轉移到老年代。

老年代(Old Generation)

在新生代中經歷了 N 次垃圾回收後仍然存活的對象,就會被放到老年代,該區域中對象存活率高。老年代的垃圾回收通常使用“標記-整理”算法。

GC 事件分類

根據垃圾收集回收的區域不同,垃圾收集主要分為:

  • Young GC
  • Old GC
  • Full GC
  • Mixed GC

①Young GC

新生代內存的垃圾收集事件稱為 Young GC(又稱 Minor GC),當 JVM 無法為新對象分配在新生代內存空間時總會觸發 Young GC。

比如 Eden 區佔滿時,新對象分配頻率越高,Young GC 的頻率就越高。

Young GC 每次都會引起全線停頓(Stop-The-World),暫停所有的應用線程,停頓時間相對老年代 GC 造成的停頓,幾乎可以忽略不計。

②Old GC 、Full GC、Mixed GC

Old GC:只清理老年代空間的 GC 事件,只有 CMS 的併發收集是這個模式。

Full GC:清理整個堆的 GC 事件,包括新生代、老年代、元空間等 。

Mixed GC:清理整個新生代以及部分老年代的 GC,只有 G1 有這個模式。

GC 日誌分析

GC 日誌是一個很重要的工具,它準確記錄了每一次的 GC 的執行時間和執行結果,通過分析 GC 日誌可以調優堆設置和 GC 設置,或者改進應用程序的對象分配模式。

開啟的 JVM 啟動參數如下:

-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps 

常見的 Young GC、Full GC 日誌含義如下:


老大難的GC原理及調優,這全說清楚了


老大難的GC原理及調優,這全說清楚了


免費的 GC 日誌圖形分析工具推薦下面 2 個:

  • GCViewer,下載 jar 包直接運行 。
  • gceasy,Web 工具,上傳 GC 日誌在線使用。

內存分配策略

Java 提供的自動內存管理,可以歸結為解決了對象的內存分配和回收的問題。

前面已經介紹了內存回收,下面介紹幾條最普遍的內存分配策略:

①對象優先在 Eden 區分配:大多數情況下,對象在先新生代 Eden 區中分配。當 Eden 區沒有足夠空間進行分配時,虛擬機將發起一次 Young GC。

②大對象之間進入老年代:JVM 提供了一個對象大小閾值參數(-XX:PretenureSizeThreshold,默認值為 0,代表不管多大都是先在 Eden 中分配內存)。

大於參數設置的閾值值的對象直接在老年代分配,這樣可以避免對象在 Eden 及兩個 Survivor 直接發生大內存複製。

③長期存活的對象將進入老年代:對象每經歷一次垃圾回收,且沒被回收掉,它的年齡就增加 1,大於年齡閾值參數(-XX:MaxTenuringThreshold,默認 15)的對象,將晉升到老年代中。

④空間分配擔保:當進行 Young GC 之前,JVM 需要預估:老年代是否能夠容納 Young GC 後新生代晉升到老年代的存活對象,以確定是否需要提前觸發 GC 回收老年代空間,基於空間分配擔保策略來計算。

continueSize,老年代最大可用連續空間:


老大難的GC原理及調優,這全說清楚了


Young GC 之後如果成功(Young GC 後晉升對象能放入老年代),則代表擔保成功,不用再進行 Full GC,提高性能。

如果失敗,則會出現“promotion failed”錯誤,代表擔保失敗,需要進行 Full GC。

⑤動態年齡判定:新生代對象的年齡可能沒達到閾值(MaxTenuringThreshold 參數指定)就晉升老年代。

如果 Young GC 之後,新生代存活對象達到相同年齡所有對象大小的總和大於任意 Survivor 空間(S0+S1空間)的一半,此時 S0 或者 S1 區即將容納不了存活的新生代對象。

年齡大於或等於該年齡的對象就可以直接進入老年代,無須等到 MaxTenuringThreshold 中要求的年齡。

另外,如果 Young GC 後 S0 或 S1 區不足以容納:未達到晉升老年代條件的新生代存活對象,會導致這些存活對象直接進入老年代,需要儘量避免。

CMS 原理及調優

名詞解釋

可達性分析算法:用於判斷對象是否存活,基本思想是通過一系列稱為“GC Root”的對象作為起點(常見的 GC Root 有系統類加載器、棧中的對象、處於激活狀態的線程等),基於對象引用關係,從 GC Roots 開始向下搜索,所走過的路徑稱為引用鏈,當一個對象到 GC Root 沒有任何引用鏈相連,證明對象不再存活。

Stop The World:GC 過程中分析對象引用關係,為了保證分析結果的準確性,需要通過停頓所有 Java 執行線程,保證引用關係不再動態變化,該停頓事件稱為 Stop The World(STW)。

Safepoint:代碼執行過程中的一些特殊位置,當線程執行到這些位置的時候,說明虛擬機當前的狀態是安全的,如果有需要 GC,線程可以在這個位置暫停。

HotSpot 採用主動中斷的方式,讓執行線程在運行期輪詢是否需要暫停的標誌,若需要則中斷掛起。

CMS 簡介

CMS(Concurrent Mark and Sweep 併發-標記-清除),是一款基於併發、使用標記清除算法的垃圾回收算法,只針對老年代進行垃圾回收。

CMS 收集器工作時,儘可能讓 GC 線程和用戶線程併發執行,以達到降低 STW 時間的目的。

通過以下命令行參數,啟用 CMS 垃圾收集器:

-XX:+UseConcMarkSweepGC 

值得補充的是,下面介紹到的 CMS GC 是指老年代的 GC,而 Full GC 指的是整個堆的 GC 事件,包括新生代、老年代、元空間等,兩者有所區分。

新生代垃圾回收

能與 CMS 搭配使用的新生代垃圾收集器有 Serial 收集器和 ParNew 收集器。

這 2 個收集器都採用標記複製算法,都會觸發 STW 事件,停止所有的應用線程。不同之處在於,Serial 是單線程執行,ParNew 是多線程執行。


老大難的GC原理及調優,這全說清楚了


老年代垃圾回收


老大難的GC原理及調優,這全說清楚了


CMS GC 以獲取最小停頓時間為目的,儘可能減少 STW 時間,可以分為 7 個階段:

階段 1:初始標記(Initial Mark)


老大難的GC原理及調優,這全說清楚了


此階段的目標是標記老年代中所有存活的對象, 包括 GC Root 的直接引用, 以及由新生代中存活對象所引用的對象,觸發第一次 STW 事件。

這個過程是支持多線程的(JDK7 之前單線程,JDK8 之後並行,可通過參數 CMSParallelInitialMarkEnabled 調整)。

階段 2:併發標記(Concurrent Mark)


老大難的GC原理及調優,這全說清楚了


此階段 GC 線程和應用線程併發執行,遍歷階段 1 初始標記出來的存活對象,然後繼續遞歸標記這些對象可達的對象。

階段 3:併發預清理(Concurrent Preclean)


老大難的GC原理及調優,這全說清楚了


此階段 GC 線程和應用線程也是併發執行,因為階段 2 是與應用線程併發執行,可能有些引用關係已經發生改變。

通過卡片標記(Card Marking),提前把老年代空間邏輯劃分為相等大小的區域(Card)。

如果引用關係發生改變,JVM 會將發生改變的區域標記為“髒區”(Dirty Card),然後在本階段,這些髒區會被找出來,刷新引用關係,清除“髒區”標記。

階段 4:併發可取消的預清理(Concurrent Abortable Preclean)

此階段也不停止應用線程。本階段嘗試在 STW 的最終標記階段(Final Remark)之前儘可能地多做一些工作,以減少應用暫停時間。

在該階段不斷循環處理:標記老年代的可達對象、掃描處理 Dirty Card 區域中的對象,循環的終止條件有:

  • 達到循環次數
  • 達到循環執行時間閾值
  • 新生代內存使用率達到閾值

階段 5:最終標記(Final Remark)

這是 GC 事件中第二次(也是最後一次)STW 階段,目標是完成老年代中所有存活對象的標記。

在此階段執行:

  • 遍歷新生代對象,重新標記
  • 根據 GC Roots,重新標記
  • 遍歷老年代的 Dirty Card,重新標記

階段 6:併發清除(Concurrent Sweep)


老大難的GC原理及調優,這全說清楚了


此階段與應用程序併發執行,不需要 STW 停頓,根據標記結果清除垃圾對象。

階段 7:併發重置(Concurrent Reset)

此階段與應用程序併發執行,重置 CMS 算法相關的內部數據, 為下一次 GC 循環做準備。

CMS 常見問題

①最終標記階段停頓時間過長問題

CMS 的 GC 停頓時間約 80% 都在最終標記階段(Final Remark),若該階段停頓時間過長,常見原因是新生代對老年代的無效引用,在上一階段的併發可取消預清理階段中,執行閾值時間內未完成循環,來不及觸發 Young GC,清理這些無效引用。

通過添加參數:-XX:+CMSScavengeBeforeRemark。

在執行最終操作之前先觸發 Young GC,從而減少新生代對老年代的無效引用,降低最終標記階段的停頓。

但如果在上個階段(併發可取消的預清理)已觸發 Young GC,也會重複觸發 Young GC。

②併發模式失敗(concurrent mode failure)&晉升失敗(promotion failed)問題。


老大難的GC原理及調優,這全說清楚了


併發模式失敗:當 CMS 在執行回收時,新生代發生垃圾回收,同時老年代又沒有足夠的空間容納晉升的對象時,CMS 垃圾回收就會退化成單線程的 Full GC。所有的應用線程都會被暫停,老年代中所有的無效對象都被回收。


老大難的GC原理及調優,這全說清楚了


晉升失敗:當新生代發生垃圾回收,老年代有足夠的空間可以容納晉升的對象,但是由於空閒空間的碎片化,導致晉升失敗,此時會觸發單線程且帶壓縮動作的 Full GC。

併發模式失敗和晉升失敗都會導致長時間的停頓,常見解決思路如下:

  • 降低觸發 CMS GC 的閾值。
  • 即參數 -XX:CMSInitiatingOccupancyFraction 的值,讓 CMS GC 儘早執行,以保證有足夠的空間。
  • 增加 CMS 線程數,即參數 -XX:ConcGCThreads。
  • 增大老年代空間。
  • 讓對象儘量在新生代回收,避免進入老年代。

③內存碎片問題

通常 CMS 的 GC 過程基於標記清除算法,不帶壓縮動作,導致越來越多的內存碎片需要壓縮。

常見以下場景會觸發內存碎片壓縮:

  • 新生代 Young GC 出現新生代晉升擔保失敗(promotion failed))
  • 程序主動執行System.gc()

可通過參數 CMSFullGCsBeforeCompaction 的值,設置多少次 Full GC 觸發一次壓縮。

默認值為 0,代表每次進入 Full GC 都會觸發壓縮,帶壓縮動作的算法為上面提到的單線程 Serial Old 算法,暫停時間(STW)時間非常長,需要儘可能減少壓縮時間。

G1 原理及調優

G1 簡介

G1(Garbage-First)是一款面向服務器的垃圾收集器,支持新生代和老年代空間的垃圾收集,主要針對配備多核處理器及大容量內存的機器。

G1 最主要的設計目標是:實現可預期及可配置的 STW 停頓時間。

G1 堆空間劃分


老大難的GC原理及調優,這全說清楚了


①Region

為實現大內存空間的低停頓時間的回收,將劃分為多個大小相等的 Region。每個小堆區都可能是 Eden 區,Survivor 區或者 Old 區,但是在同一時刻只能屬於某個代。

在邏輯上, 所有的 Eden 區和 Survivor 區合起來就是新生代,所有的 Old 區合起來就是老年代,且新生代和老年代各自的內存 Region 區域由 G1 自動控制,不斷變動。

②巨型對象

當對象大小超過 Region 的一半,則認為是巨型對象(Humongous Object),直接被分配到老年代的巨型對象區(Humongous Regions)。

這些巨型區域是一個連續的區域集,每一個 Region 中最多有一個巨型對象,巨型對象可以佔多個 Region。

G1 把堆內存劃分成一個個 Region 的意義在於:

  • 每次 GC 不必都去處理整個堆空間,而是每次只處理一部分 Region,實現大容量內存的 GC。
  • 通過計算每個 Region 的回收價值,包括回收所需時間、可回收空間,在有限時間內儘可能回收更多的垃圾對象,把垃圾回收造成的停頓時間控制在預期配置的時間範圍內,這也是 G1 名稱的由來:Garbage-First。

G1工作模式

針對新生代和老年代,G1 提供 2 種 GC 模式,Young GC 和 Mixed GC,兩種會導致 Stop The World。

Young GC:當新生代的空間不足時,G1 觸發 Young GC 回收新生代空間。

Young GC 主要是對 Eden 區進行 GC,它在 Eden 空間耗盡時觸發,基於分代回收思想和複製算法,每次 Young GC 都會選定所有新生代的 Region。

同時計算下次 Young GC 所需的 Eden 區和 Survivor 區的空間,動態調整新生代所佔 Region 個數來控制 Young GC 開銷。

Mixed GC:當老年代空間達到閾值會觸發 Mixed GC,選定所有新生代裡的 Region,根據全局併發標記階段(下面介紹到)統計得出收集收益高的若干老年代 Region。

在用戶指定的開銷目標範圍內,儘可能選擇收益高的老年代 Region 進行 GC,通過選擇哪些老年代 Region 和選擇多少 Region 來控制 Mixed GC 開銷。

全局併發標記


老大難的GC原理及調優,這全說清楚了


全局併發標記主要是為 Mixed GC 計算找出回收收益較高的 Region 區域,具體分為 5 個階段:

階段 1:初始標記(Initial Mark)

暫停所有應用線程(STW),併發地進行標記從 GC Root 開始直接可達的對象(原生棧對象、全局對象、JNI 對象)。

當達到觸發條件時,G1 並不會立即發起併發標記週期,而是等待下一次新生代收集,利用新生代收集的 STW 時間段,完成初始標記,這種方式稱為借道(Piggybacking)。

階段 2:根區域掃描(Root Region Scan)

在初始標記暫停結束後,新生代收集也完成的對象複製到 Survivor 的工作,應用線程開始活躍起來。

此時為了保證標記算法的正確性,所有新複製到 Survivor 分區的對象,需要找出哪些對象存在對老年代對象的引用,把這些對象標記成根(Root)。

這個過程稱為根分區掃描(Root Region Scanning),同時掃描的 Suvivor 分區也被稱為根分區(Root Region)。

根分區掃描必須在下一次新生代垃圾收集啟動前完成(接下來併發標記的過程中,可能會被若干次新生代垃圾收集打斷),因為每次 GC 會產生新的存活對象集合。

階段 3:併發標記(Concurrent Marking)

標記線程與應用程序線程並行執行,標記各個堆中 Region 的存活對象信息,這個步驟可能被新的 Young GC 打斷。

所有的標記任務必須在堆滿前就完成掃描,如果併發標記耗時很長,那麼有可能在併發標記過程中,又經歷了幾次新生代收集。

階段 4:再次標記(Remark)

和 CMS 類似暫停所有應用線程(STW),以完成標記過程短暫地停止應用線程, 標記在併發標記階段發生變化的對象,和所有未被標記的存活對象,同時完成存活數據計算。

階段 5:清理(Cleanup)

為即將到來的轉移階段做準備, 此階段也為下一次標記執行所有必需的整理計算工作:

  • 整理更新每個 Region 各自的 RSet(Remember Set,HashMap 結構,記錄有哪些老年代對象指向本 Region,key 為指向本 Region 的對象的引用,value 為指向本 Region 的具體 Card 區域,通過 RSet 可以確定 Region 中對象存活信息,避免全堆掃描)。
  • 回收不包含存活對象的 Region。
  • 統計計算回收收益高(基於釋放空間和暫停目標)的老年代分區集合。

G1調優注意點

①Full GC 問題

G1 的正常處理流程中沒有 Full GC,只有在垃圾回收處理不過來(或者主動觸發)時才會出現,G1 的 Full GC 就是單線程執行的 Serial old gc,會導致非常長的 STW,是調優的重點,需要儘量避免 Full GC。

常見原因如下:

  • 程序主動執行 System.gc()
  • 全局併發標記期間老年代空間被填滿(併發模式失敗)
  • Mixed GC 期間老年代空間被填滿(晉升失敗)
  • Young GC 時 Survivor 空間和老年代沒有足夠空間容納存活對象

類似 CMS,常見的解決是:

  • 增大 -XX:ConcGCThreads=n 選項增加併發標記線程的數量,或者 STW 期間並行線程的數量:-XX:ParallelGCThreads=n。
  • 減小 -XX:InitiatingHeapOccupancyPercent 提前啟動標記週期。
  • 增大預留內存 -XX:G1ReservePercent=n,默認值是 10,代表使用 10% 的堆內存為預留內存,當 Survivor 區域沒有足夠空間容納新晉升對象時會嘗試使用預留內存。

②巨型對象分配

巨型對象區中的每個 Region 中包含一個巨型對象,剩餘空間不再利用,導致空間碎片化,當 G1 沒有合適空間分配巨型對象時,G1 會啟動串行 Full GC 來釋放空間。

可以通過增加 -XX:G1HeapRegionSize 來增大 Region 大小,這樣一來,相當一部分的巨型對象就不再是巨型對象了,而是採用普通的分配方式。

③不要設置 Young 區的大小

原因是為了儘量滿足目標停頓時間,邏輯上的 Young 區會進行動態調整。如果設置了大小,則會覆蓋掉並且會禁用掉對停頓時間的控制。

④平均響應時間設置

使用應用的平均響應時間作為參考來設置 MaxGCPauseMillis,JVM 會盡量去滿足該條件,可能是 90% 的請求或者更多的響應時間在這之內, 但是並不代表是所有的請求都能滿足,平均響應時間設置過小會導致頻繁 GC。

調優方法與思路

如何分析系統 JVM GC 運行狀況及合理優化?

GC 優化的核心思路在於:儘可能讓對象在新生代中分配和回收,儘量避免過多對象進入老年代,導致對老年代頻繁進行垃圾回收,同時給系統足夠的內存減少新生代垃圾回收次數,進行系統分析和優化也是圍繞著這個思路展開。

分析系統的運行狀況

分析系統的運行狀況:

  • 系統每秒請求數、每個請求創建多少對象,佔用多少內存。
  • Young GC 觸發頻率、對象進入老年代的速率。
  • 老年代佔用內存、Full GC 觸發頻率、Full GC 觸發的原因、長時間 Full GC 的原因。

常用工具如下:

jstat:JVM 自帶命令行工具,可用於統計內存分配速率、GC 次數,GC 耗時。

常用命令格式:

jstat -gc    

輸出返回值代表含義如下:


老大難的GC原理及調優,這全說清楚了


例如:jstat -gc 32683 1000 10,統計 pid=32683 的進程,每秒統計 1 次,統計 10 次。

jmap:JVM 自帶命令行工具,可用於瞭解系統運行時的對象分佈。

常用命令格式如下:

// 命令行輸出類名、類數量數量,類佔用內存大小, 
// 按照類佔用內存大小降序排列
jmap -histo

// 生成堆內存轉儲快照,在當前目錄下導出dump.hrpof的二進制文件,
// 可以用eclipse的MAT圖形化工具分析
jmap -dump:live,format=b,file=dump.hprof

jinfo,命令格式:

jinfo  

用來查看正在運行的 Java 應用程序的擴展參數,包括 Java System 屬性和 JVM 命令行參數。

其他 GC 工具:

  • 監控告警系統:Zabbix、Prometheus、Open-Falcon
  • jdk 自動實時內存監控工具:VisualVM
  • 堆外內存監控:Java VisualVM 安裝 Buffer Pools 插件、google perf工具、Java NMT(Native Memory Tracking)工具
  • GC 日誌分析:GCViewer、gceasy
  • GC 參數檢查和優化:http://xxfox.perfma.com/

GC 優化案例

①數據分析平臺系統頻繁 Full GC

平臺主要對用戶在 App 中行為進行定時分析統計,並支持報表導出,使用 CMS GC 算法。

數據分析師在使用中發現系統頁面打開經常卡頓,通過 jstat 命令發現系統每次 Young GC 後大約有 10% 的存活對象進入老年代。

原來是因為 Survivor 區空間設置過小,每次 Young GC 後存活對象在 Survivor 區域放不下,提前進入老年代。

通過調大 Survivor 區,使得 Survivor 區可以容納 Young GC 後存活對象,對象在 Survivor 區經歷多次 Young GC 達到年齡閾值才進入老年代。

調整之後每次 Young GC 後進入老年代的存活對象穩定運行時僅幾百 Kb,Full GC 頻率大大降低。

②業務對接網關 OOM

網關主要消費 Kafka 數據,進行數據處理計算然後轉發到另外的 Kafka 隊列,系統運行幾個小時候出現 OOM,重啟系統幾個小時之後又 OOM。

通過 jmap 導出堆內存,在 eclipse MAT 工具分析才找出原因:代碼中將某個業務 Kafka 的 topic 數據進行日誌異步打印,該業務數據量較大,大量對象堆積在內存中等待被打印,導致 OOM。

③賬號權限管理系統頻繁長時間 Full GC

系統對外提供各種賬號鑑權服務,使用時發現系統經常服務不可用,通過 Zabbix 的監控平臺監控發現系統頻繁發生長時間 Full GC,且觸發時老年代的堆內存通常並沒有佔滿,發現原來是業務代碼中調用了 System.gc()。

總結

GC 問題可以說沒有捷徑,排查線上的性能問題本身就並不簡單,除了將本文介紹到的原理和工具融會貫通,還需要我們不斷去積累經驗,真正做到性能最優。

篇幅所限,不再展開介紹常見 GC 參數的使用,我發佈在 GitHub:

https://github.com/caison/caison-blog-demo 

參考:

  • 《Java Performance: The Definitive Guide》 Scott Oaks
  • 《深入理解 Java 虛擬機:JVM 高級特性與最佳實踐(第二版》 周志華
  • Java 性能調優實戰
  • Getting Started with the G1 Garbage Collector
  • GC 參考手冊-Java 版
  • 請教 G1 算法的原理——RednaxelaFX 的回答
  • Java Hotspot G1 GC 的一些關鍵技術——美團技術團隊


分享到:


相關文章: