作者|秦兵兵 & 宋志陽
一、摘要
本文從飛書 Android 升級 JDK 11 意外引發的 CI 構建性能劣化談起,結合高版本 JDK 在 Docker 容器和 GC 方面的新特性,深挖 JVM 和 Gradle 的源碼實現,抽絲剝繭地介紹了分析過程和修復方法,供其他升級 JDK 的團隊參考。
二、背景
最近飛書適配 Android 12 時把 targetSdkVersion 和 compileSdkVersion 改成了 31,改完后遇到了如下的構建問題。
在 StackOverflow 上有不少人遇到同樣的問題,簡單無侵入的解決方案是把構建用的 JDK 版本從 8 升到 11。
飛書目前用的 AGP 是 4.1.0,考慮到將來升級 AGP 7.0 會強制要求 JDK 11,而且新版 AS 已經做了鋪墊,所以就把構建用的 JDK 版本也升到了 11。
三、問題
升級后不少同學反饋子倉發組件(即發布 AAR)很慢,看大盤指標確實上漲了很多。
除了子倉發組件指標明顯上升,每周例行分析指標時發現主倉打包指標也明顯上升,從 17m上升到了 26m,漲幅約 50%。
四、分析
1.主倉打包和子倉發組件變成了單線程
子倉發組件指標和主倉打包指標,都在 06-17 劣化到了峰值,找了 06-17 主倉打包最慢的 10 次構建進行分析。
初步分析就有一個大發現:10 次構建都是單線程。
而之前正常的構建是并發的
子倉發組件的情況也一樣,由并發發布變成了單線程發布。
2.并發變單線程和升級 JDK 有關
查了下并發構建相關的屬性,org.gradle.parallel 一直為 true,并沒有更改。然后對比機器信息,發現并發構建用的是JDK 8,可用核心數是 96;單線程構建用的是 JDK 11,可用核心數是 1。初步分析,問題應該就在這里,從 JDK 8 升到 JDK 11 后,由并發構建變成了單線程構建,導致耗時明顯上升。而且升級 JDK 11 的修改是在 06-13 合入主干的,06-14 構建耗時明顯上升,時間上吻合。
3.整體恢復了并發,但指標沒下降
為了恢復并發構建,容易聯想到另一個相關的屬性 org.gradle.workers.max。
由于 PC 和服務器可用核心數有差異,為了不寫死,就試著在 CI 打包時動態指定了 --max-workers 參數。設置參數后主倉打包恢復了并發構建,子倉發組件也恢復了并發。
但觀察了一周大盤指標后,發現構建耗時并沒有明顯的回落,穩定在 25 m,遠高于之前 17 m的水平。
4.重點 Task 的耗時沒下降
細化分析,發現 ByteXTransform(ByteX是字節推出的基于 AGP Transform 的開源字節碼處理框架,通過把多個串行執行重復 IO 的 Transform 整合成一個 Transform 和并發處理 Class來優化 Transform 性能,詳見相關資料)和 DexBuilder 的走勢和構建整體的走勢一致,06-21 后都維持在高位,沒有回落。ByteXTransform 劣化了約 200 s,DexBuilder 劣化了約 200 s,而且這兩個 Task 是串行執行,合在一起劣化了約 400 s,接近構建整體的劣化9 m。GC 情況在 06-21 后也沒有好轉。
5.獲取 CPU 核心數的 API 有變化
進一步分析發現其他 Transform (由于歷史原因,有些 Transform 還沒有接入 ByteX)并沒有劣化,只有 ByteXTransform 明顯劣化了 200s。聯想到 ByteXTransform 內部使用了并發來處理 Class,而其他 Transform 默認都是單線程處理 Class,排查的同學定位到了一行可能出問題的代碼。
調試 DexBuilder 時發現核心邏輯 convertToDexArchive 也是并發執行。
再聯想到雖然使用 --max-workers 恢復了并發構建,但 OsAvailableProcessors 字段仍然為 1,而這個字段在源碼中是通過下面的 API 獲取的ManagementFactory.getOperatingSystemMXBean().getAvailableProcessors()
ManagementFactory.getOperatingSystemMXBean().getAvailableProcessors() 和Runtime.getRuntime().availableProcessors() 的效果一樣,底層也是 Native 方法。綜上推斷,可能是 JDK 11 的 Native 實現導致了獲取核心數的 API 都返回了 1,從而導致雖然構建整體恢復了并發,但依賴 API 進行并發設置的 ByteXTransform 和 DexBuilder 仍然有問題,進而導致這兩個 Task 的耗時一直沒有回落。
直接在 .gradle 腳本中調用這兩個 API 驗證上面的推斷,發現返回的核心數果然從 96 變成了 1。
另外有同學發現并不是所有的 CI 構建都發生了劣化,只有用 Docker 容器的 CI 構建發生了明顯的劣化,而 Linux 原生環境下的構建正常。所以獲取核心數的 Native 實現可能和 Docker 容器有關。
GC 劣化推斷也是同樣的原因。下面用 -XX:+PrintFlagsFinal 打印所有的 JVM 參數來驗證推斷??梢钥吹絾尉€程構建用的是 SerialGC,GC 變成了單線程,沒能利用多核優勢,GC 耗時占比高。并發構建用的是 G1GC,而且 ParallelGCThreads = 64,ConcGCThreads = 16(約是 ParallelGCThreads 的 1/4),GC 并發度高,兼顧 Low Pause 和 High Throughput,GC 耗時占比自然就低。
// 單線程構建時 GC 相關的參數值
bool UseG1GC = false {product} {default}
bool UseParallelGC = false {product} {default}
bool UseSerialGC = true {product} {ergonomic}
uint ParallelGCThreads = 0 {product} {default}
uint ConcGCThreads = 0 {product} {default}
// 并發構建時 GC 相關的參數值
bool UseG1GC = true {product} {ergonomic}
bool UseParallelGC = false {product} {default}
bool UseSerialGC = false {product} {default}
uint ParallelGCThreads = 63 {product} {default}
uint ConcGCThreads = 16 {product} {ergonomic}
6.Native 源碼分析
下面分析下 JDK 8 和 JDK 11 獲取可用核心數的 Native 實現,由于 AS 默認使用 OpenJDK,這里就用OpenJDK 的源碼進行分析。
JDK 8 實現
JDK 11 實現
JDK 11 默認沒有設置可用核心數并開啟了容器化,所以可用核心數由 OSContainer::active_processor_count() 決定。
查詢 Docker 環境下的 CPU 參數并代入計算邏輯,很容易得出可用核心數是 1,從而導致 Native 方法返回 1
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us
cat /sys/fs/cgroup/cpu/cpu.shares
五、修復
1.設置相關的 JVM 參數
總結上面的分析可知,問題的核心是在 Docker 容器默認的參數配置下 JDK 11 獲取核心數的 API 返回值有了變化。Gradle 構建時 org.gradle.workers.max 屬性的默認值、ByteXTransform 的線程數、DexBuilder 設置的 maxWorkers、OsAvailableProcessors 字段、GC 方式都依賴了獲取核心數的 API,用 JDK 8 構建時 API 返回 96,用 JDK 11 構建時返回 1,修復的思路就是讓 JDK 11 也能正常返回 96。
從源碼看,修復該問題主要有兩種辦法:
設置 -XX:ActiveProcessorCount=[count],指定 JVM 的可用核心數
設置 -XX:-UseContainerSupport,讓 JVM 禁用容器化
設置 -XX:ActiveProcessorCount=[count]
根據 Oracle 官方文檔和源碼,可以指定 JVM 的可用核心數來影響 Gradle 構建。
這個方法適用于進程常駐的場景,避免資源被某個 Docker 實例無限占用。例如 Web 服務的常駐進程,若不限制資源,當程序存在 Bug 或出現大量請求時,JVM 會不斷向操作系統申請資源,最終進程會被 Kubernetes 或操作系統殺死。
設置 -XX:-UseContainerSupport
根據 Oracle 官方文檔和源碼,通過顯式設置 -XX:-UseContainerSupport 可以禁用容器化,不再通過 Docker 容器相關的配置信息來設置 CPU 數,而是直接查詢操作系統來設置。
這個方法適用于構建任務耗時不長的場景,應最大程度調度資源快速完成構建任務。目前 CI 上均為短時間的構建任務,當任務完成后,Docker 實例會視情況進行緩存或銷毀,資源也會被釋放。
選擇的參數
對于 CI 構建,雖然可以查詢物理機的可用核心數,然后設置-XX:ActiveProcessorCount。但這里根據使用場景,選擇了設置更簡單的 -XX:-UseContainerSupport 來提升構建性能。
2.怎么設置參數
通過命令行設置
這個是最先想到的方法,但執行命令 "./gradlew clean, app:lark-application:assembleProductionChinaRelease -Dorg.gradle.jvmargs=-Xms12g -Xss4m -XX:-UseContainerSupport" 后有意外發現。雖然 OsAvailableProcessors 字段和 ByteXTransform 的耗時恢復正常;但構建整體仍然是單線程且 DexBuilder 的耗時也沒回落。
這個和 Gradle 的構建機制有關。
- 執行上面的命令時會觸發 GradleWrapperMain#main 方法啟動 GradleWrapperMain 進程(下面簡稱 wrapper 進程)
- wrapper 進程會解析 org.gradle.jvmargs 屬性,然后通過 Socket 傳遞給 Gradle Daemon 進程(下面簡稱 daemon 進程),所以上面的 -XX:-UseContainerSupport 只對 daemon 進行有效,對 wrapper 進程無效,同時 wrapper 進程也會初始化DefaultParallelismConfiguration#maxWorkerCount 然后傳給 daemon 進程
- daemon 進程禁用了容器化,所以能通過 API 獲取到正確的核心數,從而正確顯示 OsAvailableProcessors 字段和并發執行 ByteXTransform;但 wrapper 進程沒有禁用容器化,所以獲取的核心數是 1 ,傳給 daemon 進程后導致構建整體和 DexBuilder 都是單線程執行。
這里有個不好理解的點是 ByteXTransform 和 DexBuilder 都是 daemon 進程中執行的 Task,為什么 ByteXTransform 恢復正常了,而 DexBuilder 沒有?
因為 ByteXTransform 內部主動調了 API ,能獲取到正確的核心數,所以 ByteXTransform 可以并發執行;但 DexBuilder 受 Gradle Worker API (詳見相關資料)的調度,執行時的 maxWorkers 是被動設置的(wrapper 進程傳給 daemon 進程的)。如果通過 -XX:ActiveProcessorCount=[count] 給 wrapper 進程指定核心數,然后斷點,會發現 maxWorkers = count 。所以當 wrapper 進程沒有禁用容器化時,獲取的核心數是 1,DexBuilder 會單線程執行,因而沒有恢復正常。
上面引出來的一個點是既然構建整體和 DexBuilder 都受 Gradle Worker API 調度,為什么之前在 CI 上執行“./gradlew clean, app:lark-application:assembleProductionChinaRelease --max-workers=96”時,構建整體恢復了并發,但 DexBuilder 仍然沒有恢復正常?
因為 DexBuilder 的并發度除了受 maxWorkers 影響,還受 numberOfBuckets 的影響。
對于 Release 包,DexBuilder 的輸入是上游 MinifyWithProguard (不是MinifyWithR8,因為顯式關閉了R8)的輸出(minified.jar),minified.jar 會分成 numberOfBuckets 個 ClassBucket,每個 ClassBucket 會作為 DexWorkActionParams 的一部分設置給 DexWorkAction,最后把 DexWorkAction 提交給 WorkerExecutor 分配的線程完成 Class 到 DexArchive 的轉換
默認情況下,numberOfBuckets = DexArchiveBuilderTask#DEFAULT_NUM_BUCKETS = Math.max(12 / 2, 1) = 6
雖然通過 --max-workers 把 DexBuilder 的 maxWorkers 設置成了12,但由于 daemon 進程默認開啟了容器化,通過 Runtime.getRuntime().availableProcessors() 獲取的可用核心數是 1,因此 numberOfBuckets 并不是預期的 6 而是 1,所以轉 dex 時不能把 Class 分組然后并發處理,導致 DexBuilder 的耗時沒有恢復正常。CI 上也是一樣的邏輯,numberOfBuckets 從 48 變成了 1,極大的降低了并發度。
所以要讓構建整體恢復并發,讓DexBuilder 的耗時恢復正常,還需要讓 daemon進程接收的 maxWorkers 恢復正常,即讓wrapper 進程獲取到正確的核心數。通過給工程根目錄下的 gradlew 腳本設置 DEFAULT_JVM_OPTS 可以達到這個效果。
所以最終執行如下構建命令時,wrapper 進程和 daemon 進程都能通過 API 獲取到正確的核心數,從而讓構建整體、ByteXTransform、DexBuilder、OsAvailableProcessors 字段顯示都恢復正常。
但上面的命令在 CI Docker 容器中執行時正常,在本地 Mac 執行時會報無法識別 UseContainerSupport。通過判斷構建機器和環境(本地 Mac,CI Linux 原生環境,CI Docker 容器)動態設置參數可以解這個問題,但顯然比較麻煩。
通過環境變量設置
后來發現環境變量 JAVA_TOOL_OPTIONS 在創建 JVM 時就會檢測,簡單設置后對 wrapper 進程和 daemon 進程都有效,也可以解決上面所有的問題。
選擇的設置方法
對比上面兩種設置方法,這里選擇了更簡單的即通過環境變量來設置 -XX:-UseContainerSupport。
3.新老分支同時可用
由于飛書自身的業務特點,老分支也需要長期維護,老分支上存在和 JDK 11 不兼容的構建邏輯,為了新老分支都能正常出包,需要動態設置構建用的 JDK 版本。
另外 UseContainerSupport 是 JDK 8u191 引入的(也就是說高版本的 JDK 8 也有上面的問題,教育團隊升 AGP 4.1.0 時把 JDK 升到了 1.8.0_332,就遇到上面的問題),直接設置給 JDK 1.8.0_131 會無法識別,導致無法創建 JVM。
所以飛書最終的解決方案是根據分支動態設置構建用的 JDK 版本,并且只在使用 JDK 11 時顯式設置JAVA_TOOL_OPTIONS 為 -XX:-UseContainerSupport。對于其他團隊,如果老分支用 JDK 11 也能正常構建,可以選擇默認使用 JDK 11 且內置了該環境變量的 Docker 鏡像,無需修改構建邏輯。
六、效果
06-30 22點以后合入了修改,07-01 的構建整體耗時明顯下降,恢復到了 06-13(合入了 JDK 11 的升級)之前的水平,ByteXTransform 和 DexBuilder 的耗時也回落到了之前的水平,構建指標恢復正常,OsAvailableProcessors 字段也恢復正常,GC 情況恢復正常,世界又清靜了。
七、總結
雖然最后解決了構建性能劣化的問題,但在整個引入問題-->發現問題-->分析問題的流程中還是有不少點可以改進。比如對基礎構建工具(包括Gradle、AGP、Kotlin、JDK)變更進行更充分的測試可以事前發現問題,完善的防劣化機制可以有效攔截問題,有區分度的監控報警可以及時發現劣化,強大的自動歸因機制可以給分析問題提供更多輸入,后面會持續完善這些方面來提供更好的研發體驗。