不會JVM調優怎么進互聯網大廠
如果說有什么在面試中經常被問到,但是在實際工作中又不經常用到的Java技術,那么JVM調優絕對可以排得上號。每當有同學被問到這個問題的時候,內心的OS大概是這樣:我一個QPS幾百的系統,有啥好調優的,默認配置用用得了,調JVM參數整不好系統還能干崩了,想想好像是這么個道理。但是對于一些高并發大流量業務場景,JVM調優就有用武之地了。因此個人覺得JVM調優也許不像SQL調優或者代碼優化在工作中使用得那么頻繁,甚至很多時候其實是用不上的,但是在需要用到的時候如果你能頂得住壓力完成優化,那么你和其他人的不同就顯現出來了。另外如果我們想進入一線互聯網大廠,那么JVM調優就是必須要掌握的重要技能。
那么JVM到底應該怎樣調優呢?有沒有什么套路我們可以學習?本文主要著眼于如何進行參數預估以及JVM優化,希望對大家平時工作可以有所裨益。
預估比調優更重要
為什么需要進行預估
所謂凡事預則立不預則廢,對于JVM調優來說也是如此。無論修改線上已有JVM參數配置還是優化代碼實際都是一種無奈之舉,因為生產環境出現了運行異常不得不采用這種方式進行優化,從而保障線上應用服務能夠正常運行,否則就要拉程序員出來祭天了。但是如果我們在服務發布部署之前可以預估服務的容量而后進行對應的JVM參數配置,那么就相當于把可能出現的JVM異常扼殺在搖籃中。當然這是最理想的狀態,在現實中也實際不容易做到,但是即便我們不能預估的那么準確,也總比不做容量預估直接裸奔上線的好。因此關于JVM調優這件事情,實際和《孫子兵法》的核心思想有異曲同工之妙,上兵伐謀,其次伐交,其次伐兵,其下攻城。也就是說JVM調優最高境界是預估不調,打仗最高境界是不戰而勝。
如何進行預估
JVM參數預估基本流程
在明確了JVM參數預估對于生產環境中服務穩定運行的意義之后,我們一起看下如何進行JVM參數預估。首先我們應該先分析下自己系統的核心業務流程是什么,然后根據核心業務流程結合線上可能的流量,確認好我們核心業務代碼中的對象創建以及銷毀情況是怎樣的,最后再針對性的進行相關JVM參數的預估配置。因此JVM參數預估的地址基本流程如下所示:
案例驅動
這里以一個實際的業務場景案例來幫助大家更好理解JVM參數預估的過程。假設有這樣一個電商平臺,它主要由商品中心、訂單中心、營銷中心、庫存中心等子系統組成。那對于電商系統來說最核心的業務就是用戶下單購物,我們就以用戶下單購物這個業務流程來看看如何進行估算JVM參數。
假設平臺有1個億的注冊用戶,日活用戶1000萬,這些用戶會在電商平臺進行瀏覽商品、下單購買以及收貨評價等操作,但是實際上真正下單購買的用戶并沒有那么多,如果有10%的轉化率,那么就相當于每天電商平臺有100w個訂單。另外一般情況下這些訂單主要分布在一天當中的高峰時間,比如中午或者晚上,畢竟其他時間大家要忙碌工作以及其他事情,中午休息或者晚上休息的時候才會有時間逛平臺買買買。如果我們把用戶購買的高峰時間定為3小時,也就是說極端情況下將所有的訂單的生成都分布在這三個小時中完成,也就是每小時產生33萬個訂單,每秒產生92個訂單左右。
在估算訂單對象大小之前,我們先來看下堆中的對象由哪些元素組成。一個JVM對象的大小主要由三部分組成,分別是對象頭、數據以及數據補齊。對象頭以及對象補齊基本變化不大,因此對象的大小實際和對象中的屬性有直接關系,對象中的屬性越多,對象占用的空間大小也就越大。
Mark Word:主要存儲對象自身的運行數據,包括HashCode、GC分代年齡鎖狀態標志、線程持有的鎖等信息,根據操作系統位數的不同而不同,32位的操作系統對應的大小就是32bit,64位的操作系統對應的大小就是64bit;
Klass Pointer:指向對象對應的Class對象的內存地址,根據不同的操作數系統占用空間不同,在64位系統中占用8個字節;
Array Length:如果當前對象是一個數組對象那么此處存儲的就是數組的大小,占用4個字節,如果不是數組對象那么就不占用。
回到我們剛才的案例當中,我們來具體估算一個訂單對象大概占多少內存空間。訂單對象主要包括了如下的屬性:訂單編號、商品編號、商品價格、創建時間、付款時間以及發貨時間等,當然實際訂單可能不止這些屬性,我們只是說明對象大小估算的方法,因此對屬性進行了相應的裁剪。如果數據層面包含了這些屬性,那么數據部分的占用空間大小就是這些屬性的大小總和。總共估算下來應該不到1kb,但是實際考慮到其他各種占用以及平臺中肯定不止訂單這一種對象,還會有庫存對象、積分對象、物流對象、營銷對象等等,因此我們考慮將對象的總和擴大30倍進行估算,也就是說平臺中產生的各種對象的總和為30乘以1kb即為30kb。如果每秒產生92個對象的話,那么就相當于每秒產生2760kb的對象,也就是大概2Mb的對象,另外由于電商平臺中布置下單這一個操作,還會包含訂單查詢、商品查詢等等其他業務那么綜合起來我們再放大10倍,也就是說每秒JVM中新增20Mb左右的對象。對于一臺4核8G的服務器來說,我們可以為服務分配3G左右的堆內存,512Mb左右的元空間。
但是考慮到電商平臺存在大促場景,這個時候的流量可能是平時的好幾倍,因此我們實際上需要將堆內存中的年輕代進行放大,Eden區可以到1.6G,Survivor區可以各自200M。這樣可以避免由于年輕代空間不足導致對象提前進入老年代而造成fullGC的頻率變高,從而影響服務的穩定性。
JVM調優思路JVM
理想情況下預估的JVM參數應該可以cover線上的業務場景,但是假如公司業務發展飛快,業務體量迅速膨脹,原先預估的JVM配置參數可就不一定能滿足線上生產環境所有情況,因此異常情況還是會出現。這里將JVM異常主要分為兩類,一種是代碼導致的JVM異常,另一種是JVM不合理配置導致的異常,包括JVM參數以及服務器內存配置。
代碼導致的JVM異常
代碼Bug應該是導致JVM異常最常見的情況,這種情況我們只能通過調整代碼才能實現優化,因為即使臨時調整JVM參數也只是緩兵之計,并沒有根除問題所在,隨著時間的推移,業務的發展問題還是會暴露。所以要想解決根本問題還是需要定位問題代碼來進行優化。
那么首先我們就需要能夠有手段定位到到底哪部分代碼導致JVM異常。一般分為兩種情況,一種就是已經發生內存溢出了,另一種是還沒有發生內存溢出但是已經在崩潰的邊緣,系統響應也變慢了。如果我們配置了-XX:HeapDumpPath參數,當JVM發生內存溢出的時候就可以到對應的目錄去找到hprof文件。如果還沒有發生內存溢出,這個時候我們可以通過操作命令jmap -dump:format=b,file=/tmp/文件名.hprof <PID>來手動導出內存快照來進行進行分析。有了hprof文件之后,我們可以通過MAT工具來分析和定位內存溢出代碼位置,然后再進行針對性的優化。
Java代碼引起的JVM異常可以分為以下幾種情況,我們一起來看看有哪些:
(1)如下圖的例子,客戶端和服務端建立了websocket連接,如果連接未正常建立,又重新建立連接如果此時服務端未將連接關閉,那么就會導致重新使用新的請求對象,隨著時間的累計JVM中出現大量對象來不及回收,導致JVM無法分配新的內存空間給服務中新產生的對象,最終導致JVM內存溢出。由于JVM中堆積了幾千個RequestIInfo對象,同時服務還在不斷產生新的RequestInfo對象,最終不可避免地就會發生OutOfMemoryError異常。通過MAT我們可以輕松定位到發生內存溢出的代碼位置,搞清楚為什么會有RequestInfo對象被創建之后,我們就可以進行針對性的優化了。
(2)我們在實際項目開發的過程中必定會涉及到業務數據的查詢,假如沒有控制好數據查詢的條件或者說本身查詢的數據量就很大。那么就容易造成一次性查詢大量數據,這些數據如果全部load到內存中就很容易導致內存溢出。所以一般涉及到數據查詢的代碼要做好相應的處理,分頁查詢也好,限制查詢數據量也好或者流式查詢也好,總之不能一次性將大量數據加載到內存中。
(3)在for循環或者while循環中大量創建對象,最終導致對象在對堆內存中堆積,這種是由于條件沒有控制好條件導致對象被不斷創建。
(4)我們都知道JVM運行時數據區的虛擬機棧是線程所獨有的,JVM啟動后會為每個線程的虛擬機棧分配固定大小的內存(-Xss參數),因此虛擬機棧的深度是確定的,如果代碼中出現不合理的遞歸代碼,就會造成虛擬機棧只入棧不出棧,最終導致虛擬機棧內存空間被耗盡,從而產生StackOverFlowError。
當我們知道了這些常見的可能導致JVM異常的代碼結構之后,那么在平常做項目編寫代碼的時候就要時刻保持警惕。寫完代碼之后自己再回頭看看這段代碼的對象創建情況是怎樣的,有沒有大對象緩存,有沒有不合理的while循環for循環,會不會有可能造成JVM內存溢出。當我們有了這樣反觀代碼的意識之后,從根本上JVM內存溢出的概率大大降低,有益于線上服務的穩定性。
JVM參數不合理導致的異常
線上環境JVM參數不合理直接影響JVM運行穩定性。我們都知道對象都是存放在堆內存中的,而堆內存又被劃分為年輕代和老年代,新產生的對象都會被分配在年輕代對應的堆內存中,如果此時我們設置的年輕代過小。那么對象進入到老年代堆空間的概率就會增大,當然引起full GC的可能性也會大大增加。因此JVM參數如果設置的不合理一般是堆內存大小、元數據區大小以及垃圾回收器。另外我們需要根據不同的業務場景來選擇對應的垃圾回收器,如果對于停頓時間有比較高的要求可以考慮G1和ZGC。
通過上文我們可以明確無論是優化業務代碼還是參數調優,其實都是在避免在堆中遺留過多的對象。可以看得出來,JVM調優的本質思想其實就是生產者-消費者模型,為什么這么說呢?你看一方面隨著平臺業務的不斷進行,JVM中會不斷產生對象,那么平臺就相當于對象生產者。另一方面垃圾回收器這個勤勞的小蜜蜂在不斷檢測哪些對象已經是垃圾對象,然后根據策略進行垃圾回收釋放內存空間,那么JVM就相當于對象消費者。一個生產對象,一個消費對象,這可不就是生產者消費者模型嘛。所以從這個角度來看,生產者和消費者的動態平衡才能保證JVM的正常運行,如果對象生產地快,而對象回收地慢就會導致內存溢出等JVM異常。所以JVM調優從本質上來說,就是通過各種手段構建對象生產與回收的動態平衡。
JVM常見配置參數
無論是發布部署前的JVM參數預估還是異常過后的參數優化,都是需要通過調節JVM對應的參數來完成的,因此我們需要掌握常用JVM參數項及其含義。
配置項 | 含義 |
-Xms | 初始堆大小 |
-Xmx | 初始堆最大值 |
-Xmn | 堆中新生代最大值 |
-XX:SurvivorRatio | survivor區與Eden區的比例 |
-XX:NewRatio | 新生代和老年代的比例 |
-XX:MetaspaceSize | 初始元空間大小 |
-XX:MaxMetaspaceSize | 元空間最大大小 |
-Xss | 線程虛擬機棧大小 |
-XX:+HeapDumpOnOutOfMemoryError | 開啟內存溢出時進行內存快照 |
-XX:HeapDumpPath=/data/dump/jvm.hprof | 內存快照文件路徑 |
JVM常見垃圾回收器
隨著JDK版本的不斷迭代,垃圾回收器同樣也在不斷迭代優化。當然不同的業務場景,我們可以選擇不同的垃圾回收器來進行應對,而垃圾回收器也在向著回收效率高、停頓時間短的目標不斷進行調整改進。
垃圾回收器 | 引入版本 | 特點 | 適用場景 |
Serial GC | JDK3 | 單線程方式進行垃圾回收,暫停所有應用線程 | 適用于小型應用,單CPU的系統或者不需要高并發的場景 |
ParNew GC | JDK3 | Serial GC多線程實現 | 年輕代垃圾回收 |
Parallel GC | JDK4 | 利用多CPU、多核心的系統資源,提高垃圾回收效率 | 對吞吐量有要求的應用場景,如數據處理、科學計算等 |
CMS GC | JDK5 | 采用多線程方式進行垃圾回收,能夠縮短應用程序的暫停時間 | 適用于對響應時間有要求的應用場景 |
G1 GC | JDK7 | 內存劃分為一個個Region,可以指定停頓時間 | 適用于部署早多核CPU大內存機器上的大型應用,對停頓時間有一定要求, |
ZGC | JDK11 | 支持超大堆空間,最大停頓時間不超過10ms | 業務對于停頓時間低于100ms |
總結
任何技術上的優化都是建立在對技術原理的深刻理解基礎之上的,JVM調優亦是如此。文章中常見的JVM調優手段只不過是一些術,搞懂JVM的運行原理以及垃圾回收機制才是關鍵。另外在調優前我們得先搞清楚我們調優的目標是什么,有了目標的指引,我們才能做到有的放矢。其實無論是性能優化還是業務優化其實都是有一定的規律可以摸索,萬變不離其宗,都是通過觀:觀察當前是個什么樣的狀態;析:分析整條業務鏈路找到可以優化的方向以及改造點;優:動手制定優化策略以及驗證方法進行優化實操。