雲原生時代的Java應用優化實踐
Java從誕生至今已經走過了26年,在這26年的時間裡,Java應用從未停下腳步,從最開始的單機版到web應用再到現在的微服務應用,依靠其強大的生態,它仍然佔據著當今語言之爭的“天下第一”的寶座。但在如今的雲原生serverless時代,Java應用卻遭遇到了前所未有的挑戰。
在雲原生時代,雲原生技術利用各種公有云、私有云和混合雲等新型動態環境,構建和執行可彈性擴充套件的應用。而我們應用也越來越呈現出以下特點:
- 基於容器映象構建
Java誕生之初,靠著“一次編譯,到處執行”的口號,以語言層虛擬化的方式,在那個作業系統平臺尚不統一的年代,建立起了優勢。但如今步入雲原生時代,以Docker為首的容器技術同樣提出了“一次構建,到處執行”的口號,通過作業系統虛擬化的方式,為應用程式提供了環境相容性和平臺無關性。因此,在雲原生時代的今天,Java“一次編譯,到處執行”的優勢,已經被容器技術大幅度地削弱,不再是大多數服務端開發者技術選型的主要考慮因素了。此外,因為是基於映象,雲原生時代對映象大小可以說是十分敏感,而包含了JDK的Java應用動輒幾百兆的映象大小,無疑是越來越不符合時代的要求。
- 生命週期縮短,並經常需要彈性擴縮容
靈活和彈性可以說是雲原生應用的一個顯著特性,而這也意味著應用需要具備更短的冷啟動時間,以應對靈活彈性的要求。Java應用往往面向長時間大規模程式而設計,JVM的JIT和分層編譯優化技術,會使得Java應用在不斷的執行中進行自我優化,並在一段時間後達到效能頂峰。但與執行效能相反,Java應用往往有著緩慢的啟動時間。流行的框架(例如Spring)中大量的類載入、位元組碼增強和初始化邏輯,更是加重了這一問題。這無疑是與雲原生時代的理念是相悖的。
- 對計算資源用量敏感
進入公有云時代,應用往往是按用量付費,這意味著應用所需要的計算資源就變的十分重要。Java應用固有的記憶體佔用多的劣勢,在雲原生時代被放大,相對於其他語言,使用起來變得更加“昂貴”。
由此可見,在雲原生時代,Java應用的優勢正在不斷被蠶食,而劣勢卻在不斷的被放大。因此,如何讓我們的應用更加順應時代的發展,使Java語言能在雲原生時代發揮更大的價值,就成了一個值得探討的話題。為此,筆者將嘗試跳出語言對比的固有思路,為大家從一個更全域性的角度,來看看在雲原生應用釋出的全流程中,我們都能夠做哪些優化。
映象構建優化
Dockerfile
從Dockerfile說起是因為它是最基礎的,也是最簡單的優化,它可以簡單的加快我們的應用構建映象和拉取映象的時間。
以一個Springboot應用為例,我們通常會看到這種樣子的Dockerfile:
FROM openjdk:8-jdk-alpine
COPY app.jar /
ENTRYPOINT ["java","-jar","/app.jar"]
足夠簡單清晰,但很顯然,這並不是一個很好的Dockerfile,因為它沒有利用到Image layer去進行效率更高的快取。
我們都知道,Docker擁有足夠高效的快取機制,但如果不好好的應用這一特性,而是簡單的將Jar包打成單一layer映象,就會導致,即使應用只改動一行程式碼,我們也需要重新構建整個Springboot Jar包,而這其中Spring的龐大依賴類庫其實都沒有發生過更改,這無疑是一種得不償失的做法。因此,將應用的所有依賴庫作為一個單獨的layer顯然是一個更好的方案。
因此,一個更合理的Dockerfile應該長這個樣子:
FROM openjdk:8-jdk-alpine
ARG DEPENDENCY=target/dependency
COPY ${DEPENDENCY}/BOOT-INF/lib /app/lib
COPY ${DEPENDENCY}/META-INF /app/META-INF
COPY ${DEPENDENCY}/BOOT-INF/classes /app
ENTRYPOINT ["java","-cp","app:app/lib/*","HelloApplication"]
這樣,我們就可以充分利用Image layer cache來加快構建映象和拉取映象的時間。
構建元件
在Docker佔有映象構建的絕對話語權的今天,我們在實際開發過程中,往往會忽視構建元件的選擇,但事實上,選擇一個高效的構建元件,往往能使我們的構建效率事半功倍。
傳統的docker build
存在哪些問題?
在Docker v18.06之前的docker build
會存在一些問題:
- 改變Dockerfile中的任意一行,就會使之後的所有行的快取失效
# 假設只改變此Dockerfile中的EXPOSE埠號
# 那麼接下來的RUN命令的快取就會失效
FROM debian
EXPOSE 80
RUN apt update && apt install –y HEAVY-PACKAGES
- 多階段並行構建效率不佳
# 即使stage0和stage1之間並沒有依賴
# docker也無法並行構建,而是選擇序列
FROM openjdk:8-jdk AS stage0
RUN ./gradlew clean build
FROM openjdk:8-jdk AS stage1
RUN ./gradlew clean build
FROM openjdk:8-jdk-alpine
COPY --from=stage0 /app-0.jar /
COPY --from=stage1 /app-1.jar /
- 無法提供編譯歷史快取
# 單純的RUN命令無法提供編譯歷史快取
# 而RUN --mount的新語法在舊版本docker下無法支援
RUN ./gradlew build
# since Docker v18.06
# syntax = docker/dockerfile:1.1-experimental
RUN --mount=type=cache,target=/.cache ./gradlew build
- 映象push和pull的過程中存在壓縮和解壓的固有耗時
如上圖所示,在傳統的docker pull push階段,存在著pack和unpack的耗時,而這一部分並非必須的
針對這些固有的弊病,業界也一直在積極的探討,並誕生了一些可以順應新時代的構建工具。
新一代構建元件:
在最佳的新一代構建工具選擇上,是一個沒有銀彈的話題,但通過一些簡單的對比,我們仍能選出一個最適合的構建工具,我們認為,一個適合雲原生平臺的構建工具應該至少具備以下幾個特點:
- 能夠支援完整的Dockerfile語法,以便應用平順遷移
- 能夠彌補上述傳統Docker構建的缺點
- 能夠在非root privilege模式下執行(在基於Kubernetes的CICD環境中顯得尤為重要)
因此,Buildkit就脫穎而出,這個由Docker公司開發,目前由社群和Docker公司合理維護的“含著金鑰匙出生”的新一代構建工具,擁有良好的擴充套件性、極大地提高了構建速度,並提供了更好的安全性。Buildkit支援全部的Dockerfile語法,能更高效的命中構建快取,增量的轉發build context,多併發直接推送映象層至映象倉庫。
(Buildkit與其他構建元件的對比)
(Buildkit的構建效率)
映象大小
為了在拉取和推送映象過程中更高的控制耗時,我們通常會盡可能的減少映象的大小。
Alpine Linux是許多Docker容器首選的基礎映象,因為它只有5 MB大小,比起其他Cent OS、Debain 等動輒一百多MB的發行版來說,更適合用於容器環境。不過Alpine Linux為了儘量瘦身,預設是用musl作為C標準庫的,而非傳統的glibc(GNU C library),因此要以Alpine Linux為基礎製作OpenJDK映象,必須先安裝glibc,此時基礎映象大約有12 MB。
在JEP 386中,OpenJDK將上游程式碼移植到musl,並通過相容性測試。這一特性已經在Java 16中釋出。這樣製作出來的映象僅有41MB,不僅遠低於Cent OS的OpenJDK(大約 396 MB),也要比官方的slim版(約200MB)要小得多。
應用啟動加速
讓我們首先來看一下,一個Java應用在啟動過程中,會有哪些階段
這個圖代表了Java執行時各個階段的生命週期,可以看到它要經過五個階段,首先是VM init虛擬機器的初始化階段,然後是App init應用的初始化階段,再經過App active(warmup)的應用預熱時期,在預熱一段時間後進入App active(steady)達到效能巔峰期,最後應用結束完成整個生命週期。
使用AppCDS
從上面的圖中,我們不難發現,藍色的CL(ClassLoad)部分,實際長佔用了Java應用啟動的階段的一大部分時間。而Java也一直在致力於減少應用啟動的ClassLoad時間。
從JDK 1.5開始,HotSpot就提供了CDS(Class Data Sharing)功能,很長一段時間以來,它的功能都非常有限,並且只有部分商業化。早期的CDS致力於,在同一主機上的JVM例項之間“共享”同樣需要載入一次的類,但是遺憾的是早期的CDS不能處理由AppClassloader載入的類,這使得它在實際開發實踐中,顯得比較“雞肋”。
但在從OpenJDK 10 (2018) 開始,AppCDS【JEP 310】在CDS的基礎上,加入了對AppClassloader的適配,它的出現,使得CDS技術變得廣泛可用並且更加適用。尤其是對於動輒需要載入數千個類的Spring Boot程式,因為JVM不需要在每個例項的每次啟動時載入(解析和驗證)這些類,因此,啟動應該變得更快並且記憶體佔用應該更小。看起來,AppCDS的一切都很美好,但實際使用也確實如此嗎?
當我們試圖使用AppCDS時,它應該包含以下幾個步驟:
- 使用
-XX:DumpLoadedClassList
引數來獲取我們希望在應用程式例項之間共享的類 - 使用
-Xshare:dump
引數將類儲存到適合記憶體對映的存檔(.jsa檔案)中 - 使用
-Xshare:on
引數在啟動時將存檔附加到每個應用程式例項
乍一看,使用AppCDS似乎很容易,只需3個簡單的步驟。但是,在實際使用過程中,你會發現每一步都可能變成一次帶有特定JVM Options的應用啟動,我們無法簡單的通過一次啟動來獲得可重複使用的類載入存檔檔案。儘管在JDK 13中,提供了新的動態CDS【JEP 350】,來將上述步驟1和步驟2合併為一步。但在目前流行的JDK 11中,我們仍然逃不開上述三個步驟(三次啟動)。因此,使用AppCDS往往意味著對應用的啟動過程進行復雜的改造,並伴隨著更為漫長的首次編譯和啟動時間。
同時需要注意的是,在使用AppCDS時,許多應用的類路徑將會變得更加混亂:它們既位於原來的位置(JAR包)中,同時又位於新的共享存檔(.jsa檔案)中。在我們應用開發的過程中,我們會不斷更改、刪除原來的類,而JVM會從新的類中進行解析。這種情況所帶來的危險是顯而易見的:如果類歸檔檔案保持不變,那麼類不匹配是遲早的事,我們會遇到典型的“Classpath Hell”問題。
JVM無法阻止類的變化,但它至少應該能夠在適當的時候檢測到類不匹配。然而,在JVM的實現中,並沒有檢測每一個單獨的類,而是選擇去比較整個類路徑,因此,在AppCDS的官方描述中,我們可以找到這樣一句話:
The classpath used with -Xshare:dump must be the same as, or be a prefix of, the classpath used with -Xshare:on. Otherwise, the JVM will print an error message
即第二部步歸檔檔案建立時使用的類路徑必須與執行時使用的類路徑相同(或前者是後者的字首)。
但這是一個相當含糊的陳述,因為類路徑可能以幾種不同的方式形成,例如:
- 從帶有Jar包的目錄中直接載入.class檔案,例如
java com.example.Main
- 使用萬用字元,掃描帶有Jar包的目錄,例如
java -cp mydir/* com.example.Main
- 使用明確的Jar包路徑,例如
java -cp lib1.jar:lib2.jar com.example.Main
在這些方式中,AppCDS唯一支援的方式只有第三種,即是顯式列出Jar包路徑。這使得那些使用了大規模Jar包依賴的應用的啟動語句變得十分繁瑣。
同時,我們也要必須注意到,這種顯式列出Jar包路徑的方式並不會進行遞迴查詢,即它只會在包含所有class檔案的FatJar中生效。這意味著使用SpringBoot框架的巢狀Jar包結構,將很難利用AppCDS技術所帶來的便利。
因此,SpringBoot如果想在雲原生環境中使用AppCDS,就必須進行應用侵入性的改造,不去使用SpringBoot預設的巢狀Jar啟動結構,而是用類似maven shade plugin重新打FatJar,並在程式中顯示的宣告能讓程式自然關閉的介面或引數,通過Volume掛載或者Dockerfile改造的方式,來儲存和載入類的歸檔檔案。這裡給出一個改造過的Dockerfile的示例:
# 這裡假設我們已經做過FatJar改造,並且Jar包中包含應用執行所需的全部class檔案
FROM eclipse-temurin:11-jre as APPCDS
COPY target/helloworld.jar /helloworld.jar
# 執行應用,同時設定一個'--appcds'引數使程式在執行後能夠停止
RUN java -XX:DumpLoadedClassList=classes.lst -jar helloworld.jar --appcds=true
# 使用上一步得到的class列表來生成類歸檔檔案
RUN java -Xshare:dump -XX:SharedClassListFile=classes.lst -XX:SharedArchiveFile=appcds.jsa --class-path helloworld.jar
FROM eclipse-temurin:11-jre
# 同時複製Jar包和類歸檔檔案
COPY --from=APPCDS /helloworld.jar /helloworld.jar
COPY --from=APPCDS /appcds.jsa /appcds.jsa
# 使用-Xshare:on引數來啟動應用
ENTRYPOINT java -Xshare:on -XX:SharedArchiveFile=appcds.jsa -jar helloworld.jar
由此可見,使用AppCDS還是要付出相當多的學習和改造成本的,並且許多改造都會對我們的應用產生入侵。
JVM優化
除了構建階段和啟動階段,我們還可以從JVM本身入手,根據雲原生環境的特點,進行鍼對性的優化。
使用可以感知容器記憶體資源的JDK
在虛擬機器和物理機中,對於 CPU 和記憶體分配,JVM會從常見位置(例如,Linux 中的/proc/cpuinfo
和/proc/meminfo
)查詢其可以使用的CPU和記憶體。但是,在容器中執行時,CPU和記憶體限制條件儲存在/proc/cgroups/...
中。較舊版本的JDK會繼續在/proc
(而不是/proc/cgroups
)中查詢,這可能會導致CPU和記憶體用量超出分配的上限,並因此引發多種嚴重的問題:
- 執行緒過多,因為執行緒池大小由
Runtime.availableProcessors()
配置 - JVM的對記憶體使用超出容器記憶體上限。並導致容器被OOMKilled。
JDK 8u131首先實現了UseCGroupMemoryLimitForHeap
的引數。但這個引數存在缺陷,為應用新增UnlockExperimentalVMOptions
和UseCGroupMemoryLimitForHeap
引數後,JVM確實可以感知到容器記憶體,並控制應用的實際堆大小。但是這並沒有充分利用我們為容器分配的記憶體。
因此JVM提供-XX:MaxRAMFraction
標誌來幫助更好的計算堆大小,MaxRAMFraction
預設值是4(即除以4),但它是一個分數,而不是一個百分比,因此很難設定一個能有效利用可用記憶體的值。
JDK 10附帶了對容器環境的更好支援。如果在Linux容器中執行Java應用程式,JVM將使用UseContainerSupport
選項自動檢測記憶體限制。然後,通過InitialRAMPercentage
、MaxRAMPercentage
和MinRAMPercentage
來進行對記憶體控制。這時,我們使用的是百分比而不是分數,這將更加準確。
預設情況下,UseContainerSupport
引數是啟用的,MaxRAMPercentage
是25%,MinRAMPercentage
是50%。
需要注意的是,這裡MinRAMPercentage
並不是用來設定堆大小的最小值,而是僅當物理伺服器(或容器)中的總可用記憶體小於250MB時,JVM將用此引數來限制堆的大小。
同理,MaxRAMPercentage
是當物理伺服器(或容器)中的總可用記憶體大小超過250MB時,JVM將用此引數來限制堆的大小。
這幾個引數已經向下移植到JDK 8u191。UseContainerSupport預設情況下是啟用的。我們可以設定-XX:InitialRAMPercentage=50.0 -XX:MaxRAMPercentage=80.0
來JVM感知並充分利用容器的可用記憶體。需要注意的是,在指定-Xms -Xmx
時,InitialRAMPercentage
和MaxRAMPercentage
將會失效。
關閉優化編譯器
預設情況下,JVM有多個階段的JIT編譯。雖然這些階段可以逐漸提高應用的效率,但它們也會增加記憶體使用的開銷,並增加啟動時間。
對於短期執行的雲原生應用,可以考慮使用以下引數來關閉優化階段,以犧牲長期執行效率來換取更短的啟動時間。
JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
關閉類驗證
當JVM將類載入到記憶體中以供執行時,它會驗證該類未被篡改並且沒有惡意修改或損壞。但在雲原生環境,CI/CD流水線通常也由雲原生平臺提供,這表示我們的應用的編譯和部署是可信的,因此我們應該考慮使用以下引數關閉驗證。如果在啟動時載入大量類,則關閉驗證可能會提高啟動速度。
JAVA_TOOL_OPTIONS="-noverify"
減小執行緒棧大小
大多數Java Web應用都是基於每個連線一個執行緒的模式。每個Java執行緒都會消耗本機記憶體(而不是堆記憶體)。這稱為執行緒棧,並且每個執行緒預設為1 MB。如果您的應用處理100個併發請求,則它可能至少有100個執行緒,這相當於使用了100MB的執行緒棧空間。該記憶體不計入堆大小。我們可以使用以下引數來減小執行緒棧大小。
JAVA_TOOL_OPTIONS="-Xss256k"
需要注意如果減小得太多,則將出現java.lang.StackOverflowError
。您可以對應用進行分析,並找到要配置的最佳執行緒棧大小。
使用TEM進行零改造的Java應用雲原生優化
通過上面的分析,我們可以看出,如果想要讓我們的Java應用能在雲原生時代發揮出最大實力,是需要付出許多侵入性的改造和優化操作的。那麼有沒有一種方式能夠幫助我們零改造的開展Java應用雲原生優化?
騰訊雲的TEM彈性微服務就為廣大Java開發者提供了一種應用零改造的最佳實踐,幫助您的Java應用以最優姿態快速上雲。使用TEM您可以享受的以下優勢:
- 零構建部署。直接選擇使用Jar包/War包交付,無需自行構建映象。TEM預設提供能充分利用構建快取的構建流程,使用新一代構建利器Buildkit進行高速構建,構建速度優化50%以上,並且整個構建流程可追溯,構建日誌可查,簡單高效。
(直接使用Jar包部署)
(構建日誌可查)
(構建速度對比)
- 零改造加速。直接使用KONA Jdk 11/Open Jdk 11進行應用加速,並且預設支援SpringBoot應用零改造加速。您無需改造原有的SpringBoot巢狀Jar包結構,TEM將直接提供Java應用加速的最佳實踐,例項擴容時的啟動時間將縮短至10%~40%。
(不使用應用加速,規格1c2g)
(使用應用加速,規格1c2g)
(應用啟動速度對比,以spring petclinic為例,規格1c2g)
- 零運維監控。使用SkyWalking為您的Java應用進行應用級別的監控,您可以直觀的檢視JVM堆記憶體,GC次數/耗時,介面RT/QPS等關鍵引數,幫助您即使找到應用效能瓶頸。
(應用JVM監控)
- 極致彈性。TEM預設提供使用率較高的定時彈性策略和基於資源的彈性策略,為您的應用提供秒級的彈性效能,幫助您應對流量洪峰,並能在例項閒置時及時節省資源。
(指標彈性策略)
(定時彈性策略)
總結
工欲善其事,必先利其器。在步入雲原生時代的今天,如何讓您的Java應用的部署效率和執行效能最大化,這對所有開發者都是一個挑戰。而TEM作為一款面向微服務應用的Serverless PaaS平臺,將成為您手中的“雲端利器”,TEM將致力於為企業和開發者服務,幫助您的業務以最快速、便捷、省心的姿態,無憂上雲,享受雲原生時代的便利。
騰訊雲彈性微服務(Tencent Cloud Elastic Microservice,TEM)是面向微服務應用的 Serverless 平臺,實現 Serverless 與微服務的完美結合,提供開箱即用的微服務解決方案。目前騰訊雲最新一代Serverless微服務應用管理平臺TEM正在限時免費,速來官網體驗使用!
參考文件
http://seanthefish.com/2020/11/11/jvm-in-container/ https://www.infoq.cn/article/rqfww2r2zpyqiolc1wbe https://medium.com/@toparvion/appcds-for-spring-boot-applications-first-contact-6216db6a4194 https://events19.linuxfoundation.org/wp-content/uploads/2017/11/Comparing-Next-Generation-Container-Image-Building-Tools-OSS-Akihiro-Suda.pdf https://static.sched.com/hosted_files/kccnceu19/12/Building%20images%20%20efficiently%20and%20securely%20on%20Kubernetes%20with%20BuildKit.pdf
- Apache Pulsar 技術系列 - Pulsar 總覽
- 解決創新業務的三大架構難題,央廣購物用對了這個關鍵策略
- 詳解 Apache Pulsar 訊息生命週期
- 8年服務百萬客戶,這家SaaS公司是懂雲原生的
- 基於騰訊雲微服務引擎(TSE) ,輕鬆實現雲上全鏈路灰度釋出
- 騰訊雲基於 Apache Pulsar 跨地域複製功能實現租戶跨叢集遷移
- 面向異構技術棧和基礎設施的服務治理標準化
- Pulsar 在騰訊雲的穩定性實踐
- 迎接2023 | 北極星開源一週年,感恩禮傾情相送
- Apache Pulsar 技術系列 – 基於不同部署策略和配置策略的容災保障
- 輕量級SaaS化應用資料鏈路構建方案的技術探索及落地實踐
- 微服務架構下路由、多活、灰度、限流的探索與挑戰
- PolarisMesh北極星 V1.11.3 版本釋出
- 千億級、大規模:騰訊超大 Apache Pulsar 叢集效能調優實踐
- Apache Pulsar 系列 —— 深入理解 Bookie GC 回收機制
- 騰訊雲微服務引擎 TSE 產品動態
- 千億級、大規模:騰訊超大 Apache Pulsar 叢集效能調優實踐
- TSF微服務治理實戰系列(三)——服務限流
- 如何解決 Spring Cloud 下測試環境路由問題
- TSF微服務治理實戰系列(二)——服務路由