1. 程式人生 > >35、JVM優化Java程式碼時都做了什麼?

35、JVM優化Java程式碼時都做了什麼?

我在專欄上一講介紹了微基準測試和相關的注意事項,其核心就是避免 JVM 執行中對 Java 程式碼的優化導致失真。所以,系統地理解 Java 程式碼執行過程,有利於在實踐中進行更進一步的調優。

今天我要問你的問題是,JVM 優化 Java 程式碼時都做了什麼?

與以往我來給出典型回答的方式不同,今天我邀請了隔壁專欄《深入拆解 Java 虛擬機器》的作者,同樣是來自 Oracle 的鄭雨迪博士,讓他以 JVM 專家的身份去思考並回答這個問題。

來自 JVM 專欄作者鄭雨迪博士的回答

JVM 在對程式碼執行的優化可分為執行時(runtime)優化和即時編譯器(JIT)優化。執行時優化主要是解釋執行和動態編譯通用的一些機制,比如說鎖機制(如偏斜鎖)、記憶體分配機制(如-TLAB)等。除此之外,還有一些專門用於優化解釋執行效率的,比如說模版直譯器、內聯快取(inline cache,用於優化虛方法呼叫的動態繫結)。

JVM 的即時編譯器優化是指將熱點程式碼以方法為單位轉換成機器碼,直接執行在底層硬體之上。它採用了多種優化方式,包括靜態編譯器可以使用的如方法內聯、逃逸分析,也包括基於程式執行 profile 的投機性優化(speculative/optimistic optimization)。這個怎麼理解呢?比如我有一條 instanceof 指令,在編譯之前的執行過程中,測試物件的類一直是同一個,那麼即時編譯器可以假設編譯之後的執行過程中還會是這一個類,並且根據這個類直接返回 instanceof 的結果。如果出現了其他類,那麼就拋棄這段編譯後的機器碼,並且切換回解釋執行。

當然,JVM 的優化方式僅僅作用在執行應用程式碼的時候。如果應用程式碼本身阻塞了,比如說併發時等待另一執行緒的結果,這就不在 JVM 的優化範疇啦。

 

考點分析

感謝鄭雨迪博士從 JVM 的角度給出的回答。今天這道面試題在專欄裡有不少同學問我,也是會在面試時被面試官刨根問底的一個知識點,鄭博士的回答已經非常全面和深入啦。

大多數 Java 工程師並不是 JVM 工程師,知識點總歸是要落地的,面試官很有可能會從實踐的角度探討,例如,如何在生產實踐中,與 JIT 等 JVM 模組進行互動,落實到如何真正進行實際調優。

在今天這一講,我會從 Java 工程師日常的角度出發,側重於:

  •   從整體去了解 Java 程式碼編譯、執行的過程,目的是對基本機制和流程有個直觀的認識,以保證能夠理解調優選擇背後的邏輯。
  •   從生產系統調優的角度,談談將 JIT 的知識落實到實際工作中的可能思路。這裡包括兩部分:如何收集 JIT 相關的資訊,以及具體的調優手段。

 

知識擴充套件

首先,我們從整體的角度來看看 Java 程式碼的整個生命週期,你可以參考我提供的示意圖。


我在專欄第 1 講就已經提到過,Java 通過引入位元組碼這種中間表達方式,遮蔽了不同硬體的差異,由 JVM 負責完成從位元組碼到機器碼的轉化。

通常所說的編譯期,是指 javac 等編譯器或者相關 API 等將原始碼轉換成為位元組碼的過程,這個階段也會進行少量類似常量摺疊之類的優化,只要利用反編譯工具,就可以直接檢視細節。

javac 優化與 JVM 內部優化也存在關聯,畢竟它負責了位元組碼的生成。例如,Java 9 中的字串拼接,會被 javac 替換成對 StringConcatFactory 的呼叫,進而為 JVM 進行字串拼接優化提供了統一的入口。在實際場景中,還可以通過不同的策略選項來干預這個過程。

今天我要講的重點是JVM 執行時的優化,在通常情況下,編譯器和直譯器是共同起作用的,具體流程可以參考下面的示意圖。


JVM 會根據統計資訊,動態決定什麼方法被編譯,什麼方法解釋執行,即使是已經編譯過的程式碼,也可能在不同的執行階段不再是熱點,JVM 有必要將這種程式碼從 Code Cache 中移除出去,畢竟其大小是有限的。

就如鄭博士所回答的,直譯器和編譯器也會進行一些通用優化,例如:

  •   鎖優化,你可以參考我在專欄第 16 講提供的直譯器執行時的原始碼分析。
  •   Intrinsic 機制,或者叫作內建方法,就是針對特別重要的基礎方法,JDK 團隊直接提供定製的實現,利用匯編或者編譯器的中間表達方式編寫,然後 JVM 會直接在執行時進行替換。

這麼做的理由有很多,例如,不同體系結構的 CPU 在指令等層面存在著差異,定製才能充分發揮出硬體的能力。我們日常使用的典型字串操作、陣列拷貝等基礎方法,Hotspot 都提供了內建實現。

而即時編譯器(JIT),則是更多優化工作的承擔者。JIT 對 Java 編譯的基本單元是整個方法,通過對方法呼叫的計數統計,甄別出熱點方法,編譯為原生代碼。另外一個優化場景,則是最針對所謂熱點迴圈程式碼,利用通常說的棧上替換技術(OSR,On-Stack體 Replacement,更加細節請參考R 大的文章),如果方法本身的呼叫頻度還不夠編譯標準,但是內部有大的迴圈之類,則還是會有進一步優化的價值。

從理論上來看,JIT 可以看作就是基於兩個計數器實現,方法計數器和回邊計數器提供給 JVM 統計資料,以定位到熱點程式碼。實際中的 JIT 機制要複雜得多,鄭博士提到了逃逸分析、迴圈展開、方法內聯等,包括前面提到的 Intrinsic 等通用機制同樣會在 JIT 階段發生。

 

第二,有哪些手段可以探查這些優化的具體發生情況呢?

專欄中已經陸陸續續介紹了一些,我來簡單總結一下並補充部分細節。

  • 列印編譯發生的細節。
-XX:+PrintCompilation
  • 輸出更多編譯的細節。
-XX:UnlockDiagnosticVMOptions -XX:+LogCompilation -XX:LogFile=<your_file_path>

JVM 會生成一個 xml 形式的檔案,另外, LogFile 選項是可選的,不指定則會輸出到

hotspot_pid<pid>.log

具體格式可以參考 Ben Evans 提供的JitWatch工具和分析指南。

  •   列印內聯的發生,可利用下面的診斷選項,也需要明確解鎖。
-XX:+PrintInlining
  • 如何知曉 Code Cache 的使用狀態呢?

很多工具都已經提供了具體的統計資訊,比如,JMC、JConsole 之類,我也介紹過使用 NMT 監控其使用。

 

第三,我們作為應用開發者,有哪些可以觸手可及的調優角度和手段呢?

  •   調整熱點程式碼門限值

我曾經介紹過 JIT 的預設門限,server 模式預設 10000 次,client 是 1500 次。門限大小也存在著調優的可能,可以使用下面的引數調整;與此同時,該引數還可以變相起到降低預熱時間的作用。

-XX:CompileThreshold=N

很多人可能會產生疑問,既然是熱點,不是早晚會達到門限次數嗎?這個還真未必,因為 JVM 會週期性的對計數的數值進行衰減操作,導致呼叫計數器永遠不能達到門限值,除了可以利用 CompileThreshold 適當調整大小,還有一個辦法就是關閉計數器衰減。

-XX:-UseCounterDecay

如果你是利用 debug 版本的 JDK,還可以利用下面的引數進行試驗,但是生產版本是不支援這個選項的。

-XX:CounterHalfLifeTime
  • 調整 Code Cache 大小

我們知道 JIT 編譯的程式碼是儲存在 Code Cache 中的,需要注意的是 Code Cache 是存在大小限制的,而且不會動態調整。這意味著,如果 Code Cache 太小,可能只有一小部分程式碼可以被 JIT 編譯,其他的程式碼則沒有選擇,只能解釋執行。所以,一個潛在的調優點就是調整其大小限制。

-XX:ReservedCodeCacheSize=<SIZE>

當然,也可以調整其初始大小。

-XX:InitialCodeCacheSize=<SIZE>

注意,在相對較新版本的 Java 中,由於分層編譯(Tiered-Compilation)的存在,Code Cache 的空間需求大大增加,其本身預設大小也被提高了。

  •   調整編譯器執行緒數,或者選擇適當的編譯器模式

JVM 的編譯器執行緒數目與我們選擇的模式有關,選擇 client 模式預設只有一個編譯執行緒,而 server 模式則預設是兩個,如果是當前最普遍的分層編譯模式,則會根據 CPU 核心數目計算 C1 和 C2 的數值,你可以通過下面的引數指定的編譯執行緒數。

-XX:CICompilerCount=N

在強勁的多處理器環境中,增大編譯執行緒數,可能更加充分的利用 CPU 資源,讓預熱等過程更加快速;但是,反之也可能導致編譯執行緒爭搶過多資源,尤其是當系統非常繁忙時。例如,系統部署了多個 Java 應用例項的時候,那麼減小編譯執行緒數目,則是可以考慮的。

生產實踐中,也有人推薦在伺服器上關閉分層編譯,直接使用 server 編譯器,雖然會導致稍慢的預熱速度,但是可能在特定工作負載上會有微小的吞吐量提高。

  •   其他一些相對邊界比較混淆的所謂“優化”

比如,減少進入安全點。嚴格說,它遠遠不只是發生在動態編譯的時候,GC 階段發生的更加頻繁,你可以利用下面選項診斷安全點的影響。

-XX:+PrintSafepointStatistics ‑XX:+PrintGCApplicationStoppedTime

注意,在 JDK 9 之後,PrintGCApplicationStoppedTime 已經被移除了,你需要使用“-Xlog:safepoint”之類方式來指定。

很多優化階段都可能和安全點相關,例如:

  •   在 JIT 過程中,逆優化等場景會需要插入安全點。
  •   常規的鎖優化階段也可能發生,比如,偏斜鎖的設計目的是為了避免無競爭時的同步開銷,但是當真的發生競爭時,撤銷偏斜鎖會觸發安全點,是很重的操作。所以,在併發場景中偏斜鎖的價值其實是被質疑的,經常會明確建議關閉偏斜鎖。
-XX:-UseBiasedLocking

主要的優化手段就介紹到這裡,這些方法都是普通 Java 開發者就可以利用的。如果你想對 JVM 優化手段有更深入的瞭解,建議你訂閱 JVM 專家鄭雨迪博士的專欄。

 

一課一練

關於今天我們討論的題目你做到心中有數了嗎? 請思考一個問題,如何程式化驗證 final 關鍵字是否會影響效能?