1. 程式人生 > >JVM之調優及常見場景分析

JVM之調優及常見場景分析

## JVM調優 ![微信圖片_20201127154300](https://sheungxin.github.io/notpic/%E5%BE%AE%E4%BF%A1%E5%9B%BE%E7%89%87_20201127154300.jpg) **GC調優是最後要做的工作**,GC調優的目的可以總結為下面兩點: - 減少物件晉升到老年代的數量 - 減少FullGC的執行時間 通過監控排查問題及驗證優化結果,可以分為: - 命令監控:jps、jinfo、jstack、jmap、jstat、jhat - 圖形化監控:[JConsole和VisualVM](https://mp.weixin.qq.com/s?__biz=MzI4NDY5Mjc1Mg==&mid=2247484023&idx=1&sn=39be119fdf3132240adc84a85bf8a054&chksm=ebf6da08dc81531e3719389555150f2d0237554b6b6c07a123efdea7c78c0ae2f064cc577bd4&scene=21#wechat_redirect) - 阿里巴巴開源的 Java 診斷工具:[Arthas(阿爾薩斯)](https://arthas.aliyun.com/doc/): 如果GC執行時間滿足下列所有條件,就沒有必要進行GC優化了: - Minor GC執行非常迅速(50ms以內) - Minor GC沒有頻繁執行(大約10s執行一次) - Full GC執行非常迅速(1s以內) - Full GC沒有頻繁執行(大約10min執行一次) 案例參考: - [CMS調優](https://segmentfault.com/a/1190000005174819) - [OOM問題調優](https://mp.weixin.qq.com/s?__biz=MzAwNTQ4MTQ4NQ==&mid=2453559994&idx=1&sn=4859ab4b755890515921e9d5bbeca597&scene=21#wechat_redirect) - [Java中9種常見的CMS GC問題分析與解決](https://mp.weixin.qq.com/s/RFwXYdzeRkTG5uaebVoLQw) # 常見場景分析 ## 動態擴容引起的空間震盪 **現象** 服務**剛剛啟動時 GC 次數較多**,最大空間剩餘很多但是依然發生 GC,這種情況我們可以通過觀察 GC 日誌或者通過監控工具來觀察堆的空間變化情況即可。GC Cause 一般為 Allocation Failure,且在 GC 日誌中會觀察到經歷一次 GC ,堆內各個空間的大小會被調整,如下圖所示: ![圖片](https://sheungxin.github.io/notpic/641.png) **原因分析** 在 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 增加較多就可以定位了。 ```java jcmd 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。 **解決方案** ![圖片](https://sheungxin.github.io/notpic/640.png) - 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 ![圖片](https://sheungxin.github.io/notpic/642.png) 整個過程比較簡單,從 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#allocateMemory`,`ByteBuffer#allocateDirect` 主動申請了堆外記憶體而沒有釋放,常見於 NIO、Netty 等相關元件。 - 程式碼中有通過 JNI 呼叫 Native Code 申請的記憶體沒有釋放。 **解決方案** 首先可以使用 NMT([NativeMemoryTracking](https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/tooldescr007.html)) + jcmd 分析洩漏的堆外記憶體是哪裡申請,確定原因後,使用不同的手段,進行原因定位。 ![圖片](https://sheungxin.github.io/notpic/