一次 Javac 編譯速度緩慢的 JDK Bug 定位

背景

Flink 提供了從 Tuple0 ~ Tuple25 的 Tuple 類供用戶選擇,顧名思義,每個 Tuple 對象分別可以存儲 0 個 ~ 25 個任意類型的欄位,例如圖 1 展示了 Tuple2 的類定義。由於騰訊雲 Oceanus 流計算的客戶業務場景較為複雜,需要用到更高維度的固定 Tuple 類,我們將 Tuple 類進一步擴展,達到了 Tuple250 甚至 Tuple500.

圖 1:Tuple2 的類定義,有著 f0、f1 兩個泛型欄位

但是,隨著 Tuple 維度的增多,我們觀察到了一個詭異的現象:雖然需要編譯的源碼文件增加個數不多,但是編譯所需時間越來越長,且並非線性增長:原本只需要一分鐘就可以完成的編譯,現在需要動輒一個多小時;如果在本機進行編譯,甚至幾個小時都編譯不完。這給我們的開發效率帶來了一定程度的影響,因此有必要找出問題根源。

初探

為了找出 Tuple 數與編譯時間的關係,我們還寫了一個自動化腳本,每次向源碼里增加 1 個更高維度的 Tuple 類(例如依次放入 Tuple26.java、Tuple27.java 等等),觀察項目的構建速度,並繪製了如下的曲線(圖中公式使用 Excel 的趨勢線進行擬合),見下圖 2:

圖 2:Tuple 總數與編譯時間的關係

可以看到,編譯時間隨 Tuple 數變化的曲線,完美符合三次函數,即該演算法的時間複雜度約為 O(n^3)。如此高的時間複雜度,一定要找出根源,否則隨著業務規模的進一步擴大,編譯時間會越來越難以接受。

為了解決這個問題,我們首先想到的是使用 Profiling 工具進行熱點和調用時長的統計分析。這裡選擇了 JProfiler,它提供了很多有用的分析視圖,可以迅速找到問題的直接根源。

首先我們對編譯緩慢的項目啟動編譯構建,默認情況下是基於 Maven 的,因此需要找出是不是 Maven 導致的問題。我們採用的 JDK 版本是 1.8.0_202.

圖 3:使用 Sampling 模式對 Maven 編譯的進程進行取樣

首先我們使用 JProfiler 的 Sampling 模式進行取樣(如圖 3),它的效果類似於不斷地運行 jstack 命令,不進行侵入式的修改,因此得到的數據較為準確;另一種 Instrumentation 模式適合於找到問題的熱點後,使用 JVMTI 動態修改位元組碼機制(線上定位神器 btrace 也是基於這個原理),進行局部的細緻分析。需要注意的是,默認情況下取樣排除了 JVM 內部的調用,我們由於需要定位 JDK 的問題,需要在 Call tree filters 里把所有的排除規則清空,否則問題只能定位到 Maven 這一層。

當程式運行一段時間後,我們找出了熱點方法(見圖 4),即 javac 編譯起內部的 List 相關調用;通過仔細追蹤調用鏈,發現是 checkWithinBounds 方法過於緩慢。

圖 4:找出熱點方法

既然熱點方法找到了,那麼下面就需要探究這個方法在 javac 編譯器中是做什麼的,它的演算法為什麼這麼慢,以及是否有優化的方式。

詳細定位

由於調用鏈里有 Infer 類,我們知道它是負責泛型的類型推斷的。通過搜索泛型編譯緩慢等關鍵字,找到了 JDK-8086048 這個 Bug 單,同時在 JDK-8080656 這裡也有提到同樣的問題。

隨後我們又跟蹤到了 JDK-8051946JEP-215。在這個 2014 年就提出的 JEP-215 中,開發者設計了一種新的 javac 方法類型檢測機制 TA(Tiered Attribution)來代替現有的 SA(Speculative Attribution),可以極大加速多態表達式(Poly Expression)的檢查過程。

通過閱讀這個 JEP(JDK Enhancement Proposal)的描述,可以知道目前的 SA 演算法需要在同一顆語法樹上,對多個不同的目標進行多次類型檢查,例如一個 多態表達式有 N 種重載選項,那麼就需要檢查 N * 3 + 1 次。如果參數還允許嵌套的話,那麼多個因子還會相乘,這樣就導致了我們上述遇到的很高的時間複雜度了。

而這個新的 TA 演算法提供了一種更高效的多態表達式類型檢查機制。例如省去了重載解析過程的類型檢查,並於重載解析前,為每一個方法調用過程中的多態參數表達式(poly argument expression)構造解析所需的自底向上結構化類型,以大大減少總的嘗試次數。

根據這些 Bug 單,JEP-215 已經在 JDK 9 及更高版本上得以實現。因此我們改用當前已發布的 LTS 版本 JDK 11 進行驗證。

通過修改 JAVA_HOME 環境變數,可以讓 Maven 選擇使用不同的 JDK 版本進行編譯,我們修改為 JDK 11 的路徑後,重新進行編譯,並再次進行取樣,結果發現類型推斷已經不再是佔用 CPU 最多的方法了(圖 5):

圖 5:改為 JDK 11 編譯時的熱點方法

同時我們欣喜地發現,整個項目只需要 1.5 分鐘就構建完畢了,相對之前的 1 個多小時,有了質的飛躍(圖 6):

圖 6:新版本 JDK 的編譯耗時

由此可見,這個 JEP-215 起到了立竿見影的效果,讓項目構建的時間恢復了往日的情景。