Java 革新之路:GraalVM 原生鏡像
InfoQ · 程式 ·

Java 革新之路:GraalVM 原生鏡像

那麼,Java用戶的問題來了:原生Java是如何改變開發方

Java 主導著企業級應用。但在雲計算領域,採用 Java 的成本比它的一些競爭對手更高。原生編譯降低了在雲端採用 Java 的成本:用它創建的應用程式啟動速度更快,使用的內存更少。


那麼,Java 用戶的問題來了:原生 Java 是如何改變開發方式的?我們在什麼情況下應該切換到原生 Java?什麼情況下又不應該切換?我們應該使用什麼框架?本系列文章將回答這些問題。


本文是「Native Compilations Boosts Java」系列文章的一部分。你可以通過訂閱RSS接收更新通知。


GraalVM 自三年前發布以來,引發了一場 Java 開發革命。GraalVM 最常被討論的特性之一是它的原生鏡像是基於提前(AOT)編譯技術。它提升了原生應用程式的運行時性能,同時保持開發人員熟悉的生產力方式和 Java 生態系統工具不變。

傳統的 Java 應用程式執行方式

Java 平台最強大、最有趣的一個地方是 Java 虛擬機(JVM)執行代碼的方式,它提供了出色的峰值性能。


在第一次運行應用程式時,JVM 會解釋代碼並收集剖析信息。儘管 JVM 解釋器的性能很好,但還是不如運行已編譯的代碼快。這就是為什麼 Oracle 的 JVM(HotSpot)也包含了即時(JIT)編譯器,它可以在程序執行時將應用程式代碼編譯成機器碼。因此,如果你的代碼經過「預熱」——被頻繁執行,就會被 C1 編譯器編譯成機器碼。然後,如果它們仍然執行得很頻繁,並且達到某些閾值,就會被頂層的 JIT 編譯器(C2 或 Graal 編譯器)編譯。頂層編譯器會根據哪些代碼分支執行得最頻繁、循環執行的頻率以及多態代碼中使用了哪些類型來執行優化。


有時候,編譯器也會進行推測性優化。例如,JVM 會根據收集到的剖析信息生成優化、編譯過的方法。但是,由於 JVM 是動態執行代碼的——如果它所做的假設變成無效的——JVM 將進行反優化:它將忽略已編譯的代碼並恢復到解釋模式。正是這種靈活性讓 JVM 變得如此強大:從快速執行代碼開始,利用優化編譯器來優化頻繁執行的代碼,並通過推測進行更積極的優化。


乍一看,這似乎是運行應用程式的一種理想的方法。然而,就像其他大多數事情一樣,即使是這種方法也存在權衡,也需要付出成本。JVM 在執行某些操作(例如驗證代碼、加載類、動態編譯和收集剖析信息)時,它需要進行複雜的計算,需要消耗大量的 CPU 時間。除了這個成本之外,JVM 還需要相當大的內存來存儲剖析信息,在啟動時也需要相當可觀的時間和內存。隨著許多公司將應用程式部署到雲端,這些成本變得越來越重要,因為啟動時間和內存直接影響部署應用程式的成本。那麼,有沒有一種方法既能減少啟動時間和內存使用,又能保持我們都喜歡的 Java 生產力、庫和工具呢?


答案是「是」,這就是 GraalVM 原生鏡像所要做的事情。

大贏家 GraalVM

10 年前,GraalVM 是 Oracle Labs 的一個研究項目。Oracle Labs 是 Oracle 的一個研究和開發分支,主要研究程式語言和虛擬機、機器學習和安全、圖形處理等領域。GraalVM 就是一個很好的例子——它以多年的研究和 100 多篇發表的學術論文為基礎。


這個項目的核心是 Graal 編譯器——一個全新的、高度優化的現代編譯器。由於採用了多種高級優化手段,在許多情況下,它生成的代碼比 C2 編譯器更好。其中的一種優化是部分轉義分析:如果分支中的對象沒有轉義編譯單元,就通過標量替換移除不必要的堆對象分配,Graal 編譯器會確保分支中有轉義的對象一定存在於堆中。


這種方法減少了應用程式的內存占用,因為堆上的對象更少了。它還可以降低 CPU 負載,因為垃圾回收更少了。此外,GraalVM 的高級推測功能利用動態運行時反饋生成更快的機器碼。通過推測程序的某些部分在程序運行期間不會被執行,讓代碼執行變得更加高效。


你可能會驚訝地發現,GraalVM 編譯器的大部分代碼是用 Java 寫的。如果你看一下 GraalVM 的核心 GitHub 存儲庫,你會看到超過 90%的代碼是用 Java 寫的,這再次證明了 Java 是多麼的強大和通用。

原生鏡像的工作原理

Graal 編譯器還是一種提前(AOT)編譯器,可以生成原生可執行文件。既然 Java 是動態的,那麼編譯器究竟是如何做到的呢?


在 JIT 模式下,編譯和執行同時發生,但在 AOT 模式下,編譯器在構建期間(即執行之前)就完成了所有的編譯。這裡的主要思想是將所有「繁重的工作」——昂貴的計算部分——轉移到了構建時,這樣就可以一次性完成編譯,然後生成的可執行文件在運行時就可以快速啟動,並在一開始就做好準備,因為所有的東西都是預先計算和預先編譯的。


GraalVM 的「native-image」工具接受 Java 字節碼作為輸入,並輸出一個原生可執行文件。這個工具會通過假設對字節碼執行靜態分析。在分析過程中,工具會找出被應用程式使用的代碼,並消除不必要的代碼。


以下三個關鍵概念可以幫你更好地理解原生鏡像的生成過程:


  • 指向(Points-To)分析。GraalVM 原生鏡像會確定哪些 Java 類、方法和欄位在運行時是可訪問的,並且只有這些內容會被包含在原生可執行文件中。指向分析從所有入口點(通常是應用程式的 main 方法)開始。分析過程會循環處理所有可觸及的代碼路徑,直到到達一個固定點,然後分析結束。這不僅適用於應用程式代碼,還適用於庫和 JDK 類——將應用程式打包成自包含的二進位文件所需要的東西。
  • 在構建時初始化。GraalVM 原生鏡像默認在運行時進行類初始化,以確保正確的行為。但是,如果原生鏡像可以證明某些類可以安全地初始化,它就會在構建時對它們進行初始化。這樣一來,運行時初始化和檢查就變得不必要,從而提高了性能。
  • 堆快照。原生鏡像中的堆快照是一個非常有趣的概念,值得專門寫一篇文章。在鏡像構建過程中,由靜態初始化器分配的 Java 對象和所有可訪問的對象都被寫入鏡像的堆。這代表著使用預先處理的堆可以更快地啟動應用程式。有趣的是,指向分析會讓鏡像堆中的對象變得可觸及,而構建鏡像堆的快照會讓更多方法可觸及。因此,指向分析和堆快照將反覆執行,直到到達一個固定的點:


的成本:用它創建的應用程式啟動速度更快,使用的內存更少。

原生鏡像構建過程


在分析完成後,GraalVM 會將所有可觸及的代碼編譯成特定於平台的原生可執行文件。可執行文件本身功能完備,不需要 JVM 來運行。因此,你得到的是 Java 應用程式的精簡而快速的原生可執行版本:它具備完全相同的功能,但只包含必要的代碼及其所需的依賴項。


但是,誰來負責處理內存管理和線程調度等問題呢?原生鏡像中還包含了一個 Substrate VM——一個提供運行時組件(比如垃圾回收器和線程調度器)的精簡 VM 實現。就像 GraalVM 編譯器一樣,Substrate VM 是用 Java 開發的,然後用 GraalVM 原生鏡像的 AOT 編譯技術將其編譯成原生代碼!


得益於 AOT 編譯和堆快照,原生鏡像為你的 Java 應用程式提供了一種全新的性能。接下來讓我們來仔細看一看。

將 Java 啟動性能提升到一個新的水平

你可能聽說過原生鏡像生成的可執行文件具有非常好的啟動性能,那麼究竟是怎樣的性能呢?


即時啟動。在 JVM 上運行時,代碼需要經過驗證、解釋,然後(在預熱之後)最終被編譯,與此不同,原生可執行文件從一開始就帶有優化的機器碼。我喜歡用即時性能這個詞來形容它——應用程式可以在啟動的第一毫秒內執行有意義的任務,不需要任何分析或編譯開銷。


JIT

AOT

作業系統加載JVM可執行文件

作業系統加載帶有堆快照的可執行文件

VM從文件系統加載類

應用程式立即用優化的機器碼啟動

驗證字節碼


開始解釋字節碼


運行靜態初始化器


第一層編譯(C1)


收集分析指標


……(過了一段時間)


第二層編譯(C2/Graal編譯器)


最後執行優化後的機器碼



JIT 和原生鏡像的啟動過程對比


內存效率。原生可執行文件不需要 JVM 及其 JIT 編譯器,也不需要用於代碼、分析文件數據和字節碼緩存的內存。它只需要用於可執行文件和應用程式數據的內存。這裡有一個例子:

ava的成本比它的一些競爭對手更高。原生編譯降低了在雲端採用Java

JIT 和原生鏡像使用的 CPU 和內存對比


上圖顯示了 Web 伺服器在 JVM 上(左)和作為原生可執行文件(右)的運行時行為。藍綠色的線表示使用了多少內存:在 JIT 模式下是 200MB,而原生可執行文件是 40MB。紅線表示 CPU 活動:JVM 在熱身 JIT 活動期間使用了大量 CPU,而原生可執行程序幾乎不使用 CPU,因為所有昂貴的編譯操作都發生在構建時。這種快速且資源高效的運行時行為讓原生鏡像成為一種很棒的部署模型,在更短的時間內使用更少的資源,可以顯著降低成本——適用於微服務、無伺服器和雲端工作負載。


文件體積。原生可執行文件只包含必需的代碼。這就是為什麼它比應用程式代碼、庫和 JVM 的總和要小得多。在某些場景中,例如在資源受限的環境中,應用程式的體積可能是一個很重要因素。UPX等工具可以進一步壓縮原生可執行文件的體積。

峰值性能與 JVM 相當

那麼峰值性能如何呢?既然一切都是提前編譯的,那麼原生鏡像如何在運行時優化峰值吞吐量?


我們正在努力確保原生鏡像提供良好的峰值性能和快速啟動。已經有一些方法可以提高原生可執行文件的峰值性能:


  • 基於分析的優化。由於原生鏡像會提前優化和編譯代碼,所以默認情況下它無法在應用程式運行時訪問運行時分析信息來優化代碼。解決這個問題的一種方法是進行基於分析的優化(Profile-Guided Optimization,PGO)。開發人員可以運行應用程式,收集分析信息,然後將其反饋給原生鏡像生成過程。「原生鏡像」工具基於這些信息根據應用程式的運行時行為優化可執行文件的性能。PGO 包含在 GraalVM Enterprise(這是 GraalVM 的商業版本,由 Oracle 提供)中。
  • 原生鏡像的內存管理。原生鏡像生成的可執行文件的默認垃圾回收器是 Serial GC,這對小內存堆的微服務來說是最優的。當然,還有其他 GC 選項:
  • Serial GC 提供了一個新的策略,可以為年輕代分配倖存者空間,從而減少應用程式運行時的內存占用。經過我們的測試,自從引入這個策略以來,典型的微服務工作負載(如 Spring Petclinic)的峰值吞吐量改進高達 23.22%。
  • 或者,你也可以使用低延遲的 G1 垃圾回收器,從而獲得更高的吞吐量(包含在 GraalVM Enterprise 中)。G1 最合更大的堆。有了 PGO 和 G1 GC,原生可執行文件的峰值性能可與 JVM 媲美:


Java主導著企業級應用。但在雲計算領域,採用J

Renaissance 和 DaCapo 測試基準


有了這些選項,就可以利用原生鏡像最大化應用程式的各個性能維度:啟動時間、內存效率和峰值吞吐量。

反射、配置和其他

由於原生鏡像是執行 Java 應用程式的一種全新的方式,所以有幾個地方需要注意。


有人說 GraalVM 原生鏡像不支持反射,這不是真的。


原生鏡像會基於一些假設進行靜態分析。因此,要啟用 Java 的動態特性(如反射),需要進行額外的配置。在對 Java 應用程式進行靜態分析時,它會嘗試檢測和處理反射 API 調用。然而,通常情況下,這種自動分析是不夠的,而且在運行時通過反射訪問的程序元素必須通過配置來指定。你可以手動創建這些配置,也可以利用原生鏡像跟蹤代理。當程序運行在 JVM 上,代理會跟蹤動態特性,並生成配置文件。原生鏡像工具使用這個文件來包含調用了反射 API 的部分。雖然代理可用於獲得初始的配置,但我們還是建議在必要時通過手動檢查來完成這個過程。


在使用 Java 本地接口(JNI)、動態代理對象和類路徑資源時,可能需要類似的配置。你也可以使用這個跟蹤代理來獲得這些配置。


最後,你可以使用GraalVM Dashboard,一個可視化原生鏡像編譯的 Web 應用程式,可以用它來發現原生可執行文件中包含了哪些包、類和方法,還可以識別哪些對象在堆中占用了最大的空間。

改變 Java 雲端部署

原生鏡像將改變雲端部署,它對應用程式的資源消耗產生了很大的影響。我們知道,原生鏡像生成的原生可執行文件啟動快,需要的內存少。對於雲端部署來說,這到底代表著什麼? GraalVM 如何幫助最小化 Java 容器鏡像?


運行原生鏡像生成的應用程式不需要 JVM:它們可以是自包含的,包括應用程式執行所需的所有東西。這代表著你可以將應用程式放入一個苗條的 Docker 鏡像中,並且它本身將具備完整的功能。鏡像大小取決於應用程式要完成的任務以及它包含哪些依賴項。一個使用 Java 微服務框架構建的「Hello, World!」應用程式大約有 20MB。


你還可以用原生鏡像構建全靜態或部分靜態的可執行文件。部分靜態的原生可執行文件被靜態連結到所有的庫,除了容器鏡像提供的「libc』。你可以用 distroless 容器鏡像進行輕量級部署。disroless 鏡像只包含運行應用程式所需的庫,不包含 shell、包管理器和其他程序。舉個例子,你的 Dockerfile 可能是這樣的:

```FROM gcr.io/distroless/baseCOPY build/native-image/application appENTRYPOINT ["/app"]```

複製代碼

對於一個完全自主的部署(甚至不需要容器鏡像提供的 libc)來說,你可以靜態地將應用程式連結到「musl-libc」。你可以把它放在「FROM scratch」的 Docker 鏡像中,因為它是完全自包含的。

在生產環境中使用原生鏡像

到目前為止,我們已經討論了如何最大化原生鏡像生成的應用程式的性能,並考慮了在構建過程中可以應用的一些有用的技巧。除此之外,我們還可以做些什麼來最大限度地利用應用程式呢?是的,有很多。


為了簡化原生可執行文件的構建、測試和運行,可以使用 GraalVM 團隊提供的 Maven 和 Gradle插件。此外,這些插件支持原生 JUnit 5 測試,並且是與 JUnit、Micronaut 和 Spring 團隊合作開發的,充分彰顯了 JVM 生態系統的協作關係。


要在你的 GitHub Action 工作流中設置 GraalVM 原生鏡像,可以使用GraalVM的GitHub Action。可配置的 Action 支持多個 GraalVM 版本和開發者構建,並可設置好完整的 GraalVM 和特定組件。


現在我們來說一下工具。在開發 Java 應用程式時,你可以使用常規的工具。你可以使用任意的 IDE 和 JDK(包括 GraalVM JDK)來構建、測試和調試應用程式,然後使用 GraalVM 原生鏡像工具來進行最終的原生編譯。根據應用程式複雜程度的不同,原生鏡像編譯可能需要一些時間,因此建議將其作為最後一個步驟。不過,我們正在為原生鏡像開發一種快速開發模式,它將跳過一些優化步驟,以此來縮短編譯時間。


儘管你可以基於 JVM 開發應用程式,然後在稍後的開發過程中構建原生可執行文件,但我們收到了很多來自社區的請求,要求改進構建時間和資源使用。在過去的幾個版本中,我們針對這個問題做了很多工作。在最新發布的 GraalVM(22.0)中,你可以在大約13.8秒內將一個 hello-world Java 應用程式生成一個原生可執行文件,可執行文件的大小大約為 5MB。我們還減少了大約 10%的內存使用。


要調試原生鏡像生成的可執行文件,可以在命令行中使用「gdb」(在 Linux 和 macOS 上),或者使用 GraalVM 的 VS Code 擴展。這個教程提供了使用說明。


要監控原生可執行文件的性能,請使用 JDK Flight Recorder。對原生鏡像的全面支持仍在開發當中,不過你已經可以用它來觀察自定義事件和系統事件。


如果要進行額外的性能監控,可以生成原生可執行文件的堆轉儲,然後使用 VisualVM 等工具對其進行分析。這是 GraalVM Enterprise 的一個特性。

哪些 Java 框架採用了原生鏡像

如果沒有 Java 框架的支持,開發行業級應用程式將是非常困難的。幸運的是,現在有很多可用的框架。所有主流的框架都支持原生鏡像(按字母順序列出):Gluon Substrate、Helidon、Micronaut、Quarkus 和 Spring Boot。所有這些框架都利用 GraalVM 原生鏡像顯著改善了應用程式的啟動時間和資源使用,成為高效的雲端部署工具。本系列的後續文章將介紹框架是如何使用 GraalVM 原生鏡像的。

原生鏡像的未來

自從第一次公開發布以來,原生鏡像已經取得了巨大的進步。它被 Java 框架廣泛採用,雲供應商也將原生鏡像作為 Java 運行時,許多庫也都使用了原生鏡像。我們對開發者的體驗做了一些改變,我們去年的研究表明,70%使用 GraalVM 的開發者已經在用它來構建和發行原生可執行文件。


對於原生鏡像的新特性和改進,我們有很多想法,包括:


  • 支持更多的平台;
  • 簡化 Java 庫的配置和兼容性;
  • 繼續優化峰值性能;
  • 繼續與 Java 框架團隊合作,充分利用所有的原生鏡像特性,開發新的特性,提高性能,並確保良好的開發體驗;
  • 引入更快的開發編譯模式;
  • 支持 Loom 的虛擬線程;
  • 讓 IDE 支持原生鏡像配置和基於代理的配置;
  • 進一步提高 GC 性能並添加新的 GC 實現。我們要感謝社區和我們的合作夥伴幫助我們推動原生鏡像的發展,讓它對每個 Java 開發者起到越來越大的作用。如果你想在原生鏡像中看到新的功能或改進,請通過 GraalVM 的社區平台與我們分享你的反饋!


作者簡介:


Alina Yurenko 是 Oracle Labs(Oracle 的一個研究和開發部門)的 GraalVM 開發者布道師。她有開發者關係的工作經驗,現在加入了 GraalVM 團隊,與它的全球社區一起工作。

聲明:文章觀點僅代表作者本人,PTTZH僅提供信息發布平台存儲空間服務。
喔!快樂的時光竟然這麼快就過⋯
繼續其他精彩內容吧!
more