在 GraalVM 靜態編譯下無侵入實現可觀測探索

作者:鋮樸、層風

GraalVM 靜態編譯

背景介紹

隨着雲原生浪潮的蓬勃發展,利用雲原生技術爲企業應用提供極致的彈性能力是企業數字化升級的核心訴求。但 Java 作爲一種解釋執行+運行時實時編譯的語言,相比於其他靜態編譯型語言天生具有如下不足,嚴重影響了其快速啓動與擴縮容效果。

冷啓動問題

Java 程序啓動運行詳細過程如圖 1 所示:

圖 1:Java 程序的啓動過程分析 [ 1]

Java 應用在啓動時首先需要加載 JVM 虛擬機到內存中,如圖 1 紅色部分描述所示。然後JVM虛擬機再加載對應的應用程序到內存中,該過程對應上圖中的淺藍色類加載(Class Load,CL)部分。在類加載過程中,應用程序就會開始被解釋執行,對應上圖中淺綠色部分。解釋執行過程 JVM 對垃圾對象進行回收,對應上圖中的黃色部分。

隨着程序運行的深入,JVM 會採用及時編譯(Just In Time,JIT)技術對執行頻率較高的代碼進行編譯優化,以便提升應用程序運行速度。JIT 過程對應上圖中的白色部分。經過 JIT 編譯優化後的代碼對應圖中深綠色部分。經過上述分析,不難看出,一個 Java 程序從啓動到達到被 JIT 動態編譯優化會經過 VM init,App init 和 App active 幾個階段,相比於其他一些編譯型語言,其冷啓動問題比較嚴重。

運行時內存佔用高問題

除了冷啓動問題,從圖 1 中可以看到,一個 Java 程序運行過程中,什麼都不做首先便需要加載一個 JVM 虛擬機,該過程一般會佔用一定量內存,另外,JIT 編譯和 GC 都會有一定量的內存開銷。

最後,由於 Java 程序是先解釋執行字節碼,然後再做 JIT 編譯優化,因此由於其編譯期比較晚,一些非必要的代碼邏輯可能也會被預先加載到內存中進行編譯。所以除了實際要執行的應用程序外,這些非必要代碼邏輯也是一筆難以忽視的額外開銷。綜上所述,這些就是很多人常詬病 Java 程序運行內存佔用高的原因。

靜態編譯技術

嚴重的冷啓動耗時和較高的運行時內存佔用使得Java應用難以滿足雲原生快速啓動和快速擴縮容的需求。因此業界,以 Oracle 公司爲主導的 GraalVM 開源社區 [ 2] ,通過推出 Java 靜態編譯技術,可以提前將 Java 程序編譯爲本地可執行文件,達到運行即巔峯的效果,可有效解決了Java應用冷啓動和運行時內存佔用高問題,讓 Java 繼續在雲原生技術浪潮中煥發生機。

阿里巴巴作爲 GraalVM 社區中國唯一的全球顧問委員會成員,持續在 GraalVM 上深入打磨,使之更加適合電商和雲上場景。如果之前對靜態編譯技術不瞭解,可以閱讀從本地原生到雲原生,Alibaba Dragonwell 靜態編譯的實踐與挑戰 [3 ]基於靜態編譯構建微服務應用,做更詳細的瞭解。

靜態編譯技術雖好,但對現有的 Java 技術體系也會有一定的影響。例如,探索過靜態編譯的朋友可能會清楚,經過靜態編譯後 Java 語言由於沒有了字節碼,會讓原本一些基於 Java 字節碼實現的 Java Agent 無侵入字節碼改寫技術失效。比如,目前 Java 生態中存在大量基於字節碼改寫無侵入地爲 Java 應用提供如分佈式鏈路追蹤能力的解決方案,在現有的 Java 靜態編譯方案下,它們都將失效,這些也是很多企業在實施靜態編譯技術之前不得不考慮的技術難題。

通過靜態插樁另闢蹊徑

那是不是在靜態編譯場景下,就無法像傳統的 Java 應用那樣基於 Java Agent 探針實現開箱即用的可觀測效果呢?

近期,阿里雲可觀測團隊聯合阿里雲程序語言與編譯器團隊一起,爲 GraalVM 實現了靜態的 Agent 插樁增強能力,並在阿里雲 ARMS 可觀測平臺上驗證了靜態增強數據的正確性和完整性,可有效解決目前 Java 靜態編譯時 Java Agent 字節碼增強的問題。實現 Java 應用既要有基於 GraalVM 靜態編譯帶來的性能提升,又能跟非靜態編譯場景下一樣,能夠通過類似於 Java Agent 這類技術無侵入的對應用實現分佈式鏈路追蹤等可觀測效果。

什麼是靜態插樁?

要搞清楚什麼是靜態插樁?不得不提其相對的一個概念:動態插樁。

熟悉 Java Agent 探針技術的讀者,應該瞭解 Java Agent 的作用過程,其本質是一種字節碼改寫技術,在應用運行過程中的類加載階段通過字節碼改寫技術,在應用的特定類方法(也叫埋點)前後插入一些增強邏輯,以達到對應用研發人員而言無感知地給應用增加一些比如,分佈式鏈路追蹤等可觀測能力。

相比於動態插樁應用運行過程中通過字節碼改寫動態插入一些邏輯,靜態插樁即是在程序啓動前就執行字節碼改寫,然後在運行前的 GraalVM 靜態編譯階段,將之前收集的字節碼改寫最終內容編譯到最終的可執行文件中,以實現動態插裝一樣的無侵入給應用在特定埋點進行能力增強的效果。

針對 Java Agent 的靜態插樁方案

通過上述對靜態插樁概念的介紹可知,要對應用代碼進行插裝,無非要解決以下兩個問題:

  1. 在應用的哪些位置進行插樁?
  2. 要在特定的位置插樁哪些內容?

因此,我們設計了一種 “預執行記錄+編譯時替換” 的方法來解決該問題,其過程整體分爲兩步:

  1. 通過應用程序的預執行記錄所有被增強的類信息;
  2. 在 GraalVM 靜態編譯階段,利用之前預執行收集的被增強類實現編譯階段的替換。

這樣理論上就解決了在應用的哪些位置,增強什麼內容的問題。

方案正確性論證

首先,回顧一下 Java Agent 機制的詳細工作過程,其是在應用的 main 函數啓動前,將 Agent 中定義的類轉換器(Transformer)和響應 eventHandlerClassFileHook 的鉤子實現註冊到 JVM 中。每當應用程序中首次加載一個類時,都先執行 eventHandlerClassFileHook 鉤子中註冊的代碼,然後再加載類。開發者可以在該鉤子中實現對指定類的變換,這樣運行時加載到的類就是經過 Agent 增強的類了。

因此,對於任意類 C,JVM 的 Agent 機制可以保證在 C 首次被加載的時刻即被 Agent 替換爲 C'。從實際運行的程序的角度,它在運行時自始至終接觸到的只有 C',而不是 C。因此,假設我們在編譯時就實現了將 C 替換爲 C',那麼對於應用程序來說,其所見到的類自始至終也是 C'。由此可見,在此問題上編譯時替換和運行時替換對程序運行的效果是完全等價的。

所以,這個運行時問題也就轉換成爲了兩個編譯時問題:

  1. 如何可以在編譯前就獲得 C'?
  2. C 和 C'是兩個同名類,如何在編譯時保證同名類替換?

預執行記錄被增強的類

瞭解過 GraalVM 靜態編譯技術的讀者,應該知道,GraalVM 提供了一個叫做 native-image-agent [ 4] 的探針,通過給應用進行掛載進行預執行,可以記錄 Java 應用程序中的反射、動態類加載、動態代理、序列化等動態行爲,輸出記錄了這些信息的配置文件。在編譯階段,配置文件也會作爲編譯的輸入爲編譯器提供動態行爲信息,以實現 Java 動態特性在靜態編譯環境仍然可生效的目的。

因此,我們通過對 native-image-agent 進行改寫,在原有的基礎上增加了對 Agent 實現類變換代碼增強行爲的觀察記錄邏輯,實現原理如圖 2 所示。圖中的黃色 Agent 在原始應用 App 上對紅色的代碼 C 實行運行時動態增強,將 C 部分代碼轉換爲 C',從而得到了 App'。增加了記錄代碼增強能力的 native-image-agent 負責觀察從 C 到 C' 的過程,將 C 的具體類名保存到配置文件,將變換後的 C' 保存到磁盤。

圖 2 native-image-agent 監測原 Agent 代碼變換過程示意圖

通過 native-image-agent 實施增強記錄是本方案的核心。整個過程必須包括被變換的類名、原始類文件的 byte 數組和變換後的類文件 byte 數組。以便可以判斷出類是否發生了變化,以免記錄下大量的噪音信息。

通過梳理 JVM 中 Agent 的工作流程,我們選擇了 Java 函數 sun/instrument/InstrumentationImpl.transform 作爲觀察切入點,即圖 3 中的紅圈處。以下將這個函數簡稱爲 transform 函數。

圖 3 JVM 支持 agent 實現動態代碼變換流程圖

我們在 native-image-agent 中增加一個針對 transform 函數的函數斷點,然後對比變換前後的類數據是否一致。如果一致,說明沒有做變換,該類無需進行記錄;如果不一致,說明類已經被改變,則將其類的全限定名輸出到配置文件,將類的內容保存到磁盤。

編譯時替換

得到了增強類,接下來只要在編譯時用它們替換原始類,就可以在最終經過靜態編譯的 native image 可執行文件中實現插樁增強的效果了。那麼在編譯時如何替換呢?最簡單且安全的方式就是在類加載時替換。GraalVM 的靜態編譯能力本身也是一個 Java 程序,需要將編譯的目標類全部加載到 classpath 上。

所以簡單地說,我們只要在生成 classpath 列表時,將增強類的路徑放在最前邊就可以了。對於使用了 module system 的情況,因爲同一個類不能出現在兩個 module 中,我們就要將增強類準備爲 jar 包,通過 --patch-module 的形式替換原始類。這個過程原理簡單,但是自動化實現的過程比較複雜,需要在修改 GraalVM 靜態編譯框架,在此就不展開了。

經過上述方法的處理,GraalVM 靜態編譯後的本地可執行程序中就只有變換後的代碼,其運行時行爲就與期待的行爲一致。通過以上預執行記錄+編譯時替換兩個步驟就實現了對應用在 GraalVM 環境下的靜態插樁。

靜態插樁技術實踐

基於上述方案,我們已經對一些常用的微服務組件,比如 Spring Boot、Kafka、MySQL 和 Redis 進行了效果驗證,我們目前是直接基於業界知名的可觀測 Java Agent 探針實現 opentelemetry-java-instrumentation [ 5] 進行數據採集(後文簡稱 OT 探針),然後將採集的可觀測數據上報到阿里雲應用實時監控服務 ARMS 中的可觀測鏈路 OpenTelemetry 版 [ 6] 中進行的效果驗證,如下爲相關測試效果。

測試效果

JVM 模式

在一般的 JVM 運行時環境下,利用 OT 探針無侵入對 Spring Boot 應用進行可觀測數據採集,然後將數據上報到阿里雲應用實時監控服務 ARMS 中的可觀測鏈路 OpenTelemetry 版中的效果如圖5所示:

圖 5 在傳統 JVM 條件下的可觀測數據採集與展示效果

我們測試過程中對應用發了 5 次調用,從圖 5 的效果調用鏈記錄的次數和調用鏈詳情信息與實例應用都是一致的。

GraalVM 模式

在 GraalVM 靜態編譯環境下,基於上述方案,然後利用 OT 探針無侵入對 Spring Boot 應用進行可觀測數據採集,將數據上報到阿里雲應用實時監控服務 ARMS 中的可觀測鏈路 OpenTelemetry 版中的效果如圖 6 所示:

圖 6 在 GraalVM 靜態編譯條件下的可觀測數據採集與展示效果

測試過程中同樣對應用發了 5 次調用,通過上述效果對比截圖可以發現,Spring Boot 應用基於 GraalVM 靜態編譯後,採用靜態插樁技術,所採集的請求數等指標與 JVM 環境動態增強方式一致,得益於靜態編譯技術的優化,請求 Span 耗時(不涉及網絡情況下)比 JVM 環境增強方式低很多。除了上述 Spring Boot 應用的測試結果,其他的一些常用組件,例如 Kafka、MySQL 和 Redis 都做了上述同樣測試,發現方案都是有效的!

另外,下表爲我們測試的部分框架應用基於正常 JVM 環境下掛載探針 vs 基於靜態編譯場景掛載探針耗時和運行時內存佔用情況數據(測試環境:32 核(vCPU)/64 GiB/5 Mbps):

基於靜態編譯後,各類型應用的啓動耗時大致降低了 98% 左右,運行時內存佔用比原先下降了約 70% 左右,從測試結果看,上述 4 個框架組件基於當前方案,既能享受到靜態編譯帶來的性能大幅度提升,也可消除靜態編譯帶來的 Java Agent 無侵入增強失效問題。

其他

最後,如上述內容介紹所示,當前我們已經完成了方案的驗證,並向 GraalVM 社區提交了相關的修改 PR [ 7] 。如果要在生產場景應用,也還有一些其他工程性的問題需要處理和優化。比如,Java Agent 可能出於一些場景需要,要能實現對 JDK 中的類進行替換,而 GraalVM 本身也修改了部分 JDK 類,以使之適應靜態編譯後的運行時。所以碰到兩邊都進行修改要考慮兼容性等。最後,歡迎對該方案感興趣或者希望進行相關效果復現的讀者,可以加釘釘羣: 80805000690,獲取相關資料和做進一步交流探討。

相關鏈接:

[1] Java 程序的啓動過程分析

https://shipilev.net/talks/j1-Oct2011-21682-benchmarking.pdf

[2] GraalVM 開源社區

https://www.graalvm.org/

[3] 從本地原生到雲原生,Alibaba Dragonwell 靜態編譯的實踐與挑戰

https://www.infoq.cn/article/uzHpEbpMwiYd85jYslka

[4] native-image-agent

https://www.graalvm.org/latest/reference-manual/native-image/metadata/AutomaticMetadataCollection/

[5] opentelemetry-java-instrumentation

https://github.com/open-telemetry/opentelemetry-java-instrumentation

[6] 可觀測鏈路 OpenTelemetry 版

https://help.aliyun.com/zh/arms/tracing-analysis/

[7] 支持靜態插樁相關 PR

https://github.com/oracle/graal/pull/8077

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章