一、前言
在本文中,你將了解 OpenJDK HotSpot Java 虛擬機 (HotSpot JVM) 中的一些系統知識,以及如何調整它們以獲得最佳狀態適應你的程序和運行環境。
HotSpot JVM 是一項了不起且靈活的技術。它作為二進制版本適用於每個主要操作系統和 CPU 架構,從微型 Raspberry Pi Zero 一直到包含數百個 CPU 內核和 TB 級 RAM 的“大型”服務器。由於 OpenJDK 是一個開源項目,HotSpot JVM 幾乎可以針對任何其他系統進行編譯,並且可以使用選項、開關和標誌對其進行微調。
首先,這裡有一些背景。HotSpot JVM 的語言是字節碼。在撰寫本文時,有超過 30 種編程語言可以編譯成 HotSpot JVM 兼容的字節碼,但迄今為止最受歡迎的、在全球擁有超過 800 萬開發人員的當然是 Java。
Java 源代碼被編譯成字節碼(如圖 1 所示),以類文件的形式,使用 javac 編譯器。在現代開發中,這可能會被 Maven、Gradle 或基於 IDE 的編譯器等構建工具抽象掉。
圖 1. 編譯字節碼的過程
程序的字節碼錶示由 HotSpot JVM 在一個虛擬堆棧機上執行,該虛擬堆棧機知道多達256條不同的指令,每條指令由一個8位數字操作碼標識;因此,名稱是“字節碼”。
字節碼程序由解釋器執行,該解釋器獲取每條指令,將其操作數壓入堆棧,然後執行該指令,移除操作數並將結果留在堆棧中,如圖 2 所示。
圖 2. 解釋器執行字節碼後堆棧上的結果
將程序執行從底層環境中抽象出來,賦予了 Java “一次編寫,隨處運行”的可移植性優勢。在一種架構上編譯的類文件可以運行在完全不同架構的 HotSpot JVM 上執行。
如果你認為這種對底層硬件的抽象是以犧牲性能為代價的,那麼你是對的。這通常就是 HotSpot JVM 開關、選項和標誌的用武之地。
二、JIT 即時編譯
用可移植、功能豐富的高級語言(如Java)編寫的程序如何挑戰那些從“低級”、“不太友好”的編程語言(如C)編譯為特定於體系結構的本機代碼的程序的性能呢?
答案是 HotSpot JVM 包含了性能提升的即時(JIT)編譯技術,它可以分析程序的執行情況,並有選擇地優化它認為最有好處的部分。這些被稱為程序的熱點(因此,將其命名為 HotSpot JVM),它通過使用底層系統架構的知識動態地將它們編譯成本地代碼來實現這一點。
HotSpot JVM包含兩個 JIT 編譯器,稱為 C1(客戶端編譯器)和 C2(服務器編譯器),它們提供了不同的優化權衡。
- C1 提供了快速、簡單的優化。
- C2 提供了需要更多分析的高級優化,而且應用成本更高。
自 JDK 8 發布以來,默認行為一直是在稱為分層編譯的模式下同時使用這兩個編譯器,其中 C1 提供了快速的速度提升,而 C2 在進行高級優化之前收集了足夠的評測信息。生成的本機代碼存儲在熱點 JVM 的內存區域中,稱為代碼緩存,如圖3所示。
圖 3. Java 編譯過程
三、GC 垃圾回收
除了 JIT 技術之外,HotSpot JVM 還包括提高生產力和性能的功能,例如:多線程和自動內存管理以及垃圾收集 (GC) 策略的選擇。
對象被分配在 HotSpot JVM 的一個稱為堆的內存區域中,一旦這些對象不再被引用,垃圾收集器就可以將它們清理乾淨,並將它們使用的內存回收。
四、符合人體工程學的 HotSpot JVM
HotSpot JVM 具有如此多的靈活性和動態行為,你可能會擔心如何配置它以最好地滿足你的程序要求。幸運的是,對於很多用例,你不需要進行任何手動調整。HotSpot JVM 包含一個稱為 ergonomic(人體工程學)的過程,它在啟動時檢查執行環境,並根據 CPU 內核數量和可用 RAM 數量為 GC 策略、堆大小和 JIT 編譯器選擇一些合理的默認值。當前的默認值是:
- 垃圾收集器:G1 GC
- 初始堆:物理內存的 1/64
- 最大堆:物理內存的 1/4
- JIT 編譯器:同時使用 C1 和 C2 的分層編譯
通過使用選項 -XX:+PrintFlagsFinal 並使用 grep 命令搜索 ergonomic,你可以看到 HotSpot JVM 將為你的環境選擇的所有 ergonomic 默認值,如下所示:
java -XX:+PrintFlagsFinal | grep ergonomic
intx CICompilerCount = 4 {product} {ergonomic}
uint ConcGCThreads = 2 {product} {ergonomic}
uint G1ConcRefinementThreads = 8 {product} {ergonomic}
size_t G1HeapRegionSize = 2097152 {product} {ergonomic}
uintx GCDrainStackTargetSize = 64 {product} {ergonomic}
size_t InitialHeapSize = 526385152 {product} {ergonomic}
size_t MarkStackSize = 4194304 {product} {ergonomic}
size_t MaxHeapSize = 8403288064 {product} {ergonomic}
size_t MaxNewSize = 5041553408 {product} {ergonomic}
size_t MinHeapDeltaBytes = 2097152 {product} {ergonomic}
uintx NonNMethodCodeHeapSize = 5836300 {pd product} {ergonomic}
uintx NonProfiledCodeHeapSize = 122910970 {pd product} {ergonomic}
uintx ProfiledCodeHeapSize = 122910970 {pd product} {ergonomic}
uintx ReservedCodeCacheSize = 251658240 {pd product} {ergonomic}
bool SegmentedCodeCache = true {product} {ergonomic}
bool UseCompressedClassPointers = true {lp64_product} {ergonomic}
bool UseCompressedOops = true {lp64_product} {ergonomic}
bool UseG1GC = true {product} {ergonomic}
上面的輸出來自具有 32 GB RAM 的機器上的 JDK 11,因此初始堆設置為 32 GB 的 1/64(約 512 MB),最大堆設置為 32 GB 的 1/4(8 GB)。
五、自定義
如果你認為默認的設置不適合你的應用程序,很高興 HotSpot JVM 在每個領域都具有高度可配置性。
有三種主要類型的配置選項:
- 標準: 基本啟動選項,例如 -classpath 在 HotSpot JVM 實現中很常見。
- -X: 用於配置 HotSpot JVM 的通用屬性的非標準選項,例如控制最大堆大小 (-Xmx);不能保證所有 HotSpot JVM 實現都支持這些。
- -XX: 用於配置 HotSpot JVM 的高級屬性的高級選項。根據文檔,這些內容如有更改,恕不另行通知,但 Java 團隊有一個管理良好的流程來刪除它們。
六、-XX 選項
許多 -XX 選項可以進一步表徵如下:
Product. 這些是最常用的 -XX 選項。
Experimental. 這些是與 HotSpot JVM 中的實驗性功能相關的選項,這些功能可能尚未準備好投入生產。這些選項允許你嘗試新的 HotSpot JVM 功能,並且需要通過指定以下內容來解鎖它們:
-XX:+UnlockExperimentalVMOptions
例如,在 JDK 11 中使用 ZGC 垃圾收集器可以這樣開啟:
java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC
一旦一個實驗性功能準備好投入生產,控制它的選項就不再被歸類為實驗性的,不需要解鎖。ZGC 收集器成為 JDK 15 中的 Product 選項。
Manageable. 這些選項也可以在運行時通過 MXBean API 或其他 JDK 工具設置。例如,要在 HotSpot JVM 線程轉儲中顯示 java.util.concurrent 類持有的鎖,請使用:
java -XX:+PrintConcurrentLocks
Diagnostic. 這些選項與訪問有關 HotSpot JVM 的高級診斷信息有關。這些選項需要你使用以下內容才能使用:
-XX:+UnlockDiagnosticVMOptions
一個示例診斷選項是:
-XX:+LogCompilation
它指示 HotSpot JVM 輸出一個日誌文件,其中包含 JIT 編譯器所做的所有優化的詳細信息。你可以檢查此輸出以了解程序的哪些部分已優化,並確定程序中可能未按預期優化的部分。LogCompilation 輸出很詳細,但可以在 JITWatch 等工具中可視化,它可以告訴你有關方法內聯、逃逸分析、鎖省略和 HotSpot JVM 對你運行的代碼所做的其他優化。
Developmental. 這些選項允許配置和調試最高級的 HotSpot JVM 設置,並且在你訪問它們之前需要使用特殊的 HotSpot JVM 構建調試。
七、添加和刪除的選項
選項開關的添加和刪除是在 HotSpot JVM 中主要功能的到來或棄用之後進行的。這裡有一些值得注意的地方。
- 在 JDK 9 中,許多 -XX:+Print... 和 -XX:+Trace... 日誌選項被刪除並替換為 -Xlog 選項,用於控制由 JEP 158 引入的統一日誌記錄子系統。
- 在添加了實驗性 ZGC、Epsilon 和 Shenandoah 垃圾收集器的選項後,JDK11 中的選項數達到峰值,達到了驚人的 1504 個。
- 隨着並發標記掃描(CMS)垃圾收集器的刪除,JDK14 中的數據量大幅下降,如 JEP 363 中所述。
圖 4. 每個版本的 OpenJDK 中的選項總數(包括產品、實驗、診斷和開發)
表 1. 從 OpenJDK 17 中刪除的 OpenJDK 16 之前的 HotSpot JVM 選項
表 2. OpenJDK 17 新加入的 HotSpot JVM 選項
八、配置項的生命周期
那麼 HotSpot JVM 開發團隊如何管理選項的刪除呢?自 JDK 9 以來,刪除 -XX 選項的過程被擴展為三步過程:棄用、過時和過期,以向用戶發出大量警告,提示他們的 Java命令行可能很快需要更新。
讓我們看看 HotSpot JVM 如何對 -XX:+AggressiveOpts 選項作出的操作,該選項在 JDK 11 中被棄用,在 JDK 12 中被淘汰,最後在 JDK 13 中過期。
不推薦使用的選項。雖然可以支持這些選項,但會打印一條警告並讓你知道將來可能會刪除支持,例如:
./jdk11/bin/java -XX:+AggressiveOpts
OpenJDK 64-Bit Server VM warning: Option AggressiveOpts was deprecated in version 11.0 and will likely be removed in a future release.
過時的選項。這些選項雖然已被刪除,但在命令行上仍被接受。(程序)會打印一條警告,讓你知道這些選項將來可能不會被接受,例如:
./jdk12/bin/java -XX:+AggressiveOpts
OpenJDK 64-Bit Server VM warning: Ignoring option AggressiveOpts; support was removed in 12.0
過期的選項。 這些是不推薦使用或過時的選項,其 accept_until版本小於或等於當前JDK 版本。當這些選項在其過期的 JDK 版本中使用時,會打印一條警告,例如:
./jdk13/bin/java -XX:+AggressiveOpts
OpenJDK 64-Bit Server VM warning: Ignoring option AggressiveOpts; support was removed in 12.0
完全失敗(不可用)。 當你一旦使用了某個老版本 JDK 中過時的配置時,HotSpot JVM 將在通過該選項並打印警告後啟動失敗,例如:
./jdk14/bin/java -XX:+AggressiveOpts
Unrecognized VM option 'AggressiveOpts'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
令人遺憾的是,並不是所有的 option 都以這種有序的方式退場。例如,JDK 9 在引入統一日誌記錄和強大的 -Xlog 選項時放棄了對大量選項的支持,這在 Nicolai Palog 的博客中有詳細介紹。Java 文檔網站上還有一個頁面,參考:Convert GC Logging Flags to Xlog。
九、遷移到高版本的 JDK
那麼,你是否準備將 Java 啟動腳本命令遷移到更高版本的 JDK?也許你使用了統一啟動腳本,其中充滿了你不熟悉的選項和配置,並且擔心調整會影響應用程序的穩定性。
你可以使用 JaCoLine,Java 命令行檢查器來幫助你。粘貼命令,選擇目標平台,然後分析你的配置選項將如何工作。見圖 5。
圖 5. 使用 JaCoLine 分析命令行選項
十、JVM 參數配置建議
雖然在 HotSpot JVM 調優方面沒有一刀切的建議,但我相信肯定有一些選項可以幫助你更好地了解程序的執行並做出明智的配置選擇。
以下選項在 JDK 11 及更高版本中可用。我選擇這些開關是因為許多開發人員還沒有轉向更高版本的 Java。請記住,這些都是可選的;HotSpot JVM 的默認設置非常好。
首先,了解內存使用情況。 在 HotSpot JVM 中分配內存很便宜。垃圾收集成本是指當熱點JVM清理堆中不再需要的對象時,稍後以執行暫停的形式到期的消耗。
在提高應用程序性能和穩定性方面,了解代碼進行的堆分配以及由此產生的GC行為可能是最容易解決的問題,因為堆和GC配置以及應用程序的分配行為之間的不匹配會導致過度暫停,從而中斷應用程序的進程。
使用 JaCoLine Statistics 網頁確認配置堆和 GC 日誌記錄是 JaCoLine 檢查的所有命令行中最受歡迎的選項。
要配置堆,請考慮以下問題的答案:
- 正常情況下預期最大堆的內存使用量是多少?
- -Xmx 設置最大堆大小,例如:-Xmx8g。
- -XX:MaxRAMPercentage=n 將最大堆設置為總 RAM 的百分比。
- 你期望堆多快達到其最大值?
- -Xms 設置初始堆大小,例如:-Xms256m.
- -XX:InitialRAMPercentage=n 將最大堆設置為總 RAM 的百分比。
- 如果希望堆快速增長,可以將初始堆設置為更接近最大堆。
要處理 OutOfMemory 錯誤,需要考慮在應用程序內存不足時 HotSpot JVM 應該如何工作。
- -XX:+ExitOnOutOfMemoryError 告訴 HotSpot JVM 在出現第一個 OutOfMemory 錯誤時退出。如果 HotSpot JVM 將自動重新啟動,這會很有用。
- -XX:+HeapDumpOnOutOfMemoryError 通過將堆的內容轉儲到 java_pid.hprof 文件來幫助診斷內存泄漏。
- -XX:HeapDumpPath 定義 heap dump 路徑。
其次,選擇垃圾收集器。 大多數硬件上的 JDK 11 人體工程學過程將默認選擇 G1GC 收集器,但它不是 JDK 11 及更高版本中的唯一選擇。
其他可用的垃圾收集器是:
- -XX:+UseSerialGC 選擇串行收集器,它在單個線程上執行所有 GC 工作。
- -XX:+UseParallelGC 選擇並行(吞吐量)收集器,它可以使用多個線程執行壓縮。
- -XX:+UseConcMarkSweepGC 選擇 CMS 收集器。請注意,CMS 收集器在 JDK 9 中已被棄用,並在 JDK 14 中被刪除。
- -XX:+UnlockExperimentalVMOptions -XX:+UseZGC 選擇 ZGC 收集器(在 JDK 11 中是實驗性的,在 JDK 14 及更高版本中是標準功能;因此你不需要此開關)。
可以在 HotSpot Virtual Machine Garbage Collection Tuning Guide 中找到有關為你的應用程序選擇收集器的建議。這是 JDK 11 的文檔版本;如果你使用的是更高版本的 Java,請搜索更新的文檔。
為避免過早提升,請考慮你的應用程序是否以高分配率創建短期對象。這可能導致短期對象過早提升到老年代堆空間,在那裡它們將累積,直到需要完整的垃圾收集。
- -XX:NewSize=n 定義新生代的初始大小。
- -XX:MaxNewSize=n 定義新生代的最大大小。
- -XX:MaxTenuringThreshold=n 是一個對象在提升到老年代之前可以存活的最大新生代集合數。
要記錄內存使用情況和 GC 活動,請執行以下操作:
- 使用-XX:+UnlockDiagnosticVMOptions ‑XX:NativeMemoryTracking=summary ‑XX:+PrintNMTStatistics獲取 HotSpot JVM 退出時內存使用情況的完整細節。
- 使用以下命令啟用 GC 日誌記錄:
- -Xlog:gc 提供基本的 GC 日誌記錄。
- -Xlog:gc* 提供詳細的 GC 日誌記錄。
最後,了解 JIT 編譯器如何優化你的代碼。 一旦你對應用程序的 GC 停頓處於可接受的水平感到滿意,你就可以檢查 HotSpot JVM 的 JIT 編譯器是否正在優化你認為對性能很重要的程序部分。
啟用簡單的編譯日誌,如下所示:
- -XX:+PrintCompilation 將有關每個 JIT 編譯的基本信息打印到控制台。
- -XX:+UnlockDiagnosticVMOptions ‑XX:+PrintCompilation ‑XX:+PrintInlining 添加有關方法內聯的信息。
輸出示例:
java -XX:+PrintCompilation
77 1 3 java.lang.StringLatin1::hashCode (42 bytes)
78 2 3 java.util.concurrent.ConcurrentHashMap::tabAt (22 bytes)
78 3 3 jdk.internal.misc.Unsafe::getObjectAcquire (7 bytes)
80 4 3 java.lang.Object:: (1 bytes)
80 5 3 java.lang.String::isLatin1 (19 bytes)
80 6 3 java.lang.String::hashCode (49 bytes)
輸出中的項目(從左到右)如下:
PrintCompilation在《Java JIT 編譯器解釋 – 第 1 部分》文章中有說明。
將 JIT 信息記錄到控制台對於檢查方法是被 JIT 編譯還是內聯(或兩者)非常有用,但如果你想更深入地了解 JIT 優化,則需要啟用詳細的日誌記錄。
使用 -XX:+UnlockDiagnosticVMOptions ‑XX:+LogCompilation ‑XX:LogFile=jit.log 啟用詳細的編譯日誌記錄。它支持詳細的 XML 格式編譯日誌記錄,可以在 JITWatch 等工具中進行分析。你可以從 Ben Evans 的“使用 JITWatch 理解 Java JIT 編譯,第 1 部分”以及第 2 部分和第 3 部分中了解有關 JITWatch 的更多信息。