GraalVM:容器中的原生映像檔

在容器中,Java 應用像原生應用般,更快的啟動時間與較少的記憶體消耗

Du Spirit
Java Magazine 翻譯系列
13 min readJan 19, 2020

--

Translated from “GraalVM: Native Images in Containers” By Oleg Šelajev, Java Magazine May/June 2019. Copyright Oracle Corporation.

GraalVM 能執行多種語言的高性能虛擬機器,目前,它支援 Java、Scala、Kotlin 及 Groovy 等 JVM 語言,也支援 JavaScript、Node.js、Ruby、R、Python 及使用 LLVM 的原生語言。這一個多功能的專案,但對於雲端布署與容器的世界來說,GraalVM 的一項能力可能是最令人興奮的,GraalVM 能將 JVM 的 bytecode 編譯成原生可執行碼或是可分享的函式庫,使產生的 binary 檔案不需依賴 JVM 執行。

該執行檔可放於容器中像一個獨立的應用程式,且啟動速度非常非常快,除此之外,GraalVM 原生映像檔有較低的記憶體開銷,這在雲端中使用事非常具有吸引力。

開始吧

我們從頭開始用一個簡單的應用程式建立 GraalVM 原生映像檔。首先,需要 GraalVM 的發行版,可以從 GraalVM 官網下載,社群版及企業版都可以建立原生映像檔。

解壓縮檔案,將 $GRAALVM_HOME 指向 GraalVM 目錄,為方便使用,您也可以將 $GRAALVM_HOME/bin (或 macOS 上的 $GRAALVM_HOME/Contents/Home/bin) 指向該路徑,這步驟完成後,產生原生映像檔的工具,稱作 native-image 已經可以使用,用 $GRAALVM_HOME/bin/native-image –version 來檢查設定。

用一個簡單的程式示範 GraalVM,從 https://github.com/graalvm/graalvm-demos/ 取得程式,並切到 native-list-dir 目錄,您能找到 ListDir.java 類別,它是一個簡單的工具程式,走遍檔案系統,印出一些找到的有用資訊,程式碼非常直覺:

將這編譯成 .class 檔案,因為 native-image 用在 bytecode 層級,這也使它能支援其他 JVM 語言。

在執行 javac ListDir.java 後,執行 native-image ListDir

您同樣可以對一組 JAR 檔案使用 native-image,只需設定 classpath 和執行檔的主類別,native-image 會分析您的應用程式,確認其它有使用的類別 (包含您使用的第三函式庫與 JDK 函式庫),建立一個可尋訪的類別與函式的對照表,這是靜態分析,在 closed universe [譯註:這就不翻譯了]的前提下,確保在原生映像檔中,產生的可執行檔中所有 bytecode 檔案的內容可被執行。

分析結束後,您可以找到一個 listdir 檔案,以我來說,在 macOS 上,是可以原生的可執行檔,它直接連結到作業系統的函式庫,不需要 JVM。

這檔案本身只有幾 MB,它包含先前編譯的程式以及所使用的 JDK 類別,像是 java.lang 類別或是 Exception 類別,但是,即使有這些必要的類別,原生執行檔的大小通常遠小於執行該程式所需的完整 JDK。

我們執行 Java 版本以及原生版本,然後用 UNIX time 指令量測執行的時間:

$ time java ListDir
Walking path: .
Total: 7 files, total size = 8366834 bytes
java ListDir 0.22s user 0.06s system 51% cpu 0.555 total

現在執行 Graal 原生版本:

$ time ./listdir
Walking path: .
Total: 7 files, total size = 8366834 bytes
./listdir 0.00s user 0.00s system 66% cpu 0.011 total

您可以看到它們產生相同的結果,即便 Java 版本的時間並不差,但原生版本的時間幾乎是 0。

GraalVM native image 有個重要的功能是在過程中,可以產生的過程中分析類別的靜態初始化,並將預先初始化的資料結構儲存與映像檔的 heap 中。這是可配置的選項,但對於縮短啟動時間的最後幾毫秒非常有用。

然而,這設計對 native image 提出一個有趣的挑戰:假如事先編譯的程式在靜態初始化中對實際環境中的實體進行初始化,例如建立 thread pools、開啟檔案或是 mapping memory,這在映像檔產生階段執行這些動作沒有任何意義,產生映像檔通常不會在實際環境中完成,而是在持續整合的伺服器中進行。native-image 工具會退出並拒絕編譯您的程式,如果類別初始化進行一些在映像黨產生階段無意義的動作,因此,您需要用 --delay-class-initialization-to-runtime=classname 來配置哪些類別在執行期間才進行初始化。

處理特殊狀況

有幾件事需要產生映像檔時設定,最明顯的,也許是 reflection,Java 程式可以用 Reflection API 檢視類別資料、載入額外的類別或是呼叫函式,由於 Reflection API 可以動態存取類別與物件,靜態分析無法解析出要放在原生映像檔中的所有類別。這不代表 GraalVM 原生映像檔無法處理使用 reflection 的程式,您只需事先列出會以 reflection 方式使用的類別與函式,配置檔是一個 JSON 檔案,列出類別與檔案。假設您有如下兩個類別,其中一個以 reflection 方式呼叫另一個:

將它們編成原生映像檔,您提供以下的 JSON 檔案,並在執行時用參數 -H:ReflectionConfigurationFiles=指定該檔案

這檔案指定哪些類別、函式及建構子會以 reflection 的方式存取。類似的方式,如果您要把有用 Java Native Interface (JNI) 的應用程式編譯成原生映像檔,通常需要為 (JNI) 的存取進行配置。

您能想像的到提供這樣的配置會很惱人,尤其使用 reflection 的不是您的程式而是第三方函式庫。這情況下,您可使用 GraalVM 提供的 javaagent 來進行配置,執行您的程式並連接上代理,它會記錄所有 reflection、JNI 和其他您需要建置原生映像檔的所有配置:

$ /path/to/graalvm/bin/java \
-agentlib:native-image-agent=trace \
-output=/path/to/trace-file.json

您可以執行數次,產生不同的紀錄檔,確保所有程式路徑至少被執行一次,native-image 工具能捕捉您程式的全貌。

您能在執行測試時使用追蹤工具,測試通常能涵蓋重要的程式路徑 (如果沒有,也許應該先修正),記錄收集完,能將它們轉成 native-image 配置檔:

$ native-image --tool:native-image-configure
$ native-image-configure process-trace \
--output-dir=/path/to/config-dir/ /path/to/trace-file.json

上述的指令處理紀錄檔,產生需要的配置檔:jni-config.jsonreflect-config.jsonproxy-config.jsonresource-config.json

準備完成後,使用產生的配置檔相當簡單,以下指令能讓配置生效:

$ native-image -H:ConfigurationFileDirectories=/path/to/config-dir/

另一個要知道的重要配置選項是 --allow-incomplete-classpath,Java 應用程式時常檢查 classpath 是否有哪些類別,根據存在與否有不同的行為,這類行為經典例子是日誌配置,如果 logback 函式庫存在,會配置 logback,若 log4j2 存在則會配置它,再沒有會退回到 log4j,依此類推。native-image (需要所有類別都存在以便分析且及早知道所有路徑) 如何處這樣的程式?單案很簡單,預設它會拒絕編譯此模式的程式,但如果您明確說不完整的 classpath 不是問題,不需程式路徑依然能編譯這類程式。

如前所述,有許多配置選項能影響 native-image 產生映像檔的行為,身為開發者,您需常是所有配置讓 GraalVM 能成功處理更多程式。

性能

讓我們看一下原生映像檔的性能,先前的例子,您看到原生映像檔能在毫秒內啟動,那它的吞吐能力如何?畢竟,您知道 just-in-time (JIT) 編譯氣通常重視的是峰值的性能,而不是啟動或預熱的速度。原生映像檔不慢,但已預熱的 JIT 編譯器在長時間運行的工作負載會是較佳的選擇。考慮到這點,我們來看一個簡單的 Netty-based Web 服務應用程式

首先用下列指令建置並建立原生映像檔:

$ mvn clean package
$ native-image -jar target/netty-svm-httpserver-full.jar \
-H:ReflectionConfigurationResources=\
netty_reflection_config.json \
-H:Name=netty-svm-http-server \
--delay-class-initialization-to-runtime=\
io.netty.handler.codec.http.HttpObjectEncoder \
-Dio.netty.noUnsafe=true

現在您能啟動原生映像檔,觀察單一請求及在一定負載下的處理速度。

為此,我使用 wrk2 量測工具產生負載並量測回應的延遲,在我的 MacBook 上,我指定兩個執行緒及同時 100 個連線,30 秒內維持每秒 2000 個請求。

$ wrk -t2 -c100 -d30s -R2000 http://localhost:8080/

數據如下,我先呈現 Netty 應用程式的 bytecode 版本,然後是原生映像檔的版本,Java bytecode 版本:

Running 30s test @ http://127.0.0.1:8080/
2 threads and 100 connections
Thread calibration: mean lat.: 1.386ms, sampling interval: 10ms
Thread calibration: mean lat.: 1.362ms, sampling interval: 10ms
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.30ms 573.88us 3.34ms 65.01%
Req/Sec 1.05k 181.18 1.67k 78.84%
59802 requests in 30.00s, 5.70MB read
Requests/sec: 1993.21
Transfer/sec: 194.65KB

原生映像檔的版本,相當接近:

$ wrk -t2 -c100 -d30s -R2000 http://127.0.0.1:8080/
Running 30s test @ http://127.0.0.1:8080/
2 threads and 100 connections
Thread calibration: mean lat.: 1.196ms, sampling interval: 10ms
Thread calibration: mean lat.: 2.788ms, sampling interval: 10ms
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.43ms 715.90us 5.78ms 70.34%
Req/Sec 1.07k 1.37k 5.55k 89.40%
58898 requests in 30.01s, 5.62MB read
Requests/sec: 1962.88
Transfer/sec: 191.69KB

當然,這不是嚴格的測試,但這些數字說明,短時間內,原生映像檔能和 JDK 版本的應用程式有相似的性能。

如果您想要讓原生映像檔有更好的吞吐量,您可以考慮 GraalVM 的企業版,它是 Oracle 專屬產品,包含其他性能強化。針對原生映像檔,它包含profile-guided 優化及其他優化,這意味著您可以建置檢測的映像檔,透過負載收集資料,根據這應用的特殊需求進行優化,然後建置最終的映像檔,這使性能幾乎達到預熱 JIT 的水準。

記憶體消耗

我們談談記憶體消耗,對於 JVM 用於 serverless 的情境,大多抱怨它占用大量記憶體,即使只處理單個請求之類的一次性任務 (若您對 GraalVM 原生映像檔在這方面的表現有興趣,前個 Netty 例子中,在我的機器上執行大概需要 30 MB 的記憶體,包含 heap 的部分)。

原生映像檔仍然有垃圾回收機制,執行您的程式並在運行時收集不再使用的物件,創造出無限記憶體空間的假象。這不希奇,每個 JVM 都如此,此外,JVM 通常還能讓您選擇垃圾回收的演算法,對低延遲、最小 CPU 消耗或是兩者間的微調。

原生映像檔中的垃圾回收機制不是您在 JVM 中使用的,相反,它是以 Java 實作的特殊垃圾處理機制,是非平行處理的世代清潔工,簡單來說,您可以將它視為 JDK 8 預設的垃圾回收機制的簡化版。它將 heap 分成世代,在稱為 eden 的世代中建立新物件 (見 Figure 1),然後被收集或提升到舊世代。

Figure 1. Garbage collector in native images

您可以為原生映像檔微調垃圾回收器的選項,通常,您可能想調整 heap 的最大值,您能用 -Xmx 參數配置它,如果您更清楚瞭解您的原生映像檔中的垃圾回收模式,您可以用 -R:+PrintGC-R:+VerboseGC 來取得回收前及回收後的垃圾回收器的資訊摘要。原生映像檔通常使用較少的記憶體,原因之一是它不需要動態載入新類別,為 reflection 或在執行期間編譯類別而儲存它們的 metadata。

結論

總之,GraalVM 原生映像檔為執行 Java 應用程式卻不需要載入 Java runtime 提供一個絕佳的機會,還提供幾乎是瞬間啟動,及非常少的 runtime 記憶體消耗,這對雲端布署希望能自動擴展,或是您在某些記憶體與運算有限制的 function as a service (FaaS) 環境中非常重要。

原生映像檔是 GraalVM 一個實驗功能,現在,您馬上能找到不是用的應用程式,但許多一般的應用程式都能使用,許多框架將 GraalVM 原生映像檔納入作為一個布署目標,簡化其使用。如果您布署您的應用到容器中,且重視啟動速度與較低的 runtime 記憶體消耗,您可能會發現 GraalVM 原生映像檔非常有用。

--

--