談談Java應用釋出時CPU抖動的最佳化

張哥說技術發表於2022-12-20

研究背景

通常情況下應用釋出或重啟時都存在cpu抖動飆高,甚至打滿的現象,這是由於應用啟動時,JVM重新進行類載入與物件的初始化,CPU在整個過程中需要進行比平時更多的編譯工作。同樣,閒魚的訊息系統在重新發布時經常有抖動的問題,如下圖顯示:日常情況下CPU使用率基本不超過20%,而每當應用重新發布時,伺服器的cpu使用率驟增至40%以上。本文正是為了減少這種抖動,進而保障應用釋出時的穩定性。

談談Java應用釋出時CPU抖動的最佳化

Java的編譯

釋出時CPU利用率的飆高很大程度上是編譯造成的,因此在處理問題之前,我們需要了解Java編譯的機制,這對於後續的理解很重要。如果已經該部分知識非常熟悉,則可以跳過本節直接閱讀第三部分。常見的編譯型語言如C++,通常會把程式碼直接編譯成CPU所能理解的機器碼來執行。然而為了實現“一次編譯,處處執行”的特性。

Java把編譯的過程分成兩個階段

  • • 先由javac編譯成通用的中間形式(位元組碼),該階段通常被稱為編譯期。

  • • 直譯器逐條將位元組碼解釋為機器碼來執行,該階段則屬於執行期。

為了最佳化Java位元組碼執行的效能 ,HotSpot在直譯器之外引入了JIT(Just In Time)即時編譯器,形成了用直譯器+JIT編譯器混合的執行引擎

二者會在執行期並肩作戰,但分工不同

  • • 直譯器(Interpreter):當程式需要迅速啟動時,使用直譯器解釋位元組碼,節省編譯的時間,快速執行。

  • • JIT編譯器(JIT Compiler):在程式啟動後並且長時間提供服務時,JIT將越來越多的程式碼編譯為本地機器碼,獲得更高的執行效率。

Java程式在JVM上執行的過程如下圖所示:

談談Java應用釋出時CPU抖動的最佳化

編譯期先由javac將原始碼編譯成位元組碼,在這個過程中會進行詞法分析、語法分析、語義分析等操作,該過程也被稱為前端編譯。當類載入完成,程式執行時,JVM會利用熱點程式碼計數器進行判斷,如果此時執行的程式碼是熱點程式碼則使用JIT,如果不是則使用直譯器。對於熱點程式碼的判斷方式有采樣估計和計數兩種方式,Hotspot採用計數方式,到達一定閾值時觸發編譯。

大多數情況下直譯器首先發揮作用,將位元組碼按條解釋執行。隨著時間推移,透過不斷對解釋的程式碼進行資訊採集,JIT逐漸發揮作用。把越來越多的位元組碼編譯最佳化為本地機器碼並儲存在CodeCache中,來獲取更高的執行效率。直譯器這時可以作為編譯執行的降級手段,在一些不可靠的編譯最佳化出現問題時,再切換回解釋執行,保證程式可以正常執行。JIT極大地提高了Java程式的執行速度,而且跟靜態編譯相比,即時編譯器可以選擇性地編譯熱點程式碼,省去了很多編譯時間,也節省很多的空間。

定位問題

線上診斷

Arthas 是阿里巴巴推出的一款免費的線上監控診斷產品,透過全域性視角實時檢視應用 load、記憶體、gc、執行緒的狀態資訊。首先,我們使用Arthas在預發環境下連線伺服器,進而對對常規時刻的CPU使用率進行監控,操作步驟如下:

下載:>> curl -O  啟動:>> java -jar arthas-boot.jar 看板:>> dashboard

dashboard皮膚顯示如下圖,可以看到此時的各執行緒所佔用的CPU。隨後,我們進行應用的釋出重啟,來觀察該過程的CPU使用率變化,下圖是常規時段的CPU利用率。

談談Java應用釋出時CPU抖動的最佳化

隨後我們開始釋出應用。開始釋出後不久搭建的ssh連結會被伺服器斷開,此時應用被停止。在連線斷開之前捕捉到了如下記錄,“VM Thread”等執行緒,佔用了一定的的CPU,但不是很多。

談談Java應用釋出時CPU抖動的最佳化

等到應用被重新啟動時,我們重新連線伺服器,此時需要再次啟動Arthas看板,來觀察各執行緒對CPU的使用,操作如下:

啟動:>> java -jar arthas-boot.jar 看板:>> dashboard

此時我們捕捉到了如下執行緒對CPU的佔用資訊。可以發現應用啟動時刻進行的C2編譯執行緒佔用了大量的CPU資源,導致CPU利用率激增。這輪編譯的佔用會在幾分鐘內逐步減弱,隨之透過監控看到,CPU使用率也逐步恢復正常。

談談Java應用釋出時CPU抖動的最佳化
談談Java應用釋出時CPU抖動的最佳化

 原因分析

在上述診斷過程中,我們定位到了兩類佔用CPU利用率較高的執行緒:

 1、在應用關閉時,出現了與JVM關閉相關的“VM Thread”。"VM Thread"在每一次關閉JVM時都會出現,然而在單純關機的時候監控並沒有顯示出CPU抖動,況且其佔用的CPU利用率在15%以內,故該類執行緒並非CPU利用率抖動的原因。

"VM Thread" 是JVM自身的一個執行緒, 它主要用來協調其它執行緒達到安全點,而在該時機,堆記憶體不發生修改. 被該執行緒執行的操作有:"stop-the-world" GC, 執行緒堆疊dumps, 執行緒掛起以及偏向鎖的revocation。

2、重新啟動後,我們觀察到了C1 ComplierThread和C2 ComplierThread執行緒。而時機也與效能監控的抖動時間剛好吻合,故可以確定是由於應用重啟,大量的程式碼被識別為熱點程式碼,觸發了JIT complier的編譯行為從而帶來了CPU利用率的飆高。

C1(Client Compiler)是一個簡單快速的編譯器,主要實現淺層的區域性最佳化,而放棄了需要花費大量時間精力的全域性深度最佳化,預設被觸發編譯的閾值為1500次。C2(Server Compiler)則是專門面向伺服器端的,執行時會收集更多資訊,花費更多時間,實現更為充分的全域性最佳化,被觸發編譯的閾值為10000次。

方法彙總

在對訊息系統釋出過程的診斷與分析之後,我們成功定位了問題——激進的JIT編譯。

隨後,基於我們對JVM啟動和JIT編譯的理解,我們在解決過程中調研並使用了五種完全不同的方法——分層編譯、codeCache利用、龍井預熱、逐步放開流量、調整JIT引數。接下來會對這些方法逐一進行介紹:

分層編譯

JIT常用的編譯方式有如下幾種:

  • • mixed:最常規的方式,先採用解釋方案執行程式碼,當程式碼執行到一定次數的時候,JIT編譯器才會進行編譯最佳化。編譯後的原生程式碼不需要JVM 虛擬機器進行解釋執行,效率會提高很多,當應用中的熱點程式碼都進行編譯最佳化後,程式碼的效能就會有很大的提升。

  • • full compilation:純編譯方式。在所有程式碼第一次執行的時候就能使用JIT編譯後的原生程式碼,後期提供服務時有著很高的效能。但是由於編譯本身是非常耗時的,因此也會導致應用在剛剛啟動的時候就進行大量的JIT編譯,CPU負載會驟增。

  • • tried compilation :分層編譯方式。與mixed方式類似,先採用直譯器解釋執行,熱點程式碼計數器到達一定閾值後開始進行JIT編譯。分層編譯最核心的是分層,即在編譯過程中使用多種編譯器,到達不同的閾值時會使用不同的編譯器。

談談Java應用釋出時CPU抖動的最佳化

分析:

Java的分層編譯可以漸進過渡的方式充分利用C1的靈活性和C2的深層最佳化,追求啟動速度和峰值效能的平衡。在Java8之前,我們需要透過JVM引數-XX:TireCompilation 來開啟分層編譯。而對於Java8及之後的應用分層編譯測試預設進行的。我們的應用基於Java8,因此已經開啟了分層編譯

codeCache

JIT編譯之所以能夠帶來效能的提升源於其將編譯好的機器碼儲存在了本地,而儲存的位置就是CodeCache

CodeCache是一塊獨立於 java 堆之外的記憶體區域,存放 jit 編譯的程式碼,也存放java所使用的本地方法程式碼以client模式或者是分層編譯模式執行的應用,C1編譯閾值比較低,更容易達到編譯標準,所以更容易耗盡codeCache。

透過Arthas的Dashborad,在應用執行期可以監控到codeCache的使用情況

談談Java應用釋出時CPU抖動的最佳化

透過JVM引數XX:+PrintCodeCache在 jvm 停止的時候列印出 codeCache 的使用情況。

談談Java應用釋出時CPU抖動的最佳化

size為codeCache的總容量, max_used 則為整個執行過程中codeCache的最大使用量。

透過JVM引數-XX:ReservedCodeCacheSize=256M設定Code Cache 的總容量上限。 具體的設定應根據監控資料估算,例如單位時間增長量、系統最長連續執行時間等。如果沒有相關統計資料,一種推薦的設定思路是設定為當前值(或者預設值)的2倍。但也不能佔用JVM過多的記憶體,即我們需要設定一個合理的codeCache大小,在保證應用正常執行的情況下減少記憶體使用。

分析:

當codeCache容量不足時,在JDK1.7.0_4之後預設開啟的回收機制是Speculative flushing。最早被編譯的方法將會被放到一個old列表中等待回收。在一定時間間隔內,如果old列表中方法沒有被呼叫,這個方法就會被從codeCache中清除,flushing操作則會帶來CPU使用率的飆高。因此我們需要對其容量進行觀測和調整。對於我們的訊息系統來說,codeCache使用百分比最高點在50%左右,並不會影響到JIT編譯的過程。

龍井預熱

作為全球最主要的Java使用者之一,阿里內部在OpenJdk的基礎上進行了擴充套件形成Ajdk,擁有更多的功能,而龍井(DragonWell)是Ajdk定製版的開源版本,供各界使用學習。這次用到的正是Ajdk的Jwarmup功能。

JwarmUp的基本原理:根據前一次程式執行的情況,記錄熱點程式碼以及類載入順序等資訊。在應用下一次啟動的時候積極主動地對相關類進行載入,並積極編譯相關程式碼,進而使得應用盡快使用上C2編譯最佳化的指令。從而在流量進來之前,提前完成類的載入、初始化和方法編譯, 跳過解釋階段, 直接執行編譯好的native code, 避免一面解釋執行一面後臺編譯帶來的CPU與load飆高, rt超時等問題。

談談Java應用釋出時CPU抖動的最佳化

使用步驟:

  • • 記錄編譯資訊階段

-XX:+CompilationWarmUpRecording  -XX:CompilationWarmUpLogfile=jwarmup.log  -XX:CompilationWarmUpRecordTime=300

記錄模式、記錄儲存的jwarmup.log,在5分鐘後生成profiling data

  • • 使用編譯資訊階段

-XX:+CompilationWarmUp  -XX:CompilationWarmUpLogfile=jwarmup.log  -XX:CompilationWarmUpDeoptTime=0

JWarmUp會在指定時間退最佳化warmup編譯的方法,設定CompilationWarmUpDeoptTime為0可以取消這個定時。

1、recording記錄下來的日誌,是怎麼分發到其他線上機的?

答:在應用啟動的指令碼檔案進行控制:

  • • 預熱節點,會將記錄下來的編譯資訊上傳到遠端伺服器oss上,

  • • 釋出節點,在啟動時從遠處機器主動pull下來預熱節點上傳的編譯資訊。

2、是怎麼制定一臺機器做recording的呢?是訪問某個url還是判斷beta機器?

答:是透過訪問oss做了一個類似於“檔案鎖”的東西,先拿到鎖的beta機器做為預熱節點,其餘機器為釋出節點。想要達到預熱的效果請確保:

  • • 釋出的機器的引數中有 -XX:+CompilationWarmUp

  • • 每次beta釋出後,記得檢查下編譯資訊檔案是否已經上傳

  • • beta釋出的那臺機器必須是有流量的,Recording時間不要太短,儘量多編譯一些方法。

如果不保證上述兩點的話,便無法完成預熱釋出,即沒有充分利用beta的編譯資訊,仍然走正常釋出的流程

分析:

jwarmup使用的場景如下圖藍色曲線所示:專案釋出階段,大量的解釋執行時把CPU佔滿,導致沒有足夠的CPU進行編譯,會導致CPU打滿並長時間在解釋執行,沒有機會編譯,CPU的利用率會長時間居高不下。而開啟了jwarmup後如下圖紅色曲線所示,大大縮短了編譯的時間。

談談Java應用釋出時CPU抖動的最佳化

對於我們訊息應用釋出cpu利用率抖動(CPU利用率在短時間內飆高)的問題,jwarmup並不能避免。即jwarmup能跳過解釋執行階段直接進入JIT編譯,而我們的應用CPU 飆高正是因為JIT過於激進。但是這種思路仍值得我們學習和借鑑。

逐步放開流量

透過控制釋出機器的流量大小, 用低流量來先去誘發JIT, 再把釋出機器的流量設定到正常水位, 避免在JIT過程中, 因為全量流量進來導致的CPU飈高、LOAD飈高、RT飈高等問題, 使得應用釋出或重啟時順滑平穩。

較為典型的是應用中的RPC服務,透過將專案中的HSF服務分批發布,逐步放開HSF呼叫的流量,可以減小由於大流量導致的JIT編譯,緩解c2 compiler執行緒驟增對CPU佔用過高的問題。

應用啟動後,利用閘道器的流量控制功能,按照時間間隔逐步放入流量,如:10%,20%...100%,或者給予不同的訪問權重,使得服務能夠逐漸到達正常訪問的熱度。例如,如果發現應用是重啟,則開啟流量分步載入策略,每當入口流量達到流量上限, 執行緒就Sleep下一秒,過後繼續放量。根據時間間隔,逐步放開流量限制

分析:

逐步放開流量時:透過預發機器效能監控可以看出,即使是在無流量的情景下,應用釋出時CPU仍會嚴重抖動,因此可以推斷出這次的抖動與入口流量並不強相關,故這種方式也本次試驗中也不是很適用。並且而在釋出時我們的中介軟體如HSF、diamond、notify等也會佔用少量CPU(10%左右),但相比於C2可以忽略不計。並且我們應用的RPC流量本就不是非常大,還未到達分層釋出的地步。這種方式更適用於線上上流量過高且不均勻的情況下使用。

談談Java應用釋出時CPU抖動的最佳化

調整JIT閾值

通常情況下,我們可以使用-XX:CompileThreshold=5000 修改JIT編譯閾值為5000。

注意:開啟分層編譯的情況下,-XX:CompileThreshold-XX:OnStackReplacePercentage中引數設定的閾值將會失效,觸發編譯會由以下公式中新的引數的條件來判斷:

談談Java應用釋出時CPU抖動的最佳化

滿足上述其中一個條件就會觸發即時編譯,i為呼叫次數,b是迴圈回邊次數,s是係數,並且JVM會根據當前的編譯方法數以及編譯執行緒數動態調整係數s。透過檢視JVM執行時的引數,我們可以看到相關的閾值引數如下:

談談Java應用釋出時CPU抖動的最佳化

JVM 系統的分層編譯支援5種級別

  • • Tier 0 - Interpertor 解釋執行

  • • Tier 1 - C1 no profiling

  • • Tier 2 - C1 limited profiling

  • • Tier 3 - C1 full profiling

  • • Tier 4 - C2

C1 是client compiler. C2是 server compiler.profiling就是收集能夠反映程式執行狀態的資料,分層編譯。下圖顯示了在我們將advanced JIT 閾值提升後取得了較好的效果

談談Java應用釋出時CPU抖動的最佳化
談談Java應用釋出時CPU抖動的最佳化
談談Java應用釋出時CPU抖動的最佳化
談談Java應用釋出時CPU抖動的最佳化

將上述閾值調高意味著提高即時編譯的門檻,將熱點程式碼的編譯工作分散開來,以防止某一時刻CPU的飆高。調整引數後可以發現,C2 CompilerThread在任意時刻對CPU的佔用率大幅下降(從原來動輒80%,90%變化到現如今20%左右)。這也讓Tomcat的啟動執行緒localhost-startStop-1的佔用顯得理所應當。

談談Java應用釋出時CPU抖動的最佳化

上圖是機器監控顯示的叢集點CPU利用率,紅色圈圈的部分是引數調優之後。幾個CPU利用率的峰值均下降了10%~15%,該方法在一定程度上緩解了抖動問題。

JIT編譯最佳化有分層機制,隨著Threshold的增加,C2的激進編譯得到了緩和,使得瞬時的CPU峰值下降,從而讓給業務執行緒更多的計算資源,以避免在應用釋出短時間內RT飆高。

但是該閾值並不是越高越好,C2雖然會佔用大量CPU,但是其目的是為位元組碼生成較為最佳化的本地機器碼,如果遲遲不能觸發,那麼在當請求到來時,系統仍執行著C1編譯的命令,甚至是直譯器解釋的結果,那麼必將會導致接下來一段時間的服務RT略高一些。

總結展望

針對Java應用啟動效能的最佳化涉及很多方面,本文提供了五種不同方法,這些方法基本可以解決大多數場景下CPU飆高的問題,方案及對應的使用場景總結如下:

談談Java應用釋出時CPU抖動的最佳化

對於Java應用,HotSpot本身有著非常多的機制可以利用。這也我們需要深入瞭解JVM原理,比如JIT編譯最佳化的方式原理,垃圾回收機制等,以便更敏銳地發現應用所存在的缺陷。實際上,上述的五個方法都是基於JVM層面上的最佳化,較為通用,也可以覆蓋多數場景。

除此之外,在未來我們還可以進行在應用層面上的最佳化,應用層面的最佳化需要深入瞭解我們應用的細節,具體到依賴了哪些模組,系統的瓶頸時段,哪些介面的QPS較高等。儘可能地減少單體應用的複雜度是最有效,最具針對性的方案。

對於不同的應用需要具體問題具體分析,做好足夠的調研和實驗。從而根據我們應用的特性地進行最佳化,提升系統的效能。以我們的訊息系統為例,其中還存在著RASP,本質上是javaagent(相當於JVM級別的AOP),在執行時進行的二次編譯部署存在著一部分開銷;大多數執行多年的系統中都存在著很多陳舊待廢棄的類與模組,這部分的影響也不得不考慮在內;最後,在CPU利用率最佳化時也要做出可能犧牲其他方面的考量與權衡,比如記憶體消耗、啟動速度、RT等。

參考資料

《深入理解Java虛擬機器》機械工業出版社 周志明

Java Developer's Guide https://docs.oracle.com/en/database/oracle/oracle-database/21/jjdev/Oracle-JVM-JIT.html

Arthas 使用手冊 

阿里巴巴龍井使用指南 %E9%98%BF%E9%87%8C%E5%B7%B4%E5%B7%B4Dragonwell8%E7%94%A8%E6%88%B7%E6%8C%87%E5%8D%97

OpenJDK8 HotSpot VM Options

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024922/viewspace-2927349/,如需轉載,請註明出處,否則將追究法律責任。

相關文章