Quarkus-雲原生時代Java的曙光?

語言: CN / TW / HK

00 引言

至今已滿27歲的Java語言已經長期佔據服務端程式語言開發榜的榜首,無論是從生產環境的部署規模,還是從在開發者群體中的受歡迎程度來看,Java都擁有絕對的“統治”地位。龐大的開發者基礎、豐富完善的類庫和生態、以及大規模的線上服務和應用都使得Java擁有其他程式語言難以超越的優勢,也奠定了Java如今的地位。但居安思危,Java能否一直保持這種領先優勢以及如何保持這種優勢地位是包括Oracle官方在內的所有開發者應該思考的問題,也值得每位使用Java作為生產力工具的開發者關注。

01 雲原生時代下Java的挑戰

(1)Compile Once,Run AnyWhere

Java誕生之初,得益於虛擬機器的優勢,使得“一次編譯,到處執行”成為其最響亮的Slogan,在一定程度上加速了Java的火熱,然而這種根植在Java基因中的優勢在“容器時代”正在逐漸被淡化,至少不像之前那樣重要。在以前,如果需要一套程式碼同時執行在Linux、Solaris、Windows等系統之上,那開發人員不得不考慮不同平臺甚至不同指令集之間的差異性,而Java虛擬機器恰好將開發/運維人員從這種“苦海”中解脫了出來。但隨著容器等雲原生技術的普及,能夠包含底層作業系統和程式程式碼的映象成為了一種更加標準化和通用的交付產品,並且開發人員只需要修改幾行Dockerfile就能修改程式的執行時環境。在雲原生時代,通過映象遮蔽底層系統的差異,以極低的成本更新程式的執行環境等,這些都削弱了Java的傳統優勢特性。

(2)啟動耗時&記憶體佔用

無論是從JVM垃圾回收器的演進方向還是從JIT優化的角度來看,這都表明Java更適用於大規模、長時間執行的應用,對於執行時間很短或者需要頻繁更新的應用來說,Java難免會表現出一些不適應。但隨著Kubernetes等技術的飛速發展,應用的實時更新、藍綠髮布、動態擴縮容相比以前更加容易實現,而這些功能都需要應用頻繁啟停,不可能像之前一樣長時間執行。從啟動過程來看,Java程式在啟動時需要執行虛擬機器初始化、位元組碼檔案載入解析、JIT預熱等功能,這些都使得啟動時間過長,難以在毫秒級時間內完成。從系統資源佔用的角度來看,Java程式必須執行在虛擬機器之上,虛擬機器執行所必需的資源即是應用程式執行時佔用資源的下限,一個簡單的Spring Web應用即使在沒有流量的情況下也會達到百兆級別的記憶體佔用。

Java程式啟動耗時,圖片來源:周志明-雲原生時代的Java

從上圖可以看出:應用程式在啟動過程中,類載入和JIT編譯消耗了比較多的時間,並且在容器環境中,每個Java應用程式啟動時都需要先啟動Docker容器,然後在Docker內啟動JVM,最後JVM再載入應用,整個過程的耗時將進一步增加。

在當前炙手可熱的Serverless領域,Java應用的一些特性似乎都跟Serverless的設計理念背道而馳,比如:Java本身適合大規模長時間執行的應用,而Serverless要求應用程式儘量快速的執行完,AWS的Lambda的最長執行時間僅為15min;Java在啟動時執行的一系列操作導致啟動耗時較長,而Serverless按需極速擴容的特點卻要求應用必須在儘量短的時間內啟動。在Servlerless領域,“冷啟動”一直是個“火熱”的話題,如何保證函式的第一個請求能在儘量短的時間內完成計算並返回結果一直是大家反覆探討的話題,這個過程中涉及到準備Pod和映象、打通網路、啟動容器及應用,執行函式等多個步驟,如果應用本身的啟動時間過長,那麼無疑會增加“冷啟動”耗時,甚至會成為“冷啟動”優化的瓶頸。

02 Quarkus入局

面對危機和挑戰,Oracle官方提出了很多“面向未來的變革”專案以保持Java未來的活力與競爭力,包括:Leyden、Valhalla、Loom、Portola等,此處只介紹相關名詞,感興趣的讀者可以自行查閱相關資料。除此之外,社群和開發者也都在它們在各自的領域給出了一些優秀的解決方案,比如Quarkus—雲原生時代的Java框架。

(1)Quarkus簡介

Quarkus是由Red Hat於2018年開始研發的一款面向雲原生的開源Java框架,旨在使 Java 成為 Kubernetes 和無服務環境中的領先平臺,目前Star數已接近1W,累計釋出了168個版本,有超過620位開發者貢獻了程式碼,最新版本為V2.8.1。主要特點是:

1)雲原生:支援通過GraalVM native-image將Java應用打包成可執行的本地二進位制映象,減少記憶體使用、縮短應用啟動時間

2)低使用成本:遵循已有的標準,相容常用的框架,如:Spring、Hibernate、Netty、RestEasy等,無需學習新的標準和規範

3)高開發效率:支援程式碼熱更新,無需重啟即可檢視程式碼改動後的執行結果(dev環境下)

4)同時支援命令式和響應式程式碼

5)支援同時執行在GraalVM和HotSpot兩種虛擬機器上

Quarkus執行時記憶體、啟動時間對比,圖片來源:Quarkus官網

(2)Quarkus基本原理

Quarkus是基於GraalVM進行設計和開發的,因此只要我們理解了GraalVM的基本原理,對Quarkus的工作原理的理解也就水到渠成了。

GraalVM是Oracle釋出的通用型虛擬機器,可以用來執行Java程式,被稱為下一代Java虛擬機器,於2016年6月釋出第一個release版本。主要特點有:

1)高效能:GraalVM 的高效能AOT(Ahead Of Time,執行前編譯)編譯器支援在構建階段生成可直接執行的原生代碼,得益於一系列高階的編譯器優化和積極的內聯技術,使得生成的原生代碼執行速度更快,產生的垃圾物件和佔用的 CPU均更少,可極大降低雲和基礎設施的成本。同時,由於沒有使用JIT執行時優化,程式在啟動時即可達到峰值效能,不需要預熱時間。

AOT編譯過程 ,圖片來源自網路

從上圖可以看出,AOT編譯主要分為靜態分析和提前編譯兩個階段。其中,靜態分析階段主要是利用程式碼的可達性分析將執行期間不會用到的類排除在打包之外,以此來減少打包後的程式碼體積;提前編譯階段主要是將程式程式碼和執行時所需的環境打包成本地二進位制檔案並執行初始化程式碼以及將結果儲存為堆映象,程式在真正執行時直接基於堆映象啟動,以減少程式啟動耗時。

2)快速啟動、減少記憶體使用:GraalVM 0.20版本開始出現的一個極小型(相比於HotSpot)的執行時環境Substrate VM,其具有如下特點:1. 完全脫離了HotSpot虛擬機器,擁有獨立的執行時,包含異常處理、同步、執行緒管理、記憶體管理(垃圾回收)和JNI等元件;2. 在AOT編譯時儲存初始化好的堆記憶體快照,並支援以此為入口直接開始執行,避免重複執行初始化程式碼,以縮短啟動時間。

記憶體佔用對比 圖片源自GraalVM官網

從上圖可知,在利用GraalVM將Java應用打包成Native Image之後,執行時佔用的記憶體約為在傳統HotSpot上的五分之一左右。

說明:圖中的Helidon、Micronaut為不同組織基於GraalVM開發的雲原生Java 框架,功能與Quarkus類似,本文不做額外介紹。

啟動時間對比,圖片源自GraalVM官網

在利用GraalVM將應用打包成Native Image之後,應用啟動的時間約為在傳統HotSpot模式下的五十分之一。

3)多語言:支援多種語言編寫的程式執行在GraalVM上並且支援多種語言之間互相呼叫

GraalVM多語言架構,圖片源自GraalVM官網

GraalVM底層是將其他語言也編譯成class檔案,然後通過執行引擎進行解釋執行,以此來支援多種程式語言。

得益於GraalVM技術的發展,Helidon、Micronaut、Spring Native、Quarkus等雲原生Java框架應運而生,而Quarkus是這些“新星”中最耀眼的一個。其在GraalVM能力的基礎上提供了:

A.豐富的程式設計介面,以方便開發人員利用GraalVM提供的基礎能力;

B.同時支援命令式和響應式的程式設計模型;

C.程式碼熱更新的能力,支援實時更新程式碼,無需重啟即可生效;

D.豐富的生態:提供多種常用開發框架的適配包,開箱即用,如:Netty、RestEasy、Vertx、Hibernate等並且支援開發者對自定義應用編寫Quarkus擴充套件;

GraalVM和Quarkus等技術提供了一種全新的思路來解決Java在雲原生領域遇到的困境和挑戰,讓廣大Java開發者看到了Java在雲原生領域的一絲曙光。

03 Restlight的實踐之路

上篇我們主要介紹了Quarkus的主要功能及底層實現原理,相信大家對Quarkus已經有了初步的瞭解。下面我們將圍繞OPPO開源Web框架Restlight的Quarkus實踐過程,分析在此過程中我們遇到的困難及相應的解決方案、最終達成的結果等。

Restlight是OPPO開源的一款輕量級、高效能的Web框架,目前已經在公司內外大規模使用。在Restlight作為公司FaaS平臺的Runtime實現之後,我們迫切地希望基於Restlight開發的FaaS函式服務能佔用儘量小的記憶體和CPU資源以節省業務執行成本,此外,我們還希望函式能快速的啟動,以減少“冷啟動”時間。經過前期的調研和選型,最終我們決定基於Quarkus開發Restlight的擴充套件模組,並將業務開發的FaaS函式打包成Native Image執行。經過實踐,基於Quarkus擴充套件的Restlight應用在啟動時間上相比傳統HotSpot模式縮短了30倍,低負載執行時的記憶體縮小了10倍。

(1)自定義Quarkus擴充套件

傳統的Java程式執行時空間是開放的,即完全可以在執行時動態地載入配置、類等資源並進行初始化,但Quarkus進行Native Image打包時,需要在構建過程中將程式執行時用到的所有類、資原始檔等初始化好並以堆記憶體快照的形式儲存起來,即Native Image要求程式的執行空間必須是封閉的。這就導致動態載入其他類庫、反射、動態代理和CGlib代理等功能無法正常使用,需要開發人員利用Quarkus提供的一些高階特性,通過配置或者編碼的方式在靜態分析階段對這些類和檔案資源進行特殊的處理。

(2)SPI載入

Java的SPI提供了一種靈活、可擴充套件的機制來載入外部實現類,底層是依賴反射來實現的。Restlight基於分層架構設計和可擴充套件性的考慮,在內部大量使用了自定義的SPI載入機制,用來載入業務自定義的Filter、ParamResolver、ParamResolverAdvice、Interceptor、HandlerAdvice、ResponseEntityResolver、ResponseEntityResolverAdvice等,可以認為SPI載入機制是融入Restlight基因中的設計之一。但這些介面的實現類都是寫入配置檔案中,在執行時動態載入的,Quarkus本身無法在構建階段通過靜態程式碼分析感知到,因此如何使得這些實現類被應用程式感知到就成了我們在編寫Quarkus擴充套件中面臨的第一個問題。在分析了Restlight自定義SPI檔案的路徑和命名規則之後,我們通過自定義檔案掃描器的方式在構建階段將所有符合規則的SPI檔案內容解析出來並封裝成Quarkus需要的ReflectiveClassBuildItem形式,而後Quarkus在靜態分析階段自動將上述封裝的類新增到靜態程式碼分析的結果中去,以保證程式在執行期間可以正常找到需要的Class檔案。具體程式碼如下:

@BuildStepList<ReflectiveClassBuildItem> reflections() throws IOException, ClassNotFoundException {    Set<String> classNameSet = new HashSet<>();
   // reflection-configs from restlight-core.    for (ReflectedClassInfo classInfo : ReflectionInfoUtil.loadReflections("restlight-core",            Restlight.class)) {        classNameSet.add(classInfo.getName());    }
   List<ReflectiveClassBuildItem> reflections = new LinkedList<>();    for (String className : classNameSet) {        LOGGER.info("Load refection(" + className + ") when build quarkus-restlight-core");        reflections.add(new ReflectiveClassBuildItem(true, true, Class.forName(className)));    }    return reflections;}

在ReflectionInfoUtil#loadReflections方法中,我們通過載入和解析META-INF下面的/esa和/esa/internal中以io.esastack.restlight.xxx開頭的檔案並將解析出的實現類封裝ReflectiveClassBuildItem。在實際的程式碼實現中,我們除了載入Restlight本身自定義的SPI資原始檔之外,同樣需要考慮Restlight依賴的httpserver、commons-net等專案的SPI檔案的載入。此外,為了支援在不使用Quarkus時,Restlight能基於原生的GraalVM將應用打包成Native Image的功能,我們在Restlight原始碼的/META-INF/native-image目錄下配置了GraalVM用於打包Native Image所需的資原始檔,包括反射類的配置,因此實際程式碼中我們是載入和解析該部分配置檔案的內容並將其封裝成Quarkus所需的ReflectiveClassBuildItem,但這並不影響讀者瞭解Restlight在Quarkus場景下處理反射呼叫的思路和方法。

需要說明地是,對於Restlight自身的反射呼叫,我們通過開發Quarkus擴充套件的形式已經做了處理,但是對於業務程式碼中使用到的反射,卻無法在框架層面做統一的處理,這部分需要業務自行處理。原生的GraalVM要求將反射用到的資源配置在指定的資原始檔中,而Quarkus則提供了更加方便的功能,比如通過RegisterForReflection註解宣告指定的類需要加入靜態分析掃描的結果集。從這裡也可以看出,Quarkus提供了對開發者更加友好和靈活的方式以便於利用GraalVM的各種高階特性。對於難以識別的反射呼叫,GraalVM還提供了native-image-agent,只需要將原有應用以JVM的方式啟動和執行,該agent即可自動收集執行期間所有用到的反射、動態代理、JNI、序列化等資源並列印到指定的檔案。但需要注意地是,該方式僅能識別程式執行期間已經使用到的反射、動態代理等資源,而不是全部,因此對於複雜的Java應用需要謹慎評估將應用打包成Native Image之後的系統穩定性風險。RegisterForReflection使用示例:

@RequestMapping("/hello/springmvc")@RegisterForReflectionpublic class HelloController {
   @RequestMapping    public String index() {        return "Hello Restlight Quarkus(SpringMVC)";    }
}

如上,HelloController將會在構建階段被自動地新增到靜態程式碼分析的結果中去,無需其它配置。

(3)延遲初始化

Quarkus在打包Native Image過程中將執行部分初始化的操作,比如執行靜態變數的初始化和靜態程式碼塊等並將執行的結果儲存到Native-Image Heap中,在真正執行時將直接從Native-Image Heap中獲取儲存的結果,以此來減少啟動時的類載入和初始化操作,實現縮短啟動時間的目的,但這種構建時的類初始化的操作並不都是安全的,比如:

class A {    static B b = new B();}
class B {    static {        C.doSomething();    }}
class C {    static long currentTime;    static {        currentTime=System.currentTimeMillis();    }  static void doSomething(){…}}

如上程式碼所示,在構建階段執行類初始化得到的currentTime明顯要小於執行時首次載入C類時得到的值,因此必須將C類延遲到執行時初始化,並且由於級聯的依賴關係,可能導致C類構建時初始化的A類和B類也需要同時宣告進行延遲初始化。

在Restlight中同樣存在不安全的類初始化,需要延遲到執行時進行初始化,比如:

1)靜態變數提前初始化導致的不安全

public final class Platforms {
   private static final int NCPU = Runtime.getRuntime().availableProcessors();    private static final boolean IS_LINUX = isLinux0();    private static final boolean IS_WINDOWS = isWindows0();    private static final int JAVA_VERSION = getJavaVersion();
   private Platforms() {    }}

如上,在Platforms類的初始化階段,需要獲取跟當前執行環境相關的系統資源,Java版本等資訊。如果在構建階段初始化並儲存對應的結果顯然會導致無法預知的執行時異常,因為構建時的執行環境與實際執行環境經常是不相同的。

2)靜態程式碼塊提前初始化導致的不安全

public class NioTransport implements Transport {
   static NioTransport INSTANCE = new NioTransport();
   private static final boolean USE_UNPOOLED_ALLOCATOR;
   static {        USE_UNPOOLED_ALLOCATOR =                SystemPropertyUtil.getBoolean("io.esastack.httpserver.useUnpooledAllocator", false);        if (Loggers.logger().isDebugEnabled()) {            Loggers.logger().debug("-Dio.esastack.httpserver.useUnpooledAllocator: {}", USE_UNPOOLED_ALLOCATOR);        }    }    }

如上,在NioTransport類中,我們在靜態程式碼塊中通過讀取系統屬性的值來決定是否使用池化,但是如果在構建階段就執行該程式碼塊並儲存結果顯然是不符合預期的,原因同上,這裡不再贅述。

從以上兩種情形可以看出,Restlight在編寫擴充套件時必須明確識別出可能導致執行時異常的提前類初始化並通過指定的方式將識別出來的類配置成執行時初始化,相比於編寫程式碼,如何完整且準確地識別出不安全的提前類初始化及其相應的級聯依賴關係更具難度和挑戰性,因為這需要開發人員熟悉框架所有的程式碼功能及常見的不安全提前初始化的場景。只要能夠分析出不安全的提前初始化,解決該問題就相對簡單了,如下所示:

@BuildStepList<RuntimeInitializedClassBuildItem> runtimeInitializedClass() {    List<RuntimeInitializedClassBuildItem> runtimeInitializedClasses = new LinkedList<>();
   runtimeInitializedClasses.add(new RuntimeInitializedClassBuildItem(Platforms.class.getName()));    runtimeInitializedClasses.add(new RuntimeInitializedClassBuildItem(BufferUtil.class.getName()));
   return runtimeInitializedClasses;}

將需要執行時初始化的類封裝成RuntimeInitializedClassBuildItem形式並通過@BuildStep明確告知Quarkus在執行時初始化即可。

(4)資原始檔

Restlight中存在大量配置SPI實現類的資原始檔,這些資原始檔預設不會被Quarkus打包到Native Image中,因此必須由開發人員自行處理。Restlight在自定義Quarkus擴充套件時做了如下處理:

@BuildStepList<NativeImageResourceBuildItem> nativeImageResourceBuildItems() {    List<NativeImageResourceBuildItem> resources = new LinkedList<>();    resources.add(new NativeImageResourceBuildItem(            "META-INF/native-image/io.esastack/commons-net-netty/resource-config.json"));
   resources.add(new NativeImageResourceBuildItem(            "META-INF/native-image/io.esastack/restlight-common/resource-config.json"));
   resources.add(new NativeImageResourceBuildItem(            "META-INF/native-image/io.esastack/restlight-core/resource-config.json"));
   resources.add(new NativeImageResourceBuildItem(            "META-INF/native-image/io.esastack/restlight-server/resource-config.json"));
   return resources;}

將定義Resource資源的檔案封裝成NativeImageResourceBuildItem並返回,具體的Resource資原始檔定義如下:

{  "resources":{    "includes":[      {"pattern":".*/io.esastack.commons.net.buffer.BufferAllocator$"},      {"pattern":".*/io.esastack.commons.net.internal.buffer.BufferProvider$"},      {"pattern":".*/io.esastack.commons.net.internal.http.CookieProvider$"}    ]},  "bundles":[]}

如上所示,

{"pattern":".*/io.esastack.commons.net.buffer.BufferAllocator$"}表示匹配當前路徑下所有以io.esastack.commons.net.buffer.BufferAllocator結尾的資原始檔路徑,Resource資原始檔的更多用法請參考官方文件,此處不做過多的語法介紹。

需要注意地是,Quarkus在使用Native Image進行打包時對於同名的檔案內容將採取覆蓋而非合併的策略,這將導致應用執行時錯誤,必須進行特殊處理。比如:Restlight通過SPI資原始檔載入ParamResolver,由於採用分層的架構設計,核心層以及SpringMVC和JAX-RS的適配層擁有各自不同的ParamResolver實現類,但檔名稱卻是相同的,如果採用預設的覆蓋策略將導致部分ParamResolver無法生效,因此必須修改預設的覆蓋方式,進行合併處理,如下所示:

@BuildStepList<UberJarMergedResourceBuildItem> mergedResources() throws IOException {    List<UberJarMergedResourceBuildItem> mergedResources = new LinkedList<>();    Set<String> spiPathSet = new HashSet<>();    spiPathSet.addAll(SpiUtil.getAllSpiPaths(BaseRestlightServer.class));    spiPathSet.addAll(SpiUtil.getAllSpiPaths(Restlight.class));    spiPathSet.addAll(SpiUtil.getAllSpiPaths(UnpooledNettyBufferAllocator.class));    for (String spiPath : spiPathSet) {        LOGGER.info("Add mergedResources:" + spiPath);        mergedResources.add(new UberJarMergedResourceBuildItem(spiPath));    }    return mergedResources;}

(5)Unsafe使用

在Java中Unsafe是一個高效的處理併發安全的類,提供了很多高效的方法,但是其中的某些方法在打包成Native Image後可能導致無法預知的錯誤,必須進行特殊處理,比如用於獲取某個屬性在物件中偏移量的Unsafe#objectFieldOffset方法。Quarkus在將應用打包成Native Image之後將導致物件記憶體結構的改變,因此在構建階段計算得到的偏移量在執行期間將不再準確,需要重新進行計算或者直接禁用該方法。對於直接使用的Unsafe#objectFieldOffset、Unsafe#arrayBaseOffset、Unsafe#arrayIndexScale等方法,Quarkus會在構建階段自動標記該方法的使用,並在實際執行階段進行重新計算,但對於間接使用該方法的情形,自動標記的功能將不再生效,需要開發人員自行處理。Restlight在實際處理過程中,採用了和Netty相同的做法,即在打包成Native Image的場景下直接禁用該功能,如下:

// See https://github.com/oracle/graal/blob/master/sdk/src/org.graalvm.nativeimage/src/org/graalvm/nativeimage/// ImageInfo.javaprivate static final boolean RUNNING_IN_NATIVE_IMAGE = ConfigUtils.get().getStr(        "org.graalvm.nativeimage.imagecode") != null;
if (unsafeStaticFieldOffsetSupported() && UnsafeUtils.hasUnsafe()) {    valueFieldOffset = UnsafeUtils.objectFieldOffset(abstractStringBuilder, "value");}
private static boolean unsafeStaticFieldOffsetSupported() {    return !RUNNING_IN_NATIVE_IMAGE;}

通過GraalVM打包成Native Image時寫入的系統屬性來判斷當前應用是否執行在二進位制打包環境下,如果是的話則避免使用Unsafe#objectFieldOffset方法。需要注意地是,Restlight在程式碼中使用UnsafeUtils對Unsafe的操作進行了封裝,因此可能會使得Quarkus的自動標記功能失效,所以才在框架層面做了容錯的處理,對於直接使用Unsafe#objectFieldOffset的場景則無需額外處理。

(6)其他

Restlight的高效能是在完全壓榨Netty效能的基礎上取得的,換句話說Netty是Restlight高效能的重要基石。在Restlight編寫Quarkus擴充套件的過程中,如何處理Netty相關的部分也是我們非常關注的問題,好在無論是Netty官方還是Quarkus社群都在這方面做出來積極的探索和嘗試,提供了Netty自身打包成Native Image的解決方案和程式碼實現,這也為Restlight編寫Quarkus擴充套件掃清了障礙。此處,僅介紹兩個本文尚未提及的解決Native Image打包過程中相關問題的解決方案:

1)方法替代

在前文中我們使用延遲初始化的方法解決類初始化時不安全的問題,Quarkus同樣支援使用方法替代的方式來解決該問題,如下:

@TargetClass(className = "io.netty.util.internal.logging.InternalLoggerFactory")final class Target_io_netty_util_internal_logging_InternalLoggerFactory {
   @Substitute    private static InternalLoggerFactory newDefaultFactory(String name) {        return JdkLoggerFactory.INSTANCE;    }}

在上述程式碼中,@Substitute表示替換@TargetClass中同名的靜態方法,即InternalLoggerFactory在構建階段執行newDefaultFactory(String)方法時將直接返回JdkLoggerFactory.INSTANCE而不是基於當前classpath路徑下的日誌元件包進行判斷。

2)重新計算屬性的偏移量

在解決Unsafe中的偏移量計算問題時,我們通過避免使用相關方法來解決構建之後偏移量不準確的問題,Quarkus同樣提供了一種更靈活的方式來解決該問題—重新計算,如下:

@TargetClass(className = "io.netty.util.AbstractReferenceCounted")final class Target_io_netty_util_AbstractReferenceCounted {
   @Alias    @RecomputeFieldValue(kind = RecomputeFieldValue.Kind.FieldOffset, name = "refCnt")    private static long REFCNT_FIELD_OFFSET;}

上述程式碼表示,Unsafe在構建時將重新計算io.netty.util.AbstractReferenceCounted中refCnt的記憶體偏移量。

 (7)實踐結果

上文我們詳細介紹了Restlight在編寫Quarkus過程中遇到的挑戰和相應的解決方案,感興趣的讀者可以通過quarkus-restlight檢視相關原始碼。

我們基於Restlight編寫簡單的Web應用,並分別測試在HotSpot和Native Image模式下,應用的啟動時間、啟動時的記憶體佔用、壓測場景下的TPS、CPU和記憶體佔用情況。

(8)測試環境

CentOS物理機(6c 12g)環境下的對比測試

(9)啟動時間&記憶體佔用

HotSpot模式下應用的啟動時間分別為:608ms、639ms、672ms,啟動時記憶體佔用約為362MB

Native Image打包後應用的啟動時間分別為:17ms、16ms、18ms,啟動時記憶體佔用約為22MB

從以上資料大致可以得出結論:在將簡單的基於Restlight開發的Web應用打包成Native Image之後,啟動時間可以縮短為原來的1/40,啟動時的記憶體消耗大概為HotSpot模式下的1/16。

(10)效能測試結果

使用wrk分別壓測同一個介面在Native Image和HotSpot場景下的效能

壓測引數:./wrk -t64 -c200 -d300s

HotSpot模式下的兩次結果分別為:

# Round 1Running 5m test @ http://127.0.0.1:9999/hello/springmvc  64 threads and 200 connections  Thread Stats   Avg      Stdev     Max   +/- Stdev    Latency     2.14ms    2.74ms 113.28ms   90.53%    Req/Sec     1.89k   332.69     5.68k    70.38%  36192976 requests in 5.00m, 3.81GB readRequests/sec: 120603.33Transfer/sec:     13.00MB
# Round 2Running 5m test @ http://127.0.0.1:9999/hello/springmvc  64 threads and 200 connections  Thread Stats   Avg      Stdev     Max   +/- Stdev    Latency     2.29ms    3.52ms 246.11ms   92.10%    Req/Sec     1.82k   345.90     6.77k    71.62%  34791685 requests in 5.00m, 3.66GB readRequests/sec: 115933.97Transfer/sec:     12.49MB

Native Image模式下的兩次結果為:

# Round 1Running 5m test @ http://127.0.0.1:9999/hello/springmvc  64 threads and 200 connections  Thread Stats   Avg      Stdev     Max   +/- Stdev    Latency     2.56ms    3.12ms 202.59ms   90.75%    Req/Sec     1.50k   295.64     5.07k    71.20%  28702092 requests in 5.00m, 3.02GB readRequests/sec:  95641.48Transfer/sec:     10.31MB
# Round 2Running 5m test @ http://127.0.0.1:9999/hello/springmvc  64 threads and 200 connections  Thread Stats   Avg      Stdev     Max   +/- Stdev    Latency     2.58ms    3.14ms 206.28ms   90.73%    Req/Sec     1.49k   292.98     7.83k    70.26%  28533013 requests in 5.00m, 3.00GB readRequests/sec:  95078.52Transfer/sec:     10.25MB

在壓測過程中,我們也記錄了應用程序在不同場景下佔用的CPU和記憶體情況:HotSpot模式下,程序記憶體佔用穩定在430MB左右,CPU利用率為300%左右;Native Image模式下,程序佔用記憶體在290MB~550MB之間週期性變化,CPU利用率為350%左右。

根據上述結果,我們可以大致得出以下結論:在將應用打包成Native Image之後,應用的TPS大致為HotSpot模式下的80%,同時在大流量場景下會消耗更多的CPU和記憶體資源。

04 總結

本文首先介紹了雲原生Java框架Quarkus的主要功能與底層原理,再以OPPO開源的Web框架Restlight的實踐過程為例,詳細介紹了在此過程中遇到的一些問題及相應的解決方案,在深入分析相應問題的原因及解決方案之後,相信讀者對Quarkus的內部實現原理也會有更深刻的理解。最後,我們通過對比測試的方式,瞭解了Quarkus在將應用打包成Native Image之後的帶來的啟動時間和記憶體佔用上的巨大提升,同時也認識到在大流量場景下Native Image將導致TPS的下降,相比HotSpot模式下消耗更多的記憶體和CPU資源。

綜合來看,Quarkus解決了Java在雲原生環境下面臨的一些挑戰,具有如下特點:

(1)高效能:支援將通過AOT編譯以及將應用打包成Native Image,解決了HotSpot模式下Java應用映象體積大、啟動時間長、記憶體佔用大的問題,帶來了數十倍的啟動時間和記憶體優化效果。

(2)雲原生:Quarkus帶來的啟動時間和記憶體佔用的優化,使其更適合雲原生的應用場景,特別是Serverless領域。

(3)生態完善:提供了大量開箱即用的資料訪問框架、訊息和快取中介軟體、Spring框架API、Web框架等的擴充套件支援,降低了開發成本。

但世界上沒有“銀彈”,Quarkus也不例外,要想將Quarkus真正應用到生產環境並非易事。主要原因如下:

(4)開發難度大:Native Image要求應用程式的執行時空間是封閉的,因此需要開發人員識別出執行期間用到的所有反射、動態代理、JNI等,對於複雜的應用來說這通常難度很大,甚至難以實現。

系統穩定性風險增加:構建時進行類初始化的操作可能導致難以識別的執行時錯誤,需要開發人員謹慎對待,否則將出現詭異的Bug,導致系統執行時的穩定性風險增大。

(5)監控能力的缺失:在Native Image模式下,應用完全脫離JVM執行,jmap、jstack等命令以及其他的效能診斷工具將無法使用,而Quarkus目前提供的效能分析工具還不成熟。監控和診斷能力的缺失,也是開發人員需要面臨的一個問題。

Quarkus帶來了一種新的思路和解決方案,以試圖解決Java在雲原生時代面臨的一些困難與挑戰,目前看來還不太成熟,在生產環境使用仍面臨一些難題,這也許是Quarkus目前社群活躍,但並不為廣大Java開發者熟知的原因。但無論如何,Quarkus仍然帶來了一種新的思路,讓開發人員看到了雲原生時代Java的曙光,並且我們相信技術總是向前發展的,期待廣大開發者一起參與社群建設,助力Quarkus日趨完善,最終從Quarkus中受益。

 

附錄

[1]Quarkus文件:https://quarkus.io/

[2]GraalVM文件:https://www.graalvm.org/

[3]Restlight:https://github.com/esastack/esa-restlight

[4]Quarkus Restlight:

https://github.com/esastack/quarkus-restlight

[5]周志明-雲原生時代的Java:

https://time.geekbang.org/column/article/321185


作者簡介

Mkabaka  OPPO高階後端工程師

2018年加入OPPO,先後負責公司Web框架、服務治理框架、Http通訊元件的設計和開發工作,目前正積極參與OPPO開源社群ESA Stack的建設和推廣


本文分享自微信公眾號 - OPPO數智技術(OPPO_tech)。
如有侵權,請聯絡 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。