JVM之調優及常見場景分析

肆玖爺發表於2021-03-17

JVM調優

微信圖片_20201127154300

GC調優是最後要做的工作,GC調優的目的可以總結為下面兩點:

  • 減少物件晉升到老年代的數量
  • 減少FullGC的執行時間

通過監控排查問題及驗證優化結果,可以分為:

如果GC執行時間滿足下列所有條件,就沒有必要進行GC優化了:

  • Minor GC執行非常迅速(50ms以內)
  • Minor GC沒有頻繁執行(大約10s執行一次)
  • Full GC執行非常迅速(1s以內)
  • Full GC沒有頻繁執行(大約10min執行一次)

案例參考:

常見場景分析

動態擴容引起的空間震盪

現象

服務剛剛啟動時 GC 次數較多,最大空間剩餘很多但是依然發生 GC,這種情況我們可以通過觀察 GC 日誌或者通過監控工具來觀察堆的空間變化情況即可。GC Cause 一般為 Allocation Failure,且在 GC 日誌中會觀察到經歷一次 GC ,堆內各個空間的大小會被調整,如下圖所示:

圖片

原因分析

在 JVM 的引數中 -Xms-Xmx 設定的不一致,在初始化時只會初始 -Xms 大小的空間儲存資訊,每當空間不夠用時再向作業系統申請,這樣的話必然要進行一次 GC。另外,如果空間剩餘很多時也會進行縮容操作,JVM 通過 -XX:MinHeapFreeRatio-XX:MaxHeapFreeRatio 來控制擴容和縮容的比例,調節這兩個值也可以控制伸縮的時機。

解決方案

儘量將成對出現的空間大小配置引數設定成固定的,如 -Xms-Xmx-XX:MaxNewSize-XX:NewSize-XX:MetaSpaceSize-XX:MaxMetaSpaceSize 等。不過在不追求停頓時間的情況下震盪的空間也是有利的,可以動態地伸縮以節省空間,例如作為富客戶端的 Java 應用。

顯式GC的去和留

現象

手動呼叫 System.gc 方法會引發一次 STW 的 Full GC,對整個堆做收集,可以在 GC 日誌中的 GC Cause 中確認。同時JVM提供-XX:+DisableExplicitGC 引數可以避免這種 GC。那麼有沒有必要啟用該引數呢?

去留分析

首先需要了解下DirectByteBuffer,它有著零拷貝等特點,被 Netty 等各種 NIO 框架使用,會使用到堆外記憶體。它的 Native Memory 的清理工作是通過 sun.misc.Cleaner 自動完成的,是一種基於虛引用PhantomReference的清理工具,比普通的 Finalizer 輕量些。而為 DirectByteBuffer 分配空間過程中會顯式呼叫 System.gc ,希望通過 Full GC 來強迫已經無用的 DirectByteBuffer 物件釋放掉它們關聯的 Native Memory。

如果通過-XX:+DisableExplicitGC關閉顯式GC,DirectByteBuffer分配空間中System.gc將失效,這時如果很長一段時間沒有做過GC或者只做了Young GC,則不會觸發Cleaner 的工作,Native Memory得不到及時釋放,有可能發生記憶體洩漏。

所以一般建議保留顯式GC,但需要規範使用,避免頻繁GC帶來的效能開銷。可通過-XX:+ExplicitGCInvokesConcurrent-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses 引數來將 System.gc 的觸發型別從 Foreground 改為 Background,同時 Background 也會做 Reference Processing,這樣的話就能大幅降低了 STW 開銷,同時也不會發生 NIO Direct Memory OOM。

MetaSpace 區 OOM

現象

JVM 在啟動後或者某個時間點開始,MetaSpace 的已使用大小在持續增長,同時每次 GC 也無法釋放,調大 MetaSpace 空間也無法徹底解決

原因分析

Java 7 之前字串常量池被放到了 Perm 區,所有被 intern 的 String 都會被存在這裡,由於 String.intern 是不受控的,所以 -XX:MaxPermSize 的值也不太好設定,經常會出現 java.lang.OutOfMemoryError: PermGen space 異常。但在 Java 7 之後常量池等字面量(Literal)、類靜態變數(Class Static)、符號引用(Symbols Reference)等幾項被移到 Heap 中,PermGen 也被移除,取而代之的是 MetaSpace。在最底層,JVM 通過 mmap 介面向作業系統申請記憶體對映,每次申請 2MB 空間,這裡是虛擬記憶體對映,不是真的就消耗了主存的 2MB,只有之後在使用的時候才會真的消耗記憶體。申請的這些記憶體放到一個連結串列中 VirtualSpaceList,作為其中的一個 Node。

關鍵原因就是 ClassLoader 不停地在記憶體中 load 了新的 Class ,一般這種問題都發生在動態類載入等情況上。

解決方案

dump 快照之後通過 JProfiler 或 MAT 觀察 Classes 的 Histogram(直方圖)即可,或者直接通過命令即可定位, jcmd 打幾次 Histogram 的圖,看一下具體是哪個包下的 Class 增加較多就可以定位了。

jcmd <PID> GC.class_stats|awk '{print$13}'|sed  's/\(.*\)\.\(.*\)/\1/g'|sort |uniq -c|sort -nrk1

經常會出問題的幾個點有 Orika 的 classMap、JSON 的 ASMSerializer、Groovy 動態載入類等,基本都集中在反射、Javasisit 位元組碼增強、CGLIB 動態代理、OSGi 自定義類載入器等的技術點上。

過早晉升

現象

  • 分配速率接近於晉升速率,物件晉升年齡較小
  • Full GC 比較頻繁,且經歷過一次 GC 之後 Old 區的變化比例非常大

原因分析及策略

  • Young/Eden 區過小:一般情況下 Old 的大小應當為活躍物件的 2~3 倍左右,考慮到浮動垃圾問題最好在 3 倍左右,剩下的都可以分給 Young 區
  • 分配速率過大
    • 偶發較大:通過記憶體分析工具找到問題程式碼,從業務邏輯上做一些優化
    • 一直較大:當前的 Collector 已經不滿足應用程式的期望了,這種情況要麼增加應用程式的 機器,要麼調整 GC 收集器型別或加大空間

CMS Old GC頻繁

現象

Old 區頻繁的做 CMS GC,但是每次耗時不是特別長,整體最大 STW 也在可接受範圍內,但由於 GC 太頻繁導致吞吐下降比較多。

原因分析

基本都是一次 Young GC 完成後,負責處理 CMS GC 的一個後臺執行緒 concurrentMarkSweepThread 會不斷地輪詢,使用 shouldConcurrentCollect() 方法做一次檢測,判斷是否達到了回收條件。如果達到條件(參考上文中CMS GC觸發條件),使用 collect_in_background() 啟動一次 Background 模式 GC。輪詢的判斷是使用 sleepBeforeNextCycle() 方法,間隔週期為 -XX:CMSWaitDuration 決定,預設為2s。

解決方案

圖片

  • Dump Diff:分別在 CMS GC 的發生前後分別 dump 一次,進行dump檔案差異分析
  • Leak Suspects:記憶體洩露報告
  • Top Component分析:按照物件、類、類載入器、包等多個維度觀察 Histogram,同時使用 outgoing 和 incoming 分析關聯的物件,另外就是 Soft Reference 和 Weak Reference、Finalizer 等也要看一下
  • Unreachable分析:不可達物件分析

單次 CMS Old GC 耗時長

現象

CMS GC 單次 STW 最大超過 1000ms,不會頻繁發生。但這種場景非常危險,某些場景下會引起“雪崩效應”,我們應該儘量避免出現。

原因分析

可能造成STW的情況如下:

  • Init Mark

    圖片

    整個過程比較簡單,從 GC Root 出發標記 Old 中的物件,處理完成後藉助 BitMap 處理下 Young 區對 Old 區的引用,整個過程基本都比較快,很少會有較大的停頓。

  • Final Mark

    Final Remark 的開始階段與 Init Mark 處理的流程相同,但是後續多了 Card Table 遍歷、Reference 例項的清理,並將其加入到 Reference 維護的 pend_list 中,如果要收集後設資料資訊,還要清理 SystemDictionary、CodeCache、SymbolTable、StringTable 等元件中不再使用的資源。

  • STW前等待應用執行緒到達安全點(較少發生)

由此可見,大部分問題都出在 Final Remark 過程,觀察詳細 GC 日誌,找到出問題時 Final Remark 日誌,分析下 Reference 處理和後設資料處理 real 耗時是否正常,詳細資訊需要通過 -XX:+PrintReferenceGC 引數開啟。基本在日誌裡面就能定位到大概是哪個方向出了問題,耗時超過 10% 的就需要關注

一般來說最容易出問題的地方就是 Reference 中的 FinalReference 和後設資料資訊處理中的 scrub symbol table 兩個階段,想要找到具體問題程式碼就需要記憶體分析工具 MAT 或 JProfiler 了,注意要 dump 即將開始 CMS GC 的堆。在用 MAT 等工具前也可以先用命令列看下物件 Histogram,有可能直接就能定位問題。

  • 對 FinalReference 的分析主要觀察 java.lang.ref.Finalizer 物件的 dominator tree,找到洩漏的來源。經常會出現問題的幾個點有 Socket 的 SocksSocketImpl 、Jersey 的 ClientRuntime、MySQL 的 ConnectionImpl 等等。
  • scrub symbol table 表示清理後設資料符號引用耗時,符號引用是 Java 程式碼被編譯成位元組碼時,方法在 JVM 中的表現形式,生命週期一般與 Class 一致,當 _should_unload_classes 被設定為 true 時在 CMSCollector::refProcessingWork() 中與 Class Unload、String Table 一起被處理。

解決方案

一般不會大面積同時爆發,不過有很多時候單臺 STW 的時間會比較長,如果業務影響比較大,及時摘掉流量,具體後續優化策略如下:

  • FinalReference:找到記憶體來源後通過優化程式碼的方式來解決,如果短時間無法定位可以增加 -XX:+ParallelRefProcEnabled 對 Reference 進行並行處理。
  • symbol table:觀察 MetaSpace 區的歷史使用峰值,以及每次 GC 前後的回收情況,一般沒有使用動態類載入或者 DSL 處理等,MetaSpace 的使用率上不會有什麼變化,這種情況可以通過 -XX:-CMSClassUnloadingEnabled 來避免 MetaSpace 的處理,JDK8 會預設開啟 CMSClassUnloadingEnabled,這會使得 CMS 在 CMS-Remark 階段嘗試進行類的解除安裝。

記憶體碎片&收集器退化

現象

併發的 CMS GC 演算法,退化為 Foreground 單執行緒序列 GC 模式,STW 時間超長,有時會長達十幾秒。其中 CMS 收集器退化後單執行緒序列 GC 演算法有兩種:

  • 帶壓縮動作的演算法,稱為 MSC,上面我們介紹過,使用標記-清理-壓縮,單執行緒全暫停的方式,對整個堆進行垃圾收集,也就是真正意義上的 Full GC,暫停時間要長於普通 CMS。
  • 不帶壓縮動作的演算法,收集 Old 區,和普通的 CMS 演算法比較相似,暫停時間相對 MSC 演算法短一些。

原型分析

  • 晉升失敗(Promotion Failed):old空間不足或者碎片導致晉升失敗,由於concurrentMarkSweepThread 和擔保機制的存在,發生的條件是很苛刻的
  • 增量收集擔保失敗:分配記憶體失敗後,會判斷統計得到的 Young GC 晉升到 Old 的平均大小,以及當前 Young 區已使用的大小也就是最大可能晉升的物件大小,是否大於 Old 區的剩餘空間。只要 CMS 的剩餘空間比前兩者的任意一者大,CMS 就認為晉升還是安全的,反之,則代表不安全,不進行Young GC,直接觸發Full GC。
  • 顯示GC
  • 併發模式失敗(Concurrent Mode Failure)

解決方案

分析到具體原因後,我們就可以針對性解決了,具體思路還是從根因出發,具體解決策略:

  • 記憶體碎片:通過配置 -XX:UseCMSCompactAtFullCollection=true 來控制 Full GC的過程中是否進行空間的整理(預設開啟,注意是Full GC,不是普通CMS GC),以及 -XX: CMSFullGCsBeforeCompaction=n 來控制多少次 Full GC 後進行一次壓縮(可以使用 -XX:PrintFLSStatistics 來觀察記憶體碎片率情況,然後再設定具體的值)
  • 增量收集:降低觸發 CMS GC 的閾值,即引數 -XX:CMSInitiatingOccupancyFraction 的值,讓 CMS GC 儘早執行,以保證有足夠的連續空間,也減少 Old 區空間的使用大小,另外需要使用 -XX:+UseCMSInitiatingOccupancyOnly 來配合使用,不然 JVM 僅在第一次使用設定值,後續則自動調整。
  • 浮動垃圾:視情況控制每次晉升物件的大小,或者縮短每次 CMS GC 的時間,必要時可調節 NewRatio 的值。另外就是使用 -XX:+CMSScavengeBeforeRemark 在過程中提前觸發一次 Young GC,防止後續晉升過多物件。

堆外記憶體OOM

現象

記憶體使用率不斷上升,甚至開始使用 SWAP 記憶體,同時可能出現 GC 時間飆升,執行緒被 Block 等現象,通過 top 命令發現 Java 程式的 RES 甚至超過了 -Xmx 的大小。出現這些現象時,基本可以確定是出現了堆外記憶體洩漏。

原因分析

JVM 的堆外記憶體洩漏,主要有兩種的原因:

  • 通過 UnSafe#allocateMemoryByteBuffer#allocateDirect 主動申請了堆外記憶體而沒有釋放,常見於 NIO、Netty 等相關元件。
  • 程式碼中有通過 JNI 呼叫 Native Code 申請的記憶體沒有釋放。

解決方案

首先可以使用 NMT(NativeMemoryTracking) + jcmd 分析洩漏的堆外記憶體是哪裡申請,確定原因後,使用不同的手段,進行原因定位。

圖片

相關文章