百萬級 java 後臺生產 OOM 調優實例

1. 前言

之前預約小程序後臺在當用戶訪問量增大時,tomcat 老是宕機, 在未發現原因時候需要重啓。遂分析原因,在公司內部做了 OOM 調優實例分享,這裏總結記錄一下~

主要是從內存模型,線程池,以及 dump 文件方面入手~

本文涉及到的概念性東西請看

jvm 垃圾收集算法以及垃圾收集器簡介

2.jvm 優化(ParNew+CMS)

硬件配置:生產上服務器 是 4 核 8g,四臺服務器做負載。、

優化思路 儘量讓每次 Young GC 後的存活對象小於 Survivor 區域的 50%,都留存在年輕代裏。儘量別讓對象進入老年代。儘量減少 Full GC 的頻率,避免頻繁 Full GC 對 JVM 性能的影響。

2.1 內存模型分析

全國每天都要至少 20w 輛集裝箱車進行運輸行程預約。平時白天,因司機都在休息,預約數不多,基本沒有預約。

夜間是大貨車通勤高峯,特別是凌晨。我們假設有 20w 輛的集裝箱車在 5min 內進行了預約,即每秒有將近 650 多次預約。

我們假設每秒有 650 次預約,也就是每秒有 650 個 運輸行程對象 ( 裏面包括了 掛車,企業,優惠等等模塊的 其他對象信息)空間生成。負載均衡至 4 臺服務器,每臺大概 160 次。

我們可以估算一下對象大小,比如 int 類型佔用 4 字節,double 類型佔用 8 字節。也可以用 jol-core 直接算,大概 1kb 左右。

因爲服務器是 4 核 8g,就可以給 JVM 進程分配四五個 G 的內存空間,那麼堆內存可以分到三四個 G 左右,於是可以給新生代至少分配 2G。

2.2 1.0 版本

‐Xms3072M ‐Xmx3072M ‐Xmn1536M ‐Xss1M ‐XX:PermSize=256M ‐XX:MaxPermSize=256M ‐XX:SurvivorRatio=8

(1)-Xms 爲 jvm 啓動時分配 堆 的內存,比如 - Xms3072M,表示分配 3g

(2)-Xmx 爲 jvm 運行過程中分配的 堆 的最大內存,比如 - Xms3072M,表示 jvm 進程最多隻能夠佔用 3g 內存

(3)-Xmn 年輕代 大小 1536M 代表 1.5g

(4)-Xss 爲 jvm 啓動的每個線程分配的內存大小,默認 JDK1.4 中是 256K,JDK1.5 + 中是 1M

(5)‐XX:PermSize 非堆區初始內存分配大小 (方法區 1.7,1.8 時候叫元空間 用 - XX:MetaspaceSize 替代)

(6)‐XX:MaxPermSize 最大的非堆區初始內存分配大小 (1.7,1.8 的 用‐XX:MaxMetaspaceSize 來替代)

(7)‐XX:SurvivorRatio 設置兩個 survivor 和 eden 的比 8 表示 survivor:eden=2:8

分析

系統按每秒生成 60MB 的速度來生成對象,大概運行 20 秒就會撐滿 eden 區,會觸發 minor gc, 大概會有 95% 以上對象成爲垃圾被回收,可能 最後一兩秒生成的對象還被引用着 (流程還沒執行完,對象還被引用着),暫估爲 100MB 左右,那麼這 100M 會被挪到 S0 區,根據 動態對象年齡判斷原則,這 100MB 對象同齡而且總和大於 S0 區的 50%,那麼這些對象都會被挪到老年代,到了老年代不到一秒又變成了垃圾對象,很明顯,survivor 區大小設置有點小,分析下微信小程序的業務知道,明顯大部分對象都是短生存週期的,根本不應該頻繁進入老年代,也沒必要給老年代維持過大的內存空間,得讓對象儘量留在新生代裏。遂修改爲 2.0 版本。

2.3 2.0 版本

‐Xms3072M ‐Xmx3072M ‐Xmn2048M ‐Xss1M ‐XX:PermSize=256M ‐XX:MaxPermSize=256M ‐XX:SurvivorRatio=8

(1)-Xms 爲 jvm 啓動時分配 堆 的內存,比如 - Xms3072M,表示分配 3g

(2)-Xmx 爲 jvm 運行過程中分配的 堆 的最大內存,比如 - Xms3072M,表示 jvm 進程最多隻能夠佔用 3g 內存

(3)-Xmn 年輕代 大小 1536M 代表 1.5g

(4)-Xss 爲 jvm 啓動的每個線程分配的內存大小,默認 JDK1.4 中是 256K,JDK1.5 + 中是 1M

(5)‐XX:PermSize 非堆區初始內存分配大小 (方法區 1.7,1.8 時候叫元空間 用 - XX:MetaspaceSize 替代)

(6)‐XX:MaxPermSize 最大的非堆區初始內存分配大小 (1.7,1.8 的 用‐XX:MaxMetaspaceSize 來替代)

(7)‐XX:SurvivorRatio 設置兩個 survivor 和 eden 的比 8 表示 survivor:eden=2:8

將 年輕代的 大小 設置爲 2G

分析:

這樣就降低了因爲 對象動態年齡判斷原則 導致的對象頻繁進入老年代的問題。

對於對象年齡應該爲多少才移動到老年代比較合適,微信小程序中一次 minor gc 要間隔二三十秒,大多數對象一般在幾秒內就會變爲垃圾,完全可以將默認的 15 歲改小一點,比如改爲 5,那麼意味着對象要經過次 5 次 minor gc 纔會進入老年代,整個時間也有一兩分鐘了,如果對象這麼長時間都沒被回收,完全可以認爲這些對象是會存活的比較長的對象,可以移動到老年代,而不是繼續一直佔用 survivor 區空間。

對於多大的對象直接進入老年代 (參數 - XX:PretenureSizeThreshold),這個一般可以結合你自己系統看下,有沒有什麼大對象生成 (因爲不是管理系統,沒有導出 excel 等操作),預估下大對象的大小,一般來說設置爲 1M 就差不多了,很少有超過 1M 的大對象,這些對象一般就是你係統初始化分配的緩存對象,比如大的緩存 List,Map 之類的對象。

對於 JDK8 默認的垃圾回收器是 - XX:+UseParallelGC(年輕代) 和 - XX:+UseParallelOldGC(老年代),如果內存較大 (超過 4 個 G,比如我這裏 4 核 8G),系統對停頓時間比較敏感,我們可以使用 ParNew+CMS(-XX:+UseParNewGC -XX:+UseConcMarkSweepGC)

遂修改爲 3.0 版本。

2.4 3.0 版本

‐Xms3072M ‐Xmx3072M ‐Xmn2048M ‐Xss1M ‐XX:PermSize=256M ‐XX:MaxPermSize=256M ‐XX:SurvivorRatio=8

‐XX:MaxTenuringThreshold=5 ‐XX:PretenureSizeThreshold=1M ‐XX:+UseParNewGC ‐XX:+UseConcMarkSweepGC

將 對象晉升到老年代年齡設置爲 5,大對象設置爲 1m,啓用 parNew 收集年輕代 (複製算法),CMS 收集老年代 (標記 - 清除 算法)

(1)-Xms 爲 jvm 啓動時分配 堆 的內存,比如 - Xms3072M,表示分配 3g

(2)-Xmx 爲 jvm 運行過程中分配的 堆 的最大內存,比如 - Xms3072M,表示 jvm 進程最多隻能夠佔用 3g 內存

(3)-Xmn 年輕代 大小 1536M 代表 1.5g

(4)-Xss 爲 jvm 啓動的每個線程分配的內存大小,默認 JDK1.4 中是 256K,JDK1.5 + 中是 1M

(5)‐XX:PermSize 非堆區初始內存分配大小 (方法區 1.7,1.8 時候叫元空間 用 - XX:MetaspaceSize 替代)

(6)‐XX:MaxPermSize 最大的非堆區初始內存分配大小 (1.7,1.8 的 用‐XX:MaxMetaspaceSize 來替代)

(7)‐XX:SurvivorRatio 設置兩個 survivor 和 eden 的比 8 表示 survivor:eden=2:8

(8)‐XX:MaxTenuringThreshold 對象晉升到老年代的閥值 默認是 15

(9)‐XX:PretenureSizeThreshold 設置大對象的大小如果對象超過設置大小,會直接進入老年代,不會進入年輕代,這個參數只在 Serial 和 ParNew 兩個收集器下有效。

(10)‐XX:+UseParNewGC 使用 parNew 垃圾收集器 收集 年輕代,使用複製算法

(11)‐XX:+UseConcMarkSweepGC 使用 cms 收集器 收集 老年代 ,使用 標記 - 清除 算法

分析:

在新生代中,每次收集都會有大量對象 (近 99%) 死去,所以可以選擇 複製算法,只需要付出少量對象的複製成本就可以完成每次垃圾收集。而老年代的對象 存活幾率是比較高的,而且沒有額外的空間對它進行分配擔保,所以我們必須選 擇 “標記 - 清除” 或“標記 - 整理”算法進行垃圾收集。注,“標記 - 清 除”或 “標記 - 整理” 算法會比複製算法慢 10 倍以上。遂修改爲 4.0 版本。

2.5 4.0 版本

‐Xms3072M ‐Xmx3072M ‐Xmn2048M ‐Xss1M ‐XX:PermSize=256M ‐XX:MaxPermSize=256M ‐XX:SurvivorRatio=8

‐XX:MaxTenuringThreshold=5 ‐XX:PretenureSizeThreshold=1M ‐XX:+UseParNewGC ‐XX:+UseConcMarkSweepGC

‐XX:CMSInitiatingOccupancyFaction=92 ‐XX:+UseCMSCompactAtFullCollection ‐XX:CMSFullGCsBeforeCompaction=0

(1)‐XX:CMSInitiatingOccupancyFaction 當老年代使用達到該比例時會觸發 FullGC(默認是 92,這是百分比)

(2)‐XX:+UseCMSCompactAtFullCollection FullGC 之後做壓縮整理(減少碎片)

(3)‐XX:CMSFullGCsBeforeCompaction=0 多少次 FullGC 之後壓縮一次,默認是 0,代表每次 FullGC 後都會壓縮一次

分析:

對於老年代 CMS 的參數如何設置,有哪些對象可能長期存活躲過 5 次以上 minor gc 最終進入老年代。

無非就是那些 Spring 容器裏的 Bean,線程池對象,一些初始化緩存數據對象等,這些加起來充其量也就幾十 MB。還有就是某次 minor gc 完了之後還有超過 200M 的對象存活,那麼就會直接進入老年代,比如突然某一秒瞬間要處理五六百次行程 (比如有時候搞活動,不同時刻的不同預約可以走收費站優惠),那麼每秒生成的對象可能有一百多 M,再加上整個系統可能壓力劇增,一次預約行程要好幾秒才能處理完,下一秒可能又有很多預約行程過來。(即最後幾秒產生的行程沒有被 minor gc 掉,還存活者)

估算下大概每隔五六分鐘出現一次這樣的情況(這個要看政策,雖然不至於,但是也要考慮到,即 每五六 分鐘會有一二百 M 對象挪到老年代),那麼大概半小時到一小時之間就可能因爲老年代滿了(老年代此時是 1G)觸發一次 Full GC,Full GC 的觸發條件, 根據 老年代空間分配擔保機制,歷次的 minor gc 挪動到老年代的對象大小肯定是非常小的,所以幾乎不會在 minor gc 觸發之前由於老年代空間分配擔保失敗而產生 full gc,其實在半小時後發生 full gc,這時候已經過了預約的最高峯期,後續可能幾小時才做一次 FullGC。對於碎片整理,因爲都是 1 小時或幾小時才做一次 FullGC,是可以每做完一次就開始碎片整理。

綜上:只要年輕代參數設置合理,老年代 CMS 的參數設置基本都可以用默認值 選擇 4.0 最終版

如何選擇垃圾收集器

(1)優先調整堆的大小讓服務器自己來選擇

(2)如果內存小於 100M,使用串行收集器

(3)如果是單核,並且沒有停頓時間的要求,串行或 JVM 自己選擇

(4)如果允許停頓時間超過 1 秒,選擇並行或者 JVM 自己選

(5)如果響應時間最重要,並且不能超過 1 秒,使用併發收集器

(6)4G 以下可以用 parallel,4-8G 可以用 ParNew+CMS,8G 以上可以用 G1,幾百 G 以上用 ZGC

3. 多線程下壓縮圖片異常

因代碼中用了 spring 的線程線程池,將核心線程數調製 cpu 核數 ,拒絕策略調整爲 CallerRunsPolicy(滿了之後,由當前線程執行)。

這裏有個插曲,之前用了線程池,微信上傳圖片隨機成功失敗,有時出現異常:

File has been moved - cannot be read again

有興趣的小夥伴可以關注我的博客,裏面詳細記錄了這次 bug

記一次印象深刻的 bug—併發下 File has been moved - cannot be read again 源碼分析

4. 分析 dump 文件

生產掛了第一時間生成 dump 文件 (當然因爲內存太大也有可能不能生成),用 jvisualvm 進行分析

 1#看進程
 2jps 
 3# 使用hprof二進制形式,輸出jvm的heap內容到文件。
 4# live子選項是可選的,假如指定live選項,那麼只輸出活的對象到文件。
 5jmap -dump:[live,] format=b,file=<filename> <pid> 
 6例如: 
 7      jmap -dump:format=b,file=jzx.hprof 17921 #堆快照
 8
 9
10

發現原因是 連接數太多而沒有關閉,遂分析代碼 (連蒙帶猜),發現其實代碼中只有阿里雲 OSS 一個地方創建連接較多 (主要是其他都是用的 spring 框架默認的,只有阿里雲 OSS 代碼是自己封裝的),遂分析發現,OSS 所有方法創建連接後都關閉了,只有查看圖片的方法沒關 (可能是當時查看官網 API 不仔細,給自己找個理由)

5. 教訓總結

根據實際併發量,估算好 jvm 模型,調整大小。

管理好線程池,在不熟悉的情況下用多線程一定要向技術好的人多請教請教。

且無論什麼連接,或者流,都要關閉。

最別不要分析 dump,因爲每分析一次,都說明生產發生了一次重大 bug(主要是測試沒環境壓測,其實就是測試太菜 233333333)。

原文鏈接:

https://blog.csdn.net/weixin_42437633/article/details/107074499

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/PHZT5VhD6crGrvBT3JztXg