進擊的 Java ,云原生時代的蛻變
【編者的話】云原生時代的來臨,與Java 開發者到底有什么聯系?有人說,云原生壓根不是為了 Java 存在的。然而,本文的作者卻認為云原生時代,Java 依然可以勝任“巨人”的角色。作者希望通過一系列實驗,開拓同學視野,提供有益思考。
在企業軟件領域,Java 依然是絕對王者,但它讓開發者既愛又恨。一方面因為其豐富的生態和完善的工具支持,可以極大提升了應用開發效率;但在運行時效率方面,Java 也背負著”內存吞噬者“,“CPU 撕裂者“的惡名,持續受到 NodeJS、Python、Golang 等新老語言的挑戰。
在技術社區,我們經常看到有人在唱衰 Java 技術,認為其不再符合云原生計算發展的趨勢。先拋開上面這些觀點,我們首先思考一下云原生對應用運行時的不同需求:
體積更小:對于微服務分布式架構而言,更小的體積意味著更少的下載帶寬,更快的分發下載速度。
啟動速度更快:對于傳統單體應用,啟動速度與運行效率相比不是一個關鍵的指標。原因是,這些應用重啟和發布頻率相對較低。然而對于需要快速迭代、水平擴展的微服務應用而言,更快的的啟動速度就意味著更高的交付效率,和更加快速的回滾。尤其當你需要發布一個有數百個副本的應用時,緩慢的啟動速度就是時間殺手。對于Serverless 應用而言,端到端的冷啟動速度則更為關鍵,即使底層容器技術可以實現百毫秒資源就緒,如果應用無法在 500ms 內完成啟動,用戶就會感知到訪問延遲。
占用資源更少:運行時更低的資源占用,意味著更高的部署密度和更低的計算成本。同時,在 JVM 啟動時需要消耗大量 CPU資源對字節碼進行編譯,降低啟動時資源消耗,可以減少資源爭搶,更好保障其他應用 SLA。
支持水平擴展:JVM 的內存管理方式導致其對大內存管理的相對低效,一般應用無法通過配置更大的 heap size 實現性能提升,很少有 Java 應用能夠有效使用 16G 內存或者更高。另一方面,隨著內存成本的下降和虛擬化的流行,大內存配比已經成為趨勢。所以我們一般是采用水平擴展的方式,同時部署多個應用副本,在一個計算節點中可能運行一個應用的多個副本來提升資源利用率。
熱身準備
熟悉 Spring 框架的開發者大多對 Spring Petclinic 不會陌生。本文將借助這個著名示例應用來演示如何讓我們的 Java 應用變得更小、更快、更輕、更強大!
我們 fork 了 IBM 的 Michael Thompson 的示例,并做了一些調整。
- $ git clone https://github.com/denverdino/adopt-openj9-spring-boot
- $ cd adopt-openj9-spring-boot
首先,我們會為 PetClinic 應用構建一個 Docker 鏡像。在 Dockerfile 中,我們利用 OpenJDK 作為基礎鏡像,安裝 Maven,下載、編譯、打包 Spring PetClinic 應用,最后設置鏡像的啟動參數完成鏡像構建。
- $ cat Dockerfile.openjdk
- FROM adoptopenjdk/openjdk8
- RUN sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/' /etc/apt/sources.list
- RUN apt-get update
- RUN apt-get install -y \
- git \
- maven
- WORKDIR /tmp
- RUN git clone https://github.com/spring-projects/spring-petclinic.git
- WORKDIR /tmp/spring-petclinic
- RUN mvn install
- WORKDIR /tmp/spring-petclinic/target
- CMD ["java","-jar","spring-petclinic-2.1.0.BUILD-SNAPSHOT.jar"]
構建鏡像并執行:
- $ docker build -t petclinic-openjdk-hotspot -f Dockerfile.openjdk .
- $ docker run --name hotspot -p 8080:8080 --rm petclinic-openjdk-hotspot
- |\ _,,,--,,_
- /,`.-'`' ._ \-;;,_
- _______ __|,4- ) )_ .;.(__`'-'__ ___ __ _ ___ _______
- | | '---''(_/._)-'(_\_) | | | | | | | | |
- | _ | ___|_ _| | | | | |_| | | | __ _ _
- | |_| | |___ | | | | | | | | | | \ \ \ \
- | ___| ___| | | | _| |___| | _ | | _| \ \ \ \
- | | | |___ | | | |_| | | | | | | |_ ) ) ) )
- |___| |_______| |___| |_______|_______|___|_| |__|___|_______| / / / /
- ==================================================================/_/_/_/
- ...
- 2019-09-11 01:58:23.156 INFO 1 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
- 2019-09-11 01:58:23.158 INFO 1 --- [ main] o.s.s.petclinic.PetClinicApplication : Started PetClinicApplication in 7.458 seconds (JVM running for 8.187)
可以通過 http://localhost:8080/ 訪問應用界面。
檢查一下構建出的 Docker 鏡像, ”petclinic-openjdk-openj9“ 的大小為 871MB,而基礎鏡像 ”adoptopenjdk/openjdk8“ 僅有 300MB!這貨也太膨脹了!
- $ docker images petclinic-openjdk-hotspot
- REPOSITORY TAG IMAGE ID CREATED SIZE
- petclinic-openjdk-hotspot latest 469f73967d03 26 hours ago 871MB
原因是:為了構建 Spring 應用,我們在鏡像中引入了一系列編譯時依賴,如 Git,Maven 等,并產生了大量臨時的文件。然而這些內容在運行時是不需要的。
在著名的軟件12要素第五條明確指出了,”Strictly separate build and run stages.“ 嚴格分離構建和運行階段,不但可以幫助我們提升應用的可追溯性,保障應用交付的一致性,同時也可以減少應用分發的體積,減少安全風險。
鏡像瘦身
Docker 提供了 Multi-stage Build(多階段構建),可以實現鏡像瘦身。
我們將鏡像構建分成兩個階段:
- 在 ”build“ 階段依然采用 JDK 作為基礎鏡像,并利用 Maven 進行應用構建;
- 在最終發布的鏡像中,我們會采用 JRE 版本作為基礎鏡像,并從”build“ 鏡像中直接拷貝出生成的 jar 文件。這意味著在最終發布的鏡像中,只包含運行時所需必要內容,不包含任何編譯時依賴,大大減少了鏡像體積。
- $ cat Dockerfile.openjdk-slim
- FROM adoptopenjdk/openjdk8 AS build
- RUN sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/' /etc/apt/sources.list
- RUN apt-get update
- RUN apt-get install -y \
- git \
- maven
- WORKDIR /tmp
- RUN git clone https://github.com/spring-projects/spring-petclinic.git
- WORKDIR /tmp/spring-petclinic
- RUN mvn install
- FROM adoptopenjdk/openjdk8:jre8u222-b10-alpine-jre
- COPY --from=build /tmp/spring-petclinic/target/spring-petclinic-2.1.0.BUILD-SNAPSHOT.jar spring-petclinic-2.1.0.BUILD-SNAPSHOT.jar
- CMD ["java","-jar","spring-petclinic-2.1.0.BUILD-SNAPSHOT.jar"]
查看一下新鏡像大小,從 871MB 減少到 167MB!
- $ docker build -t petclinic-openjdk-hotspot-slim -f Dockerfile.openjdk-slim .
- ...
- $ docker images petclinic-openjdk-hotspot-slim
- REPOSITORY TAG IMAGE ID CREATED SIZE
- petclinic-openjdk-hotspot-slim latest d1f1ca316ec0 26 hours ago 167MB
鏡像瘦身之后將大大加速應用分發速度,我們是否有辦法優化應用的啟動速度呢?
從 JIT 到 AOT —啟動提速
為了解決 Java 啟動的性能瓶頸,我們首先需要理解 JVM 的實現原理。
為了實現“一次編寫,隨處運行”的能力,Java 程序會被編譯成實現架構無關的字節碼。JVM 在運行時將字節碼轉換成本地機器碼執行。這個轉換過程決定了 Java 應用的啟動和運行速度。為了提升執行效率,JVM 引入了 JIT compiler(Just in Time Compiler,即時編譯器),其中 Sun/Oracle 公司的 HotSpot 是最著名 JIT 編譯器實現。
HotSpot 提供了自適應優化器,可以動態分析、發現代碼執行過程中的關鍵路徑,并進行編譯優化。HotSpot 的出現極大提升了Java 應用的執行效率,在 Java 1.4 以后成為了缺省的 VM 實現。但是 HotSpot VM 在啟動時才對字節碼進行編譯,一方面導致啟動時執行效率不高,一方面編譯和優化需要很多的 CPU 資源,拖慢了啟動速度。我們是否可以優化這個過程,提升啟動速度呢?
熟悉 Java 江湖歷史的同學應該會知道 IBM J9 VM,它是用于 IBM 企業級軟件產品的一款高性能的 JVM,幫助 IBM 奠定了商業應用平臺中間件的霸主地位。2017 年 9 月,IBM 將 J9 捐獻給 Eclipse 基金會,并更名 Eclipse OpenJ9,開啟開源之旅。
OpenJ9 提供了 Shared Class Cache(SCC 共享類緩存)和 Ahead-of-Time(AOT 提前編譯)技術,顯著減少了 Java 應用啟動時間。
SCC 是一個內存映射文件,包含了J9 VM 對字節碼的執行分析信息和已經編譯生成的本地代碼。開啟 AOT 編譯后,會將 JVM 編譯結果保存在 SCC 中,在后續 JVM 啟動中可以直接重用。與啟動時進行的 JIT 編譯相比,從 SCC 加載預編譯的實現要快得多,而且消耗的資源要更少。啟動時間可以得到明顯改善。
我們開始構建一個包含 AOT 優化的 Docker 應用鏡像:
- $cat Dockerfile.openj9.warmed
- FROM adoptopenjdk/openjdk8-openj9 AS build
- RUN sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/' /etc/apt/sources.list
- RUN apt-get update
- RUN apt-get install -y \
- git \
- maven
- WORKDIR /tmp
- RUN git clone https://github.com/spring-projects/spring-petclinic.git
- WORKDIR /tmp/spring-petclinic
- RUN mvn install
- FROM adoptopenjdk/openjdk8-openj9:jre8u222-b10_openj9-0.15.1-alpine
- COPY --from=build /tmp/spring-petclinic/target/spring-petclinic-2.1.0.BUILD-SNAPSHOT.jar spring-petclinic-2.1.0.BUILD-SNAPSHOT.jar
- # Start and stop the JVM to pre-warm the class cache
- RUN /bin/sh -c 'java -Xscmx50M -Xshareclasses -Xquickstart -jar spring-petclinic-2.1.0.BUILD-SNAPSHOT.jar &' ; sleep 20 ; ps aux | grep java | grep petclinic | awk '{print $1}' | xargs kill -1
- CMD ["java","-Xscmx50M","-Xshareclasses","-Xquickstart", "-jar","spring-petclinic-2.1.0.BUILD-SNAPSHOT.jar"]
其中 Java 參數 -Xshareclasses 開啟SCC,-Xquickstart 開啟AOT。
在 Dockerfile 中,我們運用了一個技巧來預熱 SCC。在構建過程中啟動 JVM 加載應用,并開啟 SCC 和 AOT,在應用啟動后停止 JVM。這樣就在 Docker 鏡像中包含了生成的 SCC 文件。
然后,我們來構建 Docker 鏡像并啟動測試應用:
- $ docker build -t petclinic-openjdk-openj9-warmed-slim -f Dockerfile.openj9.warmed-slim .
- $ docker run --name hotspot -p 8080:8080 --rm petclinic-openjdk-openj9-warmed-slim
- ...
- 2019-09-11 03:35:20.192 INFO 1 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
- 2019-09-11 03:35:20.193 INFO 1 --- [ main] o.s.s.petclinic.PetClinicApplication : Started PetClinicApplication in 3.691 seconds (JVM running for 3.952)
- ...
可以看到,啟動時間已經從之前的 8.2s 減少到 4s,提升近50%。
在這個方案中,我們一方面將耗時耗能的編譯優化過程轉移到構建時完成,一方面采用以空間換時間的方法,將預編譯的 SCC 緩存保存到 Docker 鏡像中。在容器啟動時,JVM 可以直接使用內存映射文件來加載 SCC,優化了啟動速度和資源占用。
這個方法另外一個優勢是:由于 Docker 鏡像采用分層存儲,同一個宿主機上的多個 Docker 應用實例會共享同一份 SCC 內存映射,可以大大減少在單機高密度部署時的內存消耗。
下面我們做一下資源消耗的比較,我們首先利用基于 HotSpot VM 的鏡像,同時啟動 4 個 Docker 應用實例,30s 后利用docker stats查看資源消耗。
- $ ./run-hotspot-4.sh
- ...
- Wait a while ...
- CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
- 0fa58df1a291 instance4 0.15% 597.1MiB / 5.811GiB 10.03% 726B / 0B 0B / 0B 33
- 48f021d728bb instance3 0.13% 648.6MiB / 5.811GiB 10.90% 726B / 0B 0B / 0B 33
- a3abb10078ef instance2 0.26% 549MiB / 5.811GiB 9.23% 726B / 0B 0B / 0B 33
- 6a65cb1e0fe5 instance1 0.15% 641.6MiB / 5.811GiB 10.78% 906B / 0B 0B / 0B 33
- ...
然后使用基于 OpenJ9 VM 的鏡像,同時啟動 4 個 Docker 應用實例,并查看資源消耗。
- $ ./run-openj9-warmed-4.sh
- ...
- Wait a while ...
- CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
- 3a0ba6103425 instance4 0.09% 119.5MiB / 5.811GiB 2.01% 1.19kB / 0B 0B / 446MB 39
- c07ca769c3e7 instance3 0.19% 119.7MiB / 5.811GiB 2.01% 1.19kB / 0B 16.4kB / 120MB 39
- 0c19b0cf9fc2 instance2 0.15% 112.1MiB / 5.811GiB 1.88% 1.2kB / 0B 22.8MB / 23.8MB 39
- 95a9c4dec3d6 instance1 0.15% 108.6MiB / 5.811GiB 1.83% 1.45kB / 0B 102MB / 414MB 39
- ...
與 HotSpot VM 相比,OpenJ9 的場景下應用內存占用從平均 600MB 下降到 120MB。驚喜不驚喜?
通常而言,HotSpot JIT 比 AOT 可以進行更加全面和深入的執行路徑優化,從而有更高的運行效率。為了解決這個矛盾,OpenJ9 的 AOT SCC 只在啟動階段生效,在后續運行中會繼續利用JIT進行分支預測、代碼內聯等深度編譯優化。
HotSpot 在 Class Data Sharing (CDS) 和 AOT 方面也有了很大進展,但是 IBM J9 在這方面更加成熟。期待阿里的 Dragonwell 也提供相應的優化支持。
思考:與 C/C++,Golang, Rust 等靜態編譯語言不同,Java 采用 VM 方式運行,提升了應用可移植性的同時犧牲了部分性能。我們是否可以將 AOT 做到極致?完全移除字節碼到本地代碼的編譯過程?
原生代碼編譯
為了將 Java 應用編譯成本地可執行代碼,我們首先要解決 JVM 和應用框架在運行時的動態性挑戰。JVM 提供了靈活的類加載機制,Spring 的依賴注入(DI,Dependency-injection)可以實現運行時動態類加載和綁定。在 Spring 框架中,反射,Annotation 運行時處理器等技術也被廣泛應用。這些動態性一方面提升了應用架構的靈活性和易用性,另一方面也降低了應用的啟動速度,使得 AOT 原生編譯和優化變得非常復雜。
為了解決這些挑戰,社區有很多有趣的探索,Micronaut 是其中一個優秀代表。與 Spring 框架序不同,Micronaut 提供了編譯時的依賴注入和AOP處理能力,并最小化反射和動態代理的使用。Micronaut 應用有著更快的啟動速度和更低的內存占用。更加讓我們更感興趣的是 Micronaut 支持與 GraalVM 配合,可以將 Java 應用編譯成為本地執行代碼全速運行。
注:GraalVM 是 Oracle 推出的一種新型通用虛擬機,支持多種語言,可以將Java應用程序編譯為本地原生應用。
下面開始我們的探險,我們利用 Mitz 提供的 Micronaut 版本 PetClinic 示例工程并做了一點點調整。(使用 Graal VM 19.2)
- $ git clone https://github.com/denverdino/micronaut-petclinic
- $ cd micronaut-petclinic
其中 Docker 鏡像的內容如下:
- $ cat Dockerfile
- FROM maven:3.6.1-jdk-8 as build
- COPY ./ /micronaut-petclinic/
- WORKDIR /micronaut-petclinic
- RUN mvn package
- FROM oracle/graalvm-ce:19.2.0 as graalvm
- RUN gu install native-image
- WORKDIR /work
- COPY --from=build /micronaut-petclinic/target/micronaut-petclinic-*.jar .
- RUN native-image --no-server -cp micronaut-petclinic-*.jar
- FROM frolvlad/alpine-glibc
- EXPOSE 8080
- WORKDIR /app
- COPY --from=graalvm /work/petclinic .
- CMD ["/app/petclinic"]
其中:
- 在 "build" 階段,利用Maven構建 Micronaut 版本的 PetClinic 應用
- 在 "graalvm" 階段,我們通過 native-image 將 PetClinic jar 文件轉化成可執行文件
- 在最終階段,將本地可執行文件加入一個 Alpine Linux 基礎鏡像
構建應用:
- $ docker-compose build
啟動測試數據庫:
- $ docker-compose up db
啟動測試應用:
- $ docker-compose up app
- micronaut-petclinic_db_1 is up-to-date
- Starting micronaut-petclinic_app_1 ... done
- Attaching to micronaut-petclinic_app_1
- app_1 | 04:57:47.571 [main] INFO org.hibernate.dialect.Dialect - HHH000400: Using dialect: org.hibernate.dialect.PostgreSQL95Dialect
- app_1 | 04:57:47.649 [main] INFO org.hibernate.type.BasicTypeRegistry - HHH000270: Type registration [java.util.UUID] overrides previous : org.hibernate.type.UUIDBinaryType@5f4e0f0
- app_1 | 04:57:47.653 [main] INFO o.h.tuple.entity.EntityMetamodel - HHH000157: Lazy property fetching available for: com.example.micronaut.petclinic.owner.Owner
- app_1 | 04:57:47.656 [main] INFO o.h.e.t.j.p.i.JtaPlatformInitiator - HHH000490: Using JtaPlatform implementation: [org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform]
- app_1 | 04:57:47.672 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 159ms. Server Running: http://1285c42bfcd5:8080
應用啟動速度如閃電般提升至 159ms,僅有 HotSpot VM 的1/50!
Micronaut 和 Graal VM 還在快速發展中,遷移一個 Spring 應用還有不少工作需要考慮。此外 Graal VM 的調試、監控等工具鏈還不夠完善。但是這已經讓我們看到了曙光,Java 應用和 Serverless 的世界不再遙遠。
總結與后記
作為進擊的巨人,Java 技術在云原生時代也在不停地進化。在JDK 8u191 和 JDK 10 之后,JVM 增強了在 在 Docker 容器中對資源的感知。同時社區也在多個不同方向探索 Java 技術棧的邊界。JVM OpenJ9 作為傳統VM的一員,在對現有 Java 應用保持高度兼容的同時,對啟動速度和內存占用做了細致的優化,比較適于與現有 Spring 等微服務架構配合使用。
而 Micronaut/Graal VM 則另辟蹊徑,通過改變編程模型和編譯過程,將應用的動態性盡可能提前到編譯時期處理,極大優化了應用啟動時間,在 Serverless 領域前景可期。這些設計思路都值得我們借鑒。
在云原生時代,我們要能夠在橫向的應用開發生命周期中,將開發、交付、運維過程進行有效的分割和重組,提升研發協同效率;并且要能在整個縱向軟件技術棧中,在編程模型、應用運行時和基礎設施等多層面進行系統優化,實現 radical simplification,提升系統效率。
感謝這個時代,感謝所有幫助和支持我們的小伙伴,感謝所有追夢的技術人,我們一起開拓云原生的未來。