本文是 Java 效能優化系列的第一部分。第二部分 · 第三部分即將推出。
幾週前,我在 DevNexus 大會上進行演講時,建構了一個 Java 訂單處理應用程式。該應用程式可以正常運作,測試也通過了。我執行了負載測試並收集了 Java Flight Recording (JFR) 資料。
在進行任何變更之前:經過時間 1,198 毫秒,每秒處理 85,000 個訂單,峰值堆積使用量略高於 1GB,垃圾回收 (GC) 暫停 19 次。
變更之後:239 毫秒。每秒處理 419,000 個訂單。堆積使用量 139MB。GC 暫停 4 次。
這是同一個應用程式。相同的測試。相同的 JDK。沒有架構上的變更。當你考慮到像這樣的程式碼在生產環境中並非運行在單一伺服器上,而是運行在一個伺服器集群中時,這些數字的意義就更加重大了。
在第二部分中,我將逐步介紹這些數字背後的分析數據:火焰圖、哪些方法是真正的熱點,以及修復它們後發生了什麼變化。在我們深入探討之前,你需要了解我們實際修復了哪些類型的問題。
這些問題是真實程式碼庫中經常出現的模式。它們可以順利編譯,在程式碼審查中被忽略,而且如果不透過效能分析工具告訴你該往哪裡看,你可能會錯過它們。以下是其中八個。
總結:修復像這樣的反模式,將一個需要 1,198 毫秒的 Java 應用程式轉變為只需要 239 毫秒的應用程式。以下是一些值得尋找和修復的模式:
修復後:吞吐量提升 5 倍,堆積使用量減少 87%,GC 暫停減少 79%。相同的應用程式,相同的測試,相同的 JDK。
這段程式碼看起來不錯,對吧?問題在於 String 不可變性在實際應用中的含義。
每次你使用 + 時,Java 都會創建一個全新的 String 物件,它是所有先前內容的完整副本,並附加了新的部分。舊的 String 物件會被丟棄。這種情況在每次迭代中都會發生。
複製字元的複雜度是 O(n²)。如果你有 10,000 行,第一次迭代複製的內容幾乎為零,第 5,000 次迭代複製大約 5,000 個字元的累積內容,第 10,000 次迭代則複製所有內容。BellSoft 運行了 JMH 基準測試,結果顯示當 n 增加 4 倍時,迴圈串接版本的速度會減慢 7 倍以上,遠遠超過線性增長。
StringBuilder 則基於單一可變字元緩衝區工作。一次配置。每次 append 操作都會寫入該緩衝區。最後再進行一次 toString()。
注意:自 JDK 9 起,編譯器足夠聰明,可以優化單行程式碼中的 "Order: " + id + " total: " + amount。但這種優化不會延伸到迴圈內部。在迴圈內部,你仍然會在每次迭代中創建並丟棄一個新的 StringBuilder。你必須在迴圈之前自行宣告它,就像上面的修復所示。
這看起來很合理。你正在按小時對訂單進行分組。但看看正在發生什麼:對於每個訂單,你都會遍歷整個列表來計算有多少訂單屬於同一小時。如果你有 10,000 個訂單,那就是 10,000 次迭代乘以 10,000 個串流元素。這相當於 1 億次比較,而這本來應該只需要一次遍歷。
在我的示範應用程式中,這種確切的模式是最大的 CPU 熱點。它佔據了 JFR 記錄中近 71% 的 CPU 堆疊樣本。
一次遍歷。O(n)。每個訂單直接增加其所屬小時的計數。你也可以使用 Collectors.groupingBy(... Collectors.counting()) 在單一串流管道中完成,但合併方法更清晰,並且完全避免了創建串流的開銷。
如果你在迴圈主體內看到 .stream() 呼叫,這是一個需要暫停並檢查是否正在執行重複工作的信號。
String.format() 傾向於被推薦為建構字串的一種乾淨、易讀的方式。是的,它易於閱讀,而且當你頻繁呼叫它時,它是 Java 中最慢的字串建構選項。
Baeldung 對 Java 中所有字串串接方法進行了 JMH 基準測試。在每個類別中,String.format() 都排在最後。它必須在每次呼叫時解析格式字串,運行基於正規表達式的 token 匹配,並透過完整的 java.util.Formatter 機制進行分派。StringBuilder 一直是最快的。
在你需要數值格式化的情況下使用 String.format(),並讓編譯器優化其餘部分。或者,如果你需要完全控制,就使用 StringBuilder。
String.format() 適用於設定載入、啟動程式碼、錯誤訊息,以及任何不頻繁執行的情況。將它移出任何你的效能分析工具標記為熱點的程式碼。
JVM 層面實際發生的情況是:
每次迭代都會將 sum 解箱 (unbox) 成一個 long,進行加法運算,然後將結果裝箱 (box) 回一個新的 Long 物件。如果有一百萬個元素,你就創建了一百萬個 Long 物件,需要 GC 來清理。在 64 位元 JVM 上,每個 Long 物件大約佔用堆積 16 位元組。這相當於 16MB 的堆積變動,而這本來應該是一個簡單的加法迴圈。
這種情況經常出現在:聚合和處理迴圈。加總指標、累積計數器、建構統計數據。裝箱型別會悄悄地滲透進來,因為有人在某個上游的集合簽章中使用了 Long,而沒有人考慮到它在迴圈中會付出多少代價。這確實很容易被忽略。
注意觀察 Integer、Long 或 Double 被用作區域迴圈變數或累加器。同時也要注意在經常呼叫的程式碼中出現 List<Long> 和 Map<String, Integer>。每次 .get() 和 .put() 都涉及一次裝箱/解箱的往返,而你卻在默默地為此付出代價。
如果此方法在一個緊密的迴圈中被呼叫,並且有相當比例的輸入是非數值,那麼你就存在一個效能問題,而這個問題可能看起來不像問題。
昂貴的部分是 Throwable.fillInStackTrace(),它在每次創建例外時都會在 Throwable 建構子中運行。它透過原生方法遍歷整個呼叫堆疊,並將其具體化為 StackTraceElement 物件。呼叫堆疊越深,成本越高。想像一下在 Spring 這樣的框架中,呼叫堆疊可能變得非常深。Netty 專案的 Norman Maurer 對此進行了基準測試,結果差異顯著。Baeldung 的 JMH 結果顯示,拋出例外會使方法的運行速度比正常返回路徑慢數百倍。
這不是理論。有一個真實的生產案例,一個 Scala/JVM 範本系統在發現每次渲染範本的欄位時都會拋出 NumberFormatException,將回應時間縮短了 3 倍。每次測試欄位名稱是否為數值索引時,都會拋出例外。
或者,如果你的類別路徑中已經有 Apache Commons Lang,可以使用 NumberUtils.isParsable()。
更新:幾位 HN 評論者正確地指出,上面的修復最初沒有包含 try-catch,這意味著溢位值和像單獨的 "-" 這樣的邊界情況會拋出未處理的例外。已更新,以在最後的 parseInt 周圍保留一個 try-catch 作為安全網。預先驗證仍然可以避免對絕大多數錯誤輸入產生昂貴的例外路徑,這才是重點。
原則是:如果無效輸入是你的應用程式中的常規情況,例如使用者提供的資料、外部饋送,任何你無法完全控制的內容,請明確進行預先驗證。例外情況適用於真正意外的條件,而不是用於「此格式可能錯誤」。
共享的可變狀態需要保護。但對整個方法進行 synchronized 意味著一次只能有一個執行緒呼叫任一方法。在處理真實並發的服務中,每個呼叫 increment() 的執行緒都會排隊等待其他所有執行緒完成。鎖本身就成為了瓶頸。
ConcurrentHashMap 在不鎖定整個結構的情況下處理並發讀寫。LongAdder 是專為高並發遞增而設計的。它將計數器分散到內部單元中,在爭用情況下效能優於 AtomicLong。
值得單獨提出:Collections.synchronizedMap() 包裝器存在相同的廣泛鎖定問題,整個地圖只有一個鎖。ConcurrentHashMap 幾乎總是正確的替代方案。
ObjectMapper 是最常見的物件範例之一,它看起來創建成本不高,但實際上並非如此。創建它涉及模組發現、序列化器快取初始化和設定載入。每次呼叫都會產生實際的工作。
相同的模式還有 DateTimeFormatter.ofPattern("...")、new Gson()、newXmlMapper()。它們都被設計為一次建構並重複使用。在熱點方法中創建它們意味著每次呼叫都要支付設定成本。
ObjectMapper 在配置後是執行緒安全的,因此共享一個靜態 final 實例是沒問題的。DateTimeFormatter 的內建類別,如 DateTimeFormatter.ISO_LOCAL_DATE,本身就是單例。如果你在熱點方法中呼叫 DateTimeFormatter.ofPattern("..."),請將其移至一個常量。
啟發式規則:如果一個物件的建構子執行大量設定工作,並且該物件在建構後是無狀態的(或可以安全共享),那麼它應該是一個欄位或常量,而不是局部變數。
如果你開始使用虛擬執行緒(在 Java 21 中作為生產功能引入),那麼包含這一點是值得的。
虛擬執行緒透過掛載到一小池稱為載波執行緒的平台(OS)執行緒來工作。當虛擬執行緒阻塞時,例如等待 I/O,排程器會將其從載波上卸載,釋放該載波以運行其他任務。這就是虛擬執行緒的全部擴展性故事。
但有一個陷阱。當虛擬執行緒進入 synchronized 區塊並在其中遇到阻塞操作時,它無法被卸載。它會釘住載波執行緒。這個平台執行緒現在被卡住等待,無法為其他虛擬執行緒提供服務,直到阻塞操作完成為止。
如果這種情況頻繁發生,你所有的載波執行緒都會被釘住,你的應用程式就會停滯,即使有數千個虛擬執行緒在等待工作。Netflix 在生產環境中遇到了完全相同的情況,並撰寫了一篇文章來調試它。
JFR 實際上會告訴你何時發生這種情況。當虛擬執行緒在被釘住時阻塞時,jdk.VirtualThreadPinned 事件就會觸發,預設情況下,它只在操作時間超過 20 毫秒時觸發,因此它已經過濾掉了真正重要的情況。
ReentrantLock 不使用 OS 層級的物件監視器,因此 JVM 在虛擬執行緒阻塞時可以正常將其卸載,而不是將其釘住到載波上。
JDK 24 注意:JEP 491 在 Java 24 中發布,很大程度上解決了這個問題。在 JDK 24+ 上,synchronized 在大多數情況下不再導致釘住。如果你仍然使用 21、22 或 23 版本,這仍然是相關的,值得用 JFR 檢查。如果你使用 24 版本,對於 synchronized,你大多不必擔心,儘管原生方法呼叫仍然可能導致釘住。
這些模式中的任何一種都不會導致你的應用程式崩潰。它們不會拋出例外或產生錯誤的結果。它們只是讓一切變慢一點,消耗更多記憶體,並且擴展性不如預期。
在沒有效能分析的情況下,它們之所以難以找到,是因為它們中的任何一個在你的程式碼庫中可能完全無害。迴圈中在啟動時只運行一次的字串串接不會讓你付出任何代價。在每天呼叫兩次的工具類別中使用 String.format() 是可以的。問題在於當這些模式出現在熱點路徑中時,也就是在每次請求、每次事件、主要處理迴圈的每次迭代中運行的程式碼。
在我的示範應用程式中,類似這樣的模式和其他模式將一個 239 毫秒的操作變成了一個 1,198 毫秒的操作,並將堆積使用量從 139MB 推高到 1GB 以上。沒有單一模式是災難性的。但修復了堆積壓力後,GC 暫停從 19 次下降到 4 次。修復了爭用後,新的熱點變得可見,這些熱點以前被噪音掩蓋了。分析圖的形狀發生了變化。
這些改進不僅僅局限於單一應用程式。其中一些優化在單一實例上或在測試套件運行時間上看到的小幅改進時,可能顯得微不足道。但現實世界的 Java 程式碼通常不是運行在一個伺服器上。在生產環境中,有應用程式運行在一個伺服器集群中,處理大量真實客戶請求。在一個主機上節省幾毫秒或減少堆積壓力的改進,實際上是在數千個主機上同時發生的。在這個規模下,總體差異是驚人的。考慮到吞吐量改進和潛在的伺服器數量縮減,成本影響可能非常顯著。
這種級聯效應是我在第二部分中直接在 JDK Mission Control 中展示的。你將看到變更前的火焰圖,然後是第一輪修復後的樣子,以及圖像如何不斷變化。在第三部分中,我們將探討自動化識別和實施效能改進的過程。
如果這些模式中有任何一種看起來很熟悉,請等到看到火焰圖的樣子。我在 LinkedIn 上。第二部分:一個方法使用了 71% 的 CPU。這是火焰圖。