作者:孤戈
在 JDK 9 之前,Java 基本上平均每三年出一個版本。但是自從 2017 年 9 月分推出 JDK9 到現在,Java 開始了瘋狂更新的模式,基本上保持了每年兩個大版本的節奏。從 2017 年至今,已經發布了 十一個版本到了 JDK 19。其中包括了兩個 LTS 版本(JDK11 與 JDK17)。除了版本更新節奏明顯加快之外,JDK 也圍繞著雲原生場景的能力,推出並增強了一系列諸如容器內資源動態感知、無停頓 GC(ZGC、Shenandoah)、原生的運維能力等等。這篇文章是 EDAS 團隊的同學在服務客戶的過程中,從雲原生的角度將相關的功能進行整理和提煉而來。希望能和給大家一起認識一個新的 Java 形態。
雲原生場景定義
雲原生的內在推動力之一是讓我們的業務工作負載最大化的利用雲所帶來的技術紅利,雲帶來最大的技術紅利就是透過彈性等相關技術,帶來我們資源的高效交付和利用,從而降低最終的資源的經濟成本。所以如何最大化的利用資源的彈效能力是很多技術產品所追求的其中一個目標。
同時,另外一個內在推動力是如何去避免雲廠商技術的鎖定,實現手段就是推動各個領域的標準的建立。自從雲原生誕生以來, 隨著 Kubernetes 的大獲成功,以開源為主要形態的技術產品,持續從各個領域中形成既定的規範和標準,是技術產品追求的另外一個目標。
有了最終的目標,透過不斷最佳化的標準,那麼如何在這個新場景下利用上相關的標準能力,是很多產品不斷往前演進的方向。以上兩點,我們自己的產品如此,Java 亦如此。
Java 針對效能力
針對 Java 的近十個版本更新,我們將從運維、程式設計模型與執行時、記憶體三個場景進行解讀。其中運維部分主要是如何利用現有的容器技術獲取運維指標以及在這個場景下的一些原生能力的支援。同時 Java 也在字串和新的 IO 模型與能力。另外一個最大的變化來自於記憶體部分,除了在容器場景下對於 CGroup 有更好的支援之外,還提供了令人期待的 ZGC 和 Shenandoah GC 兩款無停頓的垃圾回收器,除了提供低時延的 STW 之外,還具備歸還部分記憶體給作業系統,最大限度的提供了應用在雲原生場景下利用硬體資源的能力。
整個解讀分為上下兩篇,除了記憶體會使用一個單獨的文章進行解讀之外,剩下的內容主要在這章講解。
更原生的運維場景
1、OperatingSystemMXBean
容器的其中一個能力是程式級別的隔離,預設情況下,容器內的 Java 程式如果基於 JMX 中提供的 OperatingSystemMXBean 中的方法進行訪問,會返回所在宿主機的所有資源資料。在 JDK 14 的版本之後,在容器或其他虛擬化操作環境中執行時,OperatingSystemMXBean 方法將返回容器特定資訊(如:系統可用記憶體、Swap 、Cpu、Load 等),這項能力在基於 JMX 開發的很多能力(如:監控、系統限流等)是一一項特別友好的能力。JDK 14 中涉及到的改造的 API 如下:
// Returns the amount of free memory in bytes
long getFreeMemorySize();
// Returns the total amount of memory in bytes.
long getTotalMemorySize();
// Returns the amount of free swap space in bytes.
long getFreeSwapSpaceSize();
// Returns the total amount of swap space in bytes
long getTotalSwapSpaceSize();
// Returns the "recent cpu usage" for the operating environment.
double getCpuLoad();
2、Single File
我們熟知的 Java 語言程式的執行過程一般情況都需要經過兩步:
- 首先,使用編譯工具將原始碼編譯成靜態的位元組碼檔案,如:執行 javac App.java 執行後會生成一個 App.class 檔案。
- 然後,再透過使用 java啟動命令,配合加上相關的類路徑並設定啟動的主程式之後開始執行應用程式,如:使用 java -cp . App 的方式執行剛剛編譯好的位元組碼程式。
很多其他的靜態語言程式,是直接編譯生成一個可執行檔案,如:c++/go 等。而對於其他的動態指令碼語言,Linux 也提供了 #shebang 這種方式,配合檔案的可執行許可權,達到簡化執行方式的目的。
很顯然,Java 的執行方式稍微繁瑣,這對於一些習慣使用指令碼方式進行運維的同學就不是特別便利,所以長久以來 Java 語言都和運維沒有太大的聯絡。而到了雲原生場景下之後,受到 Code Base 和 Admin processes 理念的影響,很多的一次性任務都習慣性的透過 Job/CronJob + Single-file 的方式執行。JDK 11 中釋出的 JEP 330 定義了這種能力,補齊了 Java 從原始碼執行的方式,即如果透過 java App.java 執行,相當於以下兩行命令執行的結果:
$ javac App.java
$ java -cp . App
同時也支援 Linux 的 shebang 檔案,即在指令碼檔案頭中指定檔案的執行引擎 ,並給予檔案可執行許可權後,就能直接執行的指令碼的內容,相關指令碼方式解釋如下:
$ cat helloJava
#!/path/to/java --source version
// Java Source Code
$ chmod +x helloJava
$ ./hellJava
3、JDK_JAVA_OPTIONS
在容器環境中,一旦映象確定,程式行為就只能透過配置的方式進行改變了。這也是符合雲原生的要素 Config 的一種設計。但是對於 JVM 程式啟動時,由於我們有很多的配置需要透過啟動引數進行配置(比如:對記憶體設定,-D設定系統引數等等)。除非我們在 Dockerfile 編寫階段就支援 JVM 啟動命令手動傳入相關的環境變數來改變 JVM 的行為,否則這種設計對於 Java 而言就很不友好。好在 JVM 提供了一個系統的環境變數 JAVA_TOOL_OPTIONS,來支援透過讀取這個環境變數的值來設定的啟動引數的預設值。可是這個引數存在以下的問題:
- 不僅針對 java 命令生效:其他的管控命令如:jar, jstack, jmap等也一樣會生效。而容器內的程式預設都會讀取外部傳入的環境變數的值,即一旦設定,這個值會被容器內所有的程式共享,意味著當我們想進入到容器進行一些 java 程式的排查工作時,預設都會受到 JAVA_TOOL_OPTIONS 這個變數的“汙染”而得不到預期的結果。
- 環境變數的長度限制:無論是在 Linux Shell 內部還是在 Kubernetes 編排的 yaml 中,針對環境變數的長度都不會是無限的,而 JVM 啟動引數通常都會很長。所以很多時候會遇到因為 JAVA_TOOL_OPTIONS 的值過長而引起不可預知的行為。
在 JDK 9 中,提供了一個新的環境變數 JDK_JAVA_OPTIONS,它只會支援影響到 java啟動命令,不會汙染其他命令;同時還支援了透過 export JDK_JAVA_OPTIONS='@file' 的方式從指定的檔案讀取的相關的內容;從而很好的規避了以上兩個問題。
4、ExitOnOutOfMemoryError
OutOfMemoryError 是 Java 程式設計師最不想遇到的一個場景,因為見到它可能意味著系統中存在一定程度的記憶體洩露。而且記憶體洩露的問題一般都需要很繁瑣的步驟加上大量精力的進行分析查出來。從發現問題,到定位到這個問題,往往需要耗費的大量的時間和精力。為了保證業務的連續性,如何在發生錯誤時及時的恢復以止損是我們處理故障時的首要原則;如果系統發生了 OutOfMemoryError,我們往往會選擇快速重啟進行恢復。
在 Kubernetes 中定義了 Liveness存活探針,讓程式設計師有機會根據業務的健康程度來決定是否需要進行快速重啟。因為常見的OutOfMemoryError 常常會伴隨著大量的 FullGC,隨著 FullGC 引發 CPU/Load 飆高而引發請求時間過長,我們可以根據這一特性,選擇合適的業務 API 進行應用健康存活的探測。然而這個方案存在以下一些問題:
- 首先,所選擇的 API 存在誤判的可能性,API 超時可能因為很多的原因引起,記憶體只是其中一種。
- 其次,發生 OutOfMemoryError 錯誤時不一定全是業務使用的堆記憶體的問題,如:後設資料空間溢位、棧空間溢位、無法建立系統執行緒等都會有這個錯誤出現。
- 第三,從發生問題到最後探活失敗,通常需要經歷連續多長時間的重複失敗探測才會導致最終的失敗。這個過程會有一定的時延。
這個問題在 JDK9 中有了更好的解法,這個版本中引入了額外的系統引數:
- ExitOnOutOfMemoryError:即遇到 OutOfMemoryError時,JVM 馬上退出。
- CrashOnOutOfMemoryError:除了繼承了 ExitOnOutOfMemoryError 的語義之外,同時還會生成 JVM Crash 的日誌檔案,讓程式可以在退出前進行現場的基本保留。
- OnOutOfMemoryError:可以在此引數後加入一個指令碼,配合此指令碼,可以在退出前進行一些狀態的清理。
以上三個引數在雲原生所推崇的 "Fail Fast" 理念中特別的有價值,尤其是在無狀態的微服務應用場景(如在 EDAS 中)中,在退出前結合 OnOutOfMemoryError 的指令碼做很多優雅下線的工作,同時可以將 JVM Crash 的檔案輸出到雲盤(如:NAS)中。最大限度保障我們的業務因為記憶體而受到干擾,同時還能儲存當時的現場。
5、CDS
雲原生應用所踐行另外一個理念是應用的快速啟動,在 Serverless 的推動下,雲廠商都在為應用的冷啟動指標努力,Java 應用一直因為初始化時間過長而飽受鋯病,在最近的 EDAS 2022 的年度報告中,EDAS 中託管應用 70% 的啟動時間要 30 秒以上。如果我們仔細分析,Java 應用啟動時間除了應用程式本身的初始化之外,還有 JVM 的初始化過程,而 JVM 的初始化過程中中最長的要數 Class 檔案的查詢和載入。CDS技術就是為加速 Class 檔案啟動速度而生,它為 Class-Data Sharing 的簡稱,即為應用間共享 Class-Data 資料資訊的一種技術,原理是利用 Class 檔案不會被輕易改變的特點,可以將其中一個程式中產生的 Class 後設資料資訊直接 dump ,在新啟動的例項中進行共享複用。省去每個新例項都需要從 0 開始初始化的開銷。
CDS 從 JDK 5 開始就有介紹,不過第一個版本只支援 Bootrap Class Loader 的 Class 共享能力。
到 JDK 10 引入 AppCDS,允許載入應用級別的 Class ;JDK 13 中的 引入了兩個 JVM 引數(-XX:ArchiveClassesAtExit=foo.jsa與 -XX:ShareArchiveFile=foo.jsa),結合這個兩個引數的使用,可以在程式退出前進行共享檔案的動態 dump,在啟動時載入;而在 JDK 19 中又簡化了運維操作,透過 -XX:+AutoCreateSharedArchive這個引數做到了執行時無需檢測共享檔案的冪等性,進一步的提升了這項技術的易用性。
更友好的執行時能力
1、Compact Strings
在 Java 內部,我們所有的字元儲存都是使用 char 型別 2 個位元組(16個位元組)來進行儲存,官方從很多不同的線上 Java 應用中曾經分析過,JVM 內部的堆的消耗主要是字串的使用。然而大部分的字串僅僅儲存了一個拉丁字元,即 1 個位元組就能完整表示。所以理論上,絕大多數的字串只需要一半的空間就能完成儲存和表示。
從 JDK9 開始,JDK 中關於字串的預設實現(java.lang.String, AbstractStringBuilder, StringBuilder, StringBuffer)的內部實現上,預設整合了這種機制,這個機制根據字串的內容,自動編碼成 一個位元組的 ISO-8859-1/Latin-1或 兩個位元組的 UTF-16,從而大幅減少堆記憶體的使用量。更小的堆使用同時也減少了 GC 次數,從而系統性的提升了整個系統的效能。
字串壓縮 JDK 從 1.6 就開始探索,當時在 JVM 引數層面提供了一個非開源 UseCompressedStrings的開關來實現,開啟之後它將透過改變儲存結構(byte[]或 char[])來達到壓縮的目的,由於這種方式只修改了 String類的實現,沒有系統性的梳理其他字串使用的場景,在實驗的過程中引發了一些不可預知的問題,後來在 JDK7 中被抹除。
2、Active Processor Count
Active Processor Count 是指獲取 JVM 程式能利用上的 CPU 核數,對應 JDK 中的 API 是 Runtime.getRuntime().availableProcessors(),常見於一些系統執行緒和 I/O(如:JVM 內預設的 GC 執行緒數、JIT 編譯執行緒數、某些框架的 I/O 、 ForJoinPool 等)的場景中,我們會習慣性的將的執行緒個數設定成 JVM 能獲取到的這個數。然而一開始的預設實現是透過讀取 /proc/cpuinfo檔案系統下的 CPU 資料來設定。容器場景中如果不做特殊預設讀取到的是宿主機的 CPU 資訊。而容器場景下,透過 cgroup 的隔離機制,我們其實可以給容器設定一個遠小於所在機器的真實核數。比如如果我們在一臺 4 核的機器上,在一個只設定了 2 個核的容器跑一個 JVM 程式的話,它獲得的資料是 4,而不是期望的 2。
容器內的資源感知不僅僅是 CPU 這一項,比較著名的版本是 JDK 8u191,這個版本中除了 CPU 之外,還增加了對於記憶體最大值的獲取、宿主機上對於容器內 JVM 程式的 attach (jstack/jcmd 命令等) 的最佳化等。在 CPU 的改進點上,主要是做了以下兩點增強:
- 首先:新增了一個啟動引數 -XX:ActiveProcessorCount,可以顯示的指定處理器的數量。
- 其次:根據 CGroup 檔案系統進行自動的探測,其中自動探測的相關變數有 3 個,1)CPU Set(直接以綁核的方式進行 CPU 分配);2)cpu.shares ;3)cfs_quota+ cfs_period。其中的在 Kubernetes 場景下,預設優先順序是 1) > 2) > 3)。
這裡大家可能會有一個疑問,為什麼在 Kubernetes 場景中會帶來問題?比如我們透過以下的配置來設定一個 POD 的資源使用情況:
resources:
limits:
cpu: "4"
requests:
cpu: "2"
以上的配置表示這個 POD 最多能用 4 個核,而向系統申請的資源則是 2 個核。在 Kubernetes 內部,CPU limit 部分最終是使用 CFS (quota + period) 的方式進行表示,而 CPU request 部分最終是透過 cpu.shares來設定(具體 kubernetes 是如何進行的 cgroup 對映,不再本篇的敘述範圍)。則此時場景下,預設透過Runtime.getRuntime().availableProcessors()能獲取到的核數就是 2。而不是我們預期中的 4。
如何避免這個問題?第一個最為簡單的方式,就是預設透過 -XX:ActiveProcessorCount顯示進行 CPU 的傳遞,當然這裡帶來一點點需要重寫啟動命令上的運維動作。JVM 在 JDK19 中,預設去掉了根據 cpu.shares 來進行計算的邏輯,同時新增了一個啟動引數 -XX:+UseContainerCpuShares來相容之前的行為。
3、JEP 380: Unix domain sockets
Unix domain socket (簡稱:UDS)是一種在 Unix 系列的系統之下解決同一臺機器中程式間(IPC)通訊的一種方式。在很多方面,他的使用方式和 TCP/IP 類似,如:針對 Socket 的讀寫行為、連結的接收與建立等。但是也有諸多的不同,比如它沒有實際的 IP 和埠,他不需要走一個 TCP/IP 的全棧解析和轉發。同時相比較直接使用 127.0.0.1的方式進行傳輸,還有以下兩個顯而易見的優點:
- 安全:UDS 是一種嚴格在本機內程式間進行通訊的設計,它不能接受任何遠端訪問,所以它從設計上久避免了非本機程式的干擾。同時它的許可權控制也能直接使用到 Unix 中基於檔案的許可權訪問控制,從而從系統角度大大增強安全性。
- 效能:雖然透過 127.0.0.1 進行 Loopback 的訪問方式在協議棧上做了很多最佳化,但是從本質上它還是一種 Socket 的通訊方式,即他還是需要進行三次握手、協議棧的拆包解包、受系統緩衝區的制約等。而 UDS 的連結建立無需那麼複雜,且資料傳輸上也不需要經過核心層面的多次複製,傳輸資料的邏輯邏輯簡化到:1)尋找對方的 Socket 。2)直接將資料放給對方的收訊息的緩衝區。這樣簡練的設計,相比 Loopback 在小資料量傳送的場景下效率高了一倍以上。
在 Java 中,一直沒有支援對 UDS 的支援,但是到了 JDK 16 這一局面將迎來改觀,但是為什麼 Java 到現在才加入對 UDS 的支援呢?原因我覺得還是雲原生場景的衝擊。在 Kubnernetes 的場景下,在一個 POD 內編排多個容器一起使用的方式(sidecar 模式)將會變的越來越流行,在同一個 POD 內部的多個容器中進行資料傳輸時,因為預設都是在同一名稱空間的檔案系統下,UDS 的加入會大大提升同一個 POD 內容器間資料傳輸的效率。
結語
本篇主要從運維和執行時上進行解讀,下一篇我們來講講記憶體。如果有感興趣的內容,歡迎留言或加入釘群:21958624 與我們進行溝通與交流;預祝大家新春快樂、闔家幸福、“兔”飛猛進!