億級流量系統如何玩轉 JVM
本文轉載自微信公眾號「Shooter茶杯」,作者Shooter。轉載本文請聯系Shooter茶杯公眾號。
抱歉很久沒寫新文章了 , 這段時間一直在學習擴大自己的知識盲區 , 工作上也挺忙的 , 拖更了好久
答應了朋友要出個 JVM 系列 , 應該會有幾篇文章 , 我會努力在保證質量的前提下進行輸出~
So 進入今天的主題
前言
有被 JVM 相關問題刁難過嗎?
上個月朋友去面某東說被 JVM 難哭了
面試官上來就是素質三連:
有沒有 高并發項目經驗、頻繁 gc 怎么解決、有沒有搞過 JVM 調優
我那個朋友公司做的是 to b 方向 , 系統流量不是很大 , 加上才工作 2 年直接被問懵逼
回來就問我高并發系統怎么玩 , 為了避免重復勞動 , 遂有此文~
一、億級流量系統回顧
接下來做個回顧:
OTA 平臺 4億 用戶
高峰期 百萬 訂單
高峰期 12 小時 1.8億 訪問量
每小時的流量是:1.8億 / 12 = 1250w
每分的流量是:1250w / 60 = 20.8w
每秒的流量是:20.8w / 60 = 3472
2 個集群 32 臺 8C/16G 的機器
一次核心接口查詢平均占用 5mb 內存
每秒鐘 JVM 會有 550mb 的新生代堆內存空間被占用
二、系統的 JVM 參數
基于G1垃圾收集器
這里我截取了這個服務生產環境的 JVM 參數:
- -Xmx12288m 初始堆大小.
- -Xms12288m 最大堆大小
- -Xss256k 每個線程的棧內存大小
- -XX:MetaspaceSize=256m 元空間初始大小
- -XX:MaxMetaspaceSize=1g 元空間最大大小
- -XX:MaxGCPauseMillis=200 每次YGC / MixedGC 的最多停頓時間 (期望最長停頓時間)
- -XX:+UseG1GC java8 指定使用G1垃圾回收器
- -XX:-OmitStackTraceInFastThrow 對異常做的一個優化,拋出異常非常快,但是看不到異常的堆棧信息(僅供參考)
- -XX:MinHeapFreeRatio=30 GC后java堆中空閑量占的最小比例,小于該值,則堆內存會增加
- -XX:MaxHeapFreeRatio=50 GC后java堆中空閑量占的最大比例,大于該值,則堆內存會減少
- -XX:CICompilerCount=4 設置的相對較大可以一定程度提升JIT編譯的速度,默認為2
- -XX:SoftRefLRUPolicyMSPerMB=0 任何軟引用對象在下一次 GC 都盡快釋放掉,給內存釋放空間。
- -XX:+PrintGC 輸出GC日志
- -XX:+PrintGCDetails 輸出GC的詳細日志
- -XX:+PrintGCDateStamps 輸出GC的時間戳(以基準時間的形式)
- -XX:+UseGCLogFileRotation 開或關閉GC日志滾動記錄功能
- -XX:NumberOfGCLogFiles=5 設置滾動日志文件的個數
- -XX:GCLogFileSize=32M 設置滾動日志文件的大小,當前寫日志文件大小超過該參數值時,日志將寫入下一個文件
- -XX:+HeapDumpOnOutOfMemoryError JVM會在遇到OutOfMemoryError時拍攝一個堆轉儲快照,并將其保存在一個文件中
注意
-XX:SoftRefLRUPolicyMSPerMB=0 這個參數在某些情況下會造成元空間 OOM ,一般最好給個 2000 / 5000,
0 是經過調優確認不會引起這個問題才用。
為什么會造成 OOM 我會在以后的文章會中提到。
三、高并發下 JVM 是怎么玩的?
堆空間怎么分配內存?
雖然給堆空間分配了 12G 的內存,但新生代并不是一開始就把這 12G 一下就占滿了,老年代還得占一部分。
也不是一開始就將新老生代按個比例分配好空間,新生代一開始只會分配 5% 的堆內存空間,然后慢慢的增大,
這個是可以通過 -XX:G1NewSizePercent 來設置新生代初始占比的,其實維持這個默認值就可以了
同樣老年代也是,并不是以開始就分配幾個G ;因為 G1 是基于 Region 的邏輯來分區的。
到底多久會觸發一次新生代的 YoungGC(ygc)?
有人說:新生代的 Eden 區空間不夠用了就會觸發 ygc 那到底 Eden區使用多少了才是內存不夠呢?
有一個參數 -XX:G1MaxNewSizePercent 默認值:60% ,限定了新生代最多占用堆內存 60% 的空間,
那就是是 12G * 60% = 7.2G,然后 新生代又有 Eden 和 兩個 Survivor 組成 默認比例是: 8:1:1,
7.2G * 0.8 = 5.76G , 是 Eden 區快到 5.7G 就觸發 ygc 么?
并不是,G1 有個很重要的參數 -XX:MaxGCPauseMillis 這個參數的默認值是 200
意味著每次 進行垃圾回收,最長的停頓時間不超過 200ms。這也是為什么 G1 號稱它造成的 STW 是停頓可控的。
做個大膽的假設: 200ms G1可以回收 300個Region 區域!
因為 G1 是在邏輯上區分 老年代和新生代的,整個堆被分成了 2048 個 Region 區域,12G 的堆內存平均每個 Region 的大小是 6MB
但 Region 的大小必須是 2的 N次冪,所以每個 Region 的大小會是 8mb
之前算出來了這個系統每秒鐘往新生代輸送的對象大小是 550mb ,550mb / 8mb = 68 ,平均每秒會有 68 個 Region 被占滿,
回收 300 個 Region 需要 200ms , 300 / 68 = 4.5ms ,
大概 4.5ms 就會進行一次 ygc ,一分鐘就會進行 13 次 ygc ,每次 ygc 200ms
這樣分析就會發現 G1 的垃圾回收其實是很動態,很靈活的,它會根據你對 GC 的預期停頓時間來進行回收。
G1 哪些對象會進入老年代?
- 一個對象在年輕代里躲過15次垃圾回收,年齡太大了,壽終正寢,進入老年代
- 大對象直接送到老年代 參數 XX:PretenureSizeThreshold 來控制多大的對象才算大。XX:PretenureSizeThreshold=100000000 單位為btye
- 動態年齡判定規則,如果一旦發現某次新生代 GC 過后,存活對象超過了 Survivor 50%
- 一次 ygc 過后存活對象太多了,導致 Survivor 區域放不下了,這批對象會進入老年代
這個接口的耗時一般在 200ms 左右,但在高并發情況下,內存資源這么吃緊,CPU 和 線程資源都會有很高的負載,這時候就很有可能出現一些性能抖動的情況
相應的表現就是接口的響應時間延長,甚至會出現超時,在頻繁的 fgc 情況下:
- 一些對象在 Survivor區 經過 15 次 ygc 后,就會晉升到老年代
- 很多接口的響應時間都延長,導致觸發動態年齡判斷規則,就會有一大批對象晉升到老年代,
看起來這么大的內存,Survivor區 也足夠大,這個晉升規則也比較嚴格,但是高并發的場景下,上面這個流程只要反復的來幾次
老年代的對象就會越來越多
什么是 Mixed GC (混合回收)?
因為 G1 是基于 Region 的,并沒有嚴格的區分老年代新生代,
G1有一個參數,XX:InitiatingHeapOccupancyPercent ,它的默認值是 45% ,意思就是說,如果 老年代 占據了堆內存的 45% 的 Region 的時候,此時就會嘗試觸發一個新生代+老年代一起回收的混合回收。
什么時候發生 Full GC(fgc) ?
異常情況
- 大對象太多,對象都跑老年代去了,老年代內存吃緊會觸發 fgc ,如果fgc 內存還是不夠使用,那再申請內存的時候就會拋出 OOM 異常,然后再 fgc 如此往復循環,系統并不會直接掛掉,表現是系統假死,非常卡頓,用戶體驗極差。
- 元空間、直接內存這些區域快滿了都會觸發 fgc
后續 堆空間、元空間、直接內存(堆外內存) OOM 都會有真實的生產環境案例 敬請期待
正常情況
- fgc 都知道是一個很耗時的操作 , G1 正常的工作狀態是沒有 Full GC 概念的,老年代垃圾的收集任務全靠 Mixed GC 來處理。
- 不過在進行 Mixed 回收的時候,無論是年輕代還是老年代都基于復制算法進行回收,都要把各個 Region 的存活對象拷貝到別的 Region 里去,
- 此時萬一出現拷貝的過程中發現沒有空閑 Region 可以承載自己的存活對象了,就會觸發一次失敗。一旦失敗,立馬就會切換為停止系統程序,切換到 G1 之外的 Serial Old GC 來收集整個堆(包括 Young、Old、Metaspace )這才是真正的 Full GC(Full GC不在G1的控制范圍內)
- 進入這種狀態的G1就跟使用參數 -XX:+UseSerialGC 的 Full GC 一樣(背后的核心邏輯是一樣的)。然后采用單線程進行標記、清理和壓縮整理,空閑出來一批 Region ,使用單線程的進行 gc 這個過程是極慢極慢的。
- 這也是 JVM 調優的關鍵所在,務必不要讓你的系統觸發 Full GC !
補充
-XX:MaxGCPauseMillis = 200 是一個默認值,停頓 200ms 也不算久,但一個高并發系統如果要求低延遲,快速響應
這個值就要再調低一點了,但是仍然不建議去把這個值改小,
很多時候設置的 200ms, 實際上也只有 20 - 80ms ,這是我觀察過不下 30 個生產環境的 GC 得出來的結論。
跟做性能測試的大佬也討論過這個的原因:G1 是一個 動態、靈活、自主、性能還不錯 的垃圾收集器
如果設置太小 ,可能導致每次 Mixed GC or ygc 只能回收很小一部分 Region ,最終可能無法跟上程序分配內存的速度
從而觸發 Full GC 所以很多系統并沒有去把這個值改成 50 或是 100
如果設置太大 ,那么可能 G1 會允許你不停的在新生代理分配新的對象,然后積累了很多對象,再一次性回收幾百個 Region
此時可能一次 GC 停頓時間就會達到幾百毫秒,但是 GC 的頻率很低。
比如說 30 分鐘才觸發一次新生代 GC,但是每次停頓 500ms ,毫無疑問, 500ms 對于一個高并發的系統來說實在是太久了
四、JVM 調優該怎么做?
主要優化在新生代
新生代gc如何優化?
對于G1而言,我們首先應該給整個JVM的堆區域足夠的內存,其次就是給新生代足夠的內存,保證:
- 不要讓對象經歷 15 次垃圾回收從而進入老年代
- 不要讓 Survivor 太小,從而觸發動態年齡判斷,也要保證每次 ygc 后 Survivor 都能夠放下存活的對象
之前我們算過,這個系統每分鐘會有 550mb 的對象會進入新生代 , 4.5s 就會來一次 ygc ,
一分鐘會有 13 次左右的 gc , 每次 gc 大概在 200ms 以內。
PS : G1 回收只有初始標記和重新標記的階段是 stw,其他階段都是并發的,
gc 200ms , 真正 stw 的時間可能只是 幾十 ~ 一百ms
不過!每分鐘 13次 的 ygc 頻率,每次接近 200ms 左右耗時 gc 效率實在太低了
新生代優化
因為有 記憶集 (RSet) 的存在,在 G1 回收 Region 效率不變的情況下 , 優化的點就來了
擴大每個 Region 的大小 , 也就是擴大堆內存的大小 , 簡而言之就是升級機器的內存 或者是 集群進行擴容增加服務器的數量
目前這個業務系統只有 32 臺機器 8C 16G的機器 , 給堆空間的大小只有 12G , 對億級的流量還是不太能抗住 ,
目前階段性的分析后 , 性能瓶頸不在 CPU , 我們只需要升級內存即可
升級到 8C 32G 給堆 24~26G 的空間 , 元空間給 1G 則機器數量不變 升級到 16C 64G 給堆 58~60G 的空間 , 元空間給 1G 還可以下幾臺機器
為什么會發生 Mixed gc ?
對于 Mixed gc 的觸發,大家都知道是老年代在堆內存里占比超過 45% 就會觸發
再回顧一下:年輕代的對象進入老年代的幾個條件:
- 新生代 gc 過后存活對象太多沒法放入 Survivor 區域
- 對象年齡太大
- 動態年齡判定規則
其中尤其關鍵的是新生代 gc 過后存活對象過多無法放入 Survivor 區域 , 以及動態年齡判定規則這兩個條件尤其可能 讓很多對象快速進入老年代
一旦老年代頻繁達到占用堆內存 45% 的閾值 , 那么就會頻繁觸發 Mixed gc
那我們的目標就是 :
盡量避免對象過快進入老年代 , 盡量避免頻繁觸發 mixed gc , 就可以做到根本上優化 mixed gc 了
Mixed gc 優化思路
Mixed gc 優化的核心還是 -XX:MaxGCPauseMills 這個參數
大家可以想一下 , 假設 -XX:MaxGCPauseMills 參數設置的值很大 , 導致系統運行很久
新生代可能都占用了堆內存的 70% 了 , 此時才觸發新生代 gc
那么存活下來的對象可能就會很多 , 此時就會導致 Survivor 區域放不下那么多的對象 , 就會進入老年代中
或者是新生代 gc 過后 , 存活下來的對象過多 , 導致進入 Survivor 區域后觸發了動態年齡判定規則
達到了 Survivor 區域的 50% , 也會快速導致一些對象進入老年代
所以這里核心還是在于調節 -XX:MaxGCPauseMills 這個參數的值 , 在保證新生代 gc 別太頻繁的同時 , 還得考慮每次 gc 過后的存活對象有多少
避免存活對象太多快速進入老年代,頻繁觸發 Mixed gc
五、實際有效的調優參數
1.-XX:MaxGCPauseMillis: 根據系統可以接受的響應時長和指標 觀察 JVM 的回收時間來進行修改 單位:ms
太小跟不上分配內存的速度 , 太大 gc 的時間太長。
2.-XX:ParallelGCThreads: 在 stw 階段工作的 GC 線程數 , 可以根據當前機器 CPU 核數來設置 , 建議核心數 -1
-XX:ConcGCThreads: 在非 stw 階段工作的 GC 線程數 , 會影響系統的吞吐量 , 畢竟是要跟用戶線程搶 CPU 資源
系統如果是計算密集型的建議是 CPU 核數的 1/4 ~ 1/3 , iO 密集型建議是 1/2
3.-XX:G1ReservePercent: G1為分配擔保預留的空間比例 也就是老年代留多少空間給 新生代來晉升 , 默認是 10%
如果晉升失敗會觸發單線程的 old gc 非常恐怖 , 建議高并發系統加大機器內存 提高這個參數的比例
4.-XX:MaxMetaspaceSize: 元空間最大大小 , 在高并發且機器內存夠的情況 建議增大元空間的大小
稍微大的點系統都會有很多依賴的組件,這些組件底層都有可能會用到一些反射 或者 字節碼框架 , 會生成一些你看不懂類名的類
一旦第三方框架出現問題 , 你的系統很有可能也會受影響
調大元空間 , 有監控系統的設置報警機制 , 給自己系統爭取一些緩沖時間也是有必要的
5.-XX:TraceClassLoading -XX:TraceClassUnloading
追蹤類加載和類卸載的情況 , 可以在 Tomcat 的 catalina.out 日志文件中
打印出來JVM中加載了哪些類,卸載了哪些類
6.-XX:SoftRefLRUPolicyMSPerMB: JVM 可以忍受多久 軟引用不被回收
如果是 0 則每次都會把軟引用回收掉釋放內存
有一個情況是反射在 15 次后會動態生成一些軟引用類來提高反射的效率 , 當 ygc 的時候把這些軟應用給回收了
但是它們的類加載器或者一些奇怪名字的類還在元空間 , 那下次要用這個反射對象的時候又得重新創建
就造成了元空間慢慢無限增大從而觸發 OOM , 建議這個參數設置 2000 - 5000 單位是: ms
7.-XX:+DisableExplicitGC: 關閉顯示的調用 System.gc() , System.gc() 是觸發類似 full gc 的操作
開啟 or 關閉 有兩個情況
關閉: 防止 team 里有剛入職的小天才寫完一個業務邏輯就給你來一個 System.gc() 來優化內存 (別問 問那個小天才就是我)
開啟: 項目里面有 Nio 相關的操作會用到直接內存 , 在 Java 中是 DirecByteBuffer 對象來申請的
在某些不合理的情況下導致控制這塊區域的 DirecByteBuffer 會晉升到老年代
Nio 在申請堆外內存空間不足的時候會手動調用 System.gc() 去回收 DirecByteBuffer 堆外內存
有用到 Nio 的系統把這個參數關掉是有一定概率發生 Direct buffer memory 的
關閉還是打開取決于你自己的系統 , 以及能不能做到 code review 不讓程序員自己去顯示的調用 System.gc()
8.-XX:G1MixedGCCountTarget: 設置垃圾回收混合回收階段,最多可以拆成幾次回收
G1 的垃圾回收是分為 初始標記、并發標記、最終標記、混合回收 這幾個階段的
其中混合回收是可以并發的反復回收多次 , 這樣的好處是避免單次停頓回收 stw 時間太長
停止系統一會兒 , 回收掉一些 Region , 再讓系統運行一會兒 , 然后再次停止系統一會兒 , 再次回收掉一些 Region
這樣可以盡可能讓系統不要停頓時間過長 , 可以在多次回收的間隙 , 也運行一下
在一定程度上可以防止部分接口相應超時
六、小結
相信你看到這里 , 應該對高并發系統中 對象如何吃 JVM 內存 頻繁 遇到 gc 如何解決 已經有所了解了 。
盡管有效的解決辦法仍然是加機器 , 但是加多少臺機器 , 怎么加機器 , JVM 參數要如何設置都有所了解了。