生產服務記憶體高問題
問題描述
-
1、“計算中心” 服務在生產環境執行一段時間後,實際佔用記憶體4.8G,業務執行正常,未出現OOM。(本文以此服務進行排查)
-
2、生產環境的老專案,均出現執行一段時間後,記憶體被佔滿但未OOM的情況。部分例項因記憶體佔用過高導致被系統kill,一般需要通過增加機器、例項進行解決(資源浪費)。
造成的影響
-
1、伺服器實體記憶體15g,部署了三個服務。如實際佔用記憶體都超過4.8g,導致伺服器實體記憶體不夠用,出現告警而將佔用記憶體最大程式kill掉,影響生產服務的可用性,後果十分嚴重。
-
2、如服務申請的記憶體超出了JVM能提供的記憶體大小(記憶體洩漏),將會導致java堆記憶體溢位,從而發生full gc,導致服務響應大幅度變慢,卡機等狀態。
-
3、在公司大促等場景的情況下,記憶體佔用很高的服務會帶來很大風險,通常需提前聯絡運維同事對“計算中心”進行重啟,增加了開發及運維同事維護的工作量。
排查過程
程式碼
(1)根據cat監控,獲取“計算中心”中的熱點方法,進行REVIEW,修正了部分可能會導致記憶體洩露的方法。並進行了觀察。
(2)通過VisualVM監控,定位到部分耗時較久的操作DB熱點方法,通過增加索引等方式,把查詢效能控制在毫秒級。
(3)dump“計算中心”的記憶體映象,通過MAT等工具觀察各個物件在堆空間中所佔用的記憶體大小、類例項數量、物件引用關係。
結論:通過以上三點,未解決和定位“計算中心”記憶體高問題。由此可以認為,“計算中心”的記憶體問題與程式碼無關。
系統
通過java ps| aux java 檢視,“計算中心”服務實際佔用的記憶體在4.9G左右,超過了JVM堆記憶體設定的大小但並未出現OOM,業務正常執行。通過free -g命令,可以發現buff/cache,3個g左右。centos中記憶體的分配是buff/cache + free + used=實體記憶體大小,系統分配給臨時檔案系統的大小預設是用掉一半的實體記憶體,這樣會造成buff/cache很大,而free很小。最終結論可能為服務記憶體沒有釋放使用了buff/cache。導致服務記憶體佔用很高。
結論:和SRE溝通實際重啟服務後,記憶體使用率立刻降低,但是buff/cache的大小沒有變化。由此可以認為,“計算中心”的記憶體問題與系統快取無關。
JVM
1、通過VisualVM監控生產環境computing記憶體使用情況得出,在服務記憶體佔用4.8g的情況下,堆記憶體(新生代 + 老年代)正常GC,在增長到2g左右會GC到300~500m,Matespace僅使用了120m左右。檢查了生產JVM引數。專案啟動引數沒有配置:-XX:MaxDirectMemorySize,來指定最大的堆外記憶體大小。這個閾值不配置的話,預設佔用-Xmx相同的記憶體。在堆內記憶體正常的情況下,懷疑是堆外記憶體佔用了大部分記憶體,導致服務記憶體佔用很高。
結論:和SRE溝通,在生產環境找了兩臺計算中心服務例項配置 -XX:MaxDirectMemorySize 引數後實際觀察後,仍未解決“計算中心”記憶體高問題。由此可以認為,“計算中心”的記憶體問題與堆外記憶體無關。
2、在發現-XX:MaxDirectMemorySize 指定堆外記憶體大小的引數沒有配置後。我檢查了“計算中心”服務的啟動引數並且和之前生產環境的服務進行了對比,發現了以下問題。
1)生產及測試環境JVM引數配置混亂,同一應用不同例項多套啟動引數配置。
2)服務啟動引數,未區分JDK版本。如:JDK1.7、JDK1.8引數混用。
3)生產服務根據模板部署,導致必要JVM引數未配置,部分引數配置不需要、不合理。
4)不同型別的應用,採用統一的啟動引數配置,不具有針對性。
在以上問題的基礎上,基於目前生產環境各專案統一使用的"CMS垃圾回收器"進行引數調整。針對計算中心的JDK版本(1.8),出了一套JVM配置方案。並在生產伺服器調整後重啟觀察。
結論:“計算中心”生產環境服務,在執行一段時間後,仍出現記憶體佔用高問題。由此可以認為,“計算中心”的記憶體問題與不同JDK版本的引數混用問題無關。
3、經調研,逐漸被淘汰的垃圾回收器比如ParallelOldGC和CMS,只要JVM申請過的記憶體,即使發生了GC回收了很多記憶體空間,JVM也不會把這些記憶體歸還給作業系統。這就會導致top命令中看到的RSS(程式RAM中實際儲存的總記憶體)只會越來越高,而且一般都會超過Xmx的值。JDK1.9以後。預設的垃圾回收器已經選擇了G1。
G1相比CMS有更清晰的優勢:
1)CMS採用"標記-清理"演算法,所以它不能壓縮,最終導致記憶體碎片化問題。而G1採用了複製演算法,它通過把物件從若干個Region(獨立區域)拷貝到新的Region(獨立區域)過程中,執行了壓縮處理,垃圾回收後會整合空間,無記憶體碎片。
2)在G1中,堆是由Region(獨立區域)組成的,因此碎片化問題比CMS肯定要少的多。而且,當碎片化出現的時候,它隻影響特定的Region(獨立區域),而不是影響整個堆中的老年代。
3)而且CMS必須掃描整個堆來確認存活物件,所以,長時間停頓是非常常見的,無法預測停頓時間。而G1的停頓時間取決於收集的Region(獨立區域)集合數量,在指定時間內只回收部分價值最大的空間,而不是整個堆的大小,所以相比起CMS,長時間停頓要少很多,可控很多。
4)G1選回收階段不會產生“浮動垃圾”,由於只回收部分Region(獨立區域),所以STW(stop-The-World機制簡稱STW,是在執行垃圾收集演算法時,Java應用程式的其他所有執行緒都被掛起)時間我們可控,所以不需要與使用者執行緒併發爭搶CPU資源。而CMS併發清理需要佔據一部分的CPU,會降低吞吐量。G1由於STW,所以不會產生"浮動垃圾",CMS在併發清理階段會產生的無法回收的垃圾。
因此在以下場景下G1更適合:
1)服務端多核CPU、JVM記憶體佔用較大的應用。
2)應用在執行過程中會產生大量記憶體碎片、需要經常壓縮空間。
3)想要更可控、可預期的GC停頓週期;防止高併發下應用雪崩現象。
結論:將”計算中心“使用的垃圾回收機制升級為G1,並增加G1相關的優化記憶體的引數,在生產伺服器進行觀察一週後發現服務記憶體始終穩定在了3.3G左右,業務處理效能穩定,成功解決了“計算中心”服務佔用記憶體較高的問題,提升了系統的可用性,無需通過增加物理資源來提升服務整體效能。
CMS升級為G1方式
計算中心“生產全部服務例項部署的伺服器,使用的是JDK1.8,JDK1.8支援G1垃圾回收器,故將服務啟動引數進行統一調整:
1)原引數(主要問題:使用CMS版本,JDK1.7,1.8引數混用,未指定堆外記憶體大小)
/opt/java/jdk1.8.0_102/bin/java -Dapp.home=${APP_HOME} -Dspring.profiles.active=prd -Dserver.port=${SERVER_PORT} -server -Xms4G -Xmx4G -Xmn2g -Xss256k -XX:PermSize=128m -XX:MaxPermSize=512m -Djava.awt.headless=true -Dfile.encoding=utf-8 -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -XX:AutoBoxCacheMax=20000 -XX:-OmitStackTraceInFastThrow -XX:ErrorFile=${APP_HOME}/logs/hs_err_%p.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${APP_HOME}/logs/ -Xloggc:${APP_HOME}/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -jar ${APP_HOME}/webapps/${JAR_NAME} ${SERVER_PORT}"
2)新引數(使用G1做為垃圾回收器)
/opt/java/jdk1.8.0_102/bin/java -Dapp.home=${APP_HOME} -Dspring.profiles.active=prd -Dserver.port=${SERVER_PORT} -server -Xms4g -Xmx4g -Xss256k -XX:NewSize=512m -XX:MaxNewSize=512m -XX:+UseG1GC -XX:InitiatingHeapOccupancyPercent=40 -XX:G1HeapRegionSize=8m -XX:+ExplicitGCInvokesConcurrent -XX:ParallelGCThreads=4 -Dsun.rmi.dgc.server.gcInterval=36000000-Dsun.rmi.dgc.client.gcInterval=36000000-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m -XX:+UseCodeCacheFlushing -XX:ReservedCodeCacheSize=256m -XX:MaxDirectMemorySize=512m -XX:GCTimeRatio=19 -XX:MinHeapFreeRatio=20 -XX:MaxHeapFreeRatio=30 -XX:ErrorFile=${APP_HOME}/logs/hs_err_%p.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${APP_HOME}/logs/ -Xloggc:${APP_HOME}/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -jar ${APP_HOME}/webapps/${JAR_NAME} ${SERVER_PORT}"
注:不同伺服器環境,不同容器,指令碼配置各不相同,要在對應指令碼的基礎上進行鍼對性升級。
生產G1回收器主要引數說明
JVM相關概念說明
JDK1.7記憶體模型
實際佔用記憶體大小(引數):-XX:MaxPermSize(非堆) + -Xmx(堆) + -Xss(棧) + -XX:MaxDirectMemorySize(堆外)
JDK1.8記憶體模型
實際佔用記憶體大小(引數):-XX:MaxMateSpaceSize(堆外) + -Xmx(堆) + -Xss(棧) + -XX:MaxDirectMemorySize(堆外)
GC流程圖
1、什麼時候觸發Minor GC
2、觸發Minor GC 的過程
3、Full GC 的過程
1、新建立的物件一般會被分配在新生代中。常用的新生代的垃圾回收器是 ParNew 垃圾回收器,它按照 8:1:1 將新生代分成 Eden 區,以及兩個 Survivor 區。建立的物件將 Eden 區全部擠滿,這個物件就是「擠滿新生代的最後一個物件」。此時,Minor GC 就觸發了。
2、在正式 Minor GC 前,JVM 會先檢查新生代中物件,是比老年代中剩餘空間大還是小。Minor GC 之後 Survivor 區放不下剩餘物件,這些物件就要進入到老年代,所以要提前檢查老年代是不是夠用。
3、老年代剩餘空間大於新生代中的物件大小,那就直接 Minor GC,GC 完 survivor 不夠放,老年代也絕對夠放。老年代剩餘空間小於新生代中的物件大小,這時候就要進入老年代空間分配擔保規則。
4、老年代空間分配擔保規則:如果老年代中剩餘空間大小,大於歷次 Minor GC 之後剩餘物件的大小,那就允許進行 Minor GC。因為從概率上來說,以前的放的下,這次的也應該放的下。那就有兩種情況:
-
老年代中剩餘空間大小,大於歷次 Minor GC 之後剩餘物件的大小,進行 Minor GC
-
老年代中剩餘空間大小,小於歷次 Minor GC 之後剩餘物件的大小,進行 Full GC,把老年代空出來再檢查。
5、結合第四步,開啟老年代空間分配擔保規則只能說是大概率上來說,Minor GC 剩餘後的物件夠放到老年代,如果放不下:Minor GC 後會有這樣三種情況: -
Minor GC 之後的物件足夠放到 Survivor 區,GC 結束。
-
Minor GC 之後的物件不夠放到 Survivor 區,接著進入到老年代,老年代能放下,那也可以,GC 結束。
-
Minor GC 之後的物件不夠放到 Survivor 區,老年代也放不下,那就只能 Full GC。
6、以上是成功 GC 的例子,以下3 中情況,會導致 GC 失敗,報 OOM:
緊接上一節 Full GC 之後,老年代任然放不下剩餘物件,就只能 OOM。
未開啟老年代分配擔保機制,且一次 Full GC 後,老年代任然放不下剩餘物件,也只能 OOM。
開啟老年代分配擔保機制,但是擔保不通過,一次 Full GC 後,老年代任然放不下剩餘物件,也是能 OOM。
注:
- 老年代分配擔保機制在JDK1.5以及之前版本中預設是關閉的,需要通過HandlePromotionFailure手動指定,JDK1.6之後就預設開啟。如果我們生產環境服務使用的是JDK/1.7JDK1.8,所以不用再手動去開啟擔保機制。
- Full GC主要指新生代、老年代、metaspace上的全部GC。
感謝以下作者給與我的幫助
- 圖解GC流程:https://www.cnblogs.com/shuiyj/p/12640692.html
- CMS垃圾回收升級G1回收器實踐:http://arick.net/content/44
- JAVA常見問題分析:https://blog.51cto.com/hmtk520/2067043