從 JDK 9 到 19,我們幫您提煉了和雲原生場景有關的能力列表(上)

語言: CN / TW / HK

作者:孤戈

在 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 語言程序的執行過程一般情況都需要經過兩步:

  1. 首先,使用編譯工具將源代碼編譯成靜態的字節碼文件,如:執行 javac App.java 執行後會生成一個 App.class 文件。
  2. 然後,再通過使用 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,來支持通過讀取這個環境變量的值來設置的啟動參數的默認值。可是這個參數存在以下的問題:

  1. 不僅針對 java 命令生效:其他的管控命令如:jar, jstack, jmap等也一樣會生效。而容器內的進程默認都會讀取外部傳入的環境變量的值,即一旦設置,這個值會被容器內所有的進程共享,意味着當我們想進入到容器進行一些 java 程序的排查工作時,默認都會受到 JAVA_TOOL_OPTIONS 這個變量的“污染”而得不到預期的結果。
  2. 環境變量的長度限制:無論是在 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 進行應用健康存活的探測。然而這個方案存在以下一些問題:

  1. 首先,所選擇的 API 存在誤判的可能性,API 超時可能因為很多的原因引起,內存只是其中一種。
  2. 其次,發生 OutOfMemoryError 錯誤時不一定全是業務使用的堆內存的問題,如:元數據空間溢出、棧空間溢出、無法創建系統線程等都會有這個錯誤出現。
  3. 第三,從發生問題到最後探活失敗,通常需要經歷連續多長時間的重複失敗探測才會導致最終的失敗。這個過程會有一定的時延。

這個問題在 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 的改進點上,主要是做了以下兩點增強:

  1. 首先:新增了一個啟動參數 -XX:ActiveProcessorCount,可以顯示的指定處理器的數量。
  2. 其次:根據 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的方式進行傳輸,還有以下兩個顯而易見的優點:

  1. 安全:UDS 是一種嚴格在本機內進程間進行通信的設計,它不能接受任何遠程訪問,所以它從設計上久避免了非本機進程的干擾。同時它的權限控制也能直接使用到 Unix 中基於文件的權限訪問控制,從而從系統角度大大增強安全性。
  2. 性能:雖然通過 127.0.0.1 進行 Loopback 的訪問方式在協議棧上做了很多優化,但是從本質上它還是一種 Socket 的通信方式,即他還是需要進行三次握手、協議棧的拆包解包、受系統緩衝區的制約等。而 UDS 的鏈接建立無需那麼複雜,且數據傳輸上也不需要經過內核層面的多次拷貝,傳輸數據的邏輯邏輯簡化到:1)尋找對方的 Socket 。2)直接將數據放給對方的收消息的緩衝區。這樣簡練的設計,相比 Loopback 在小數據量發送的場景下效率高了一倍以上。

在 Java 中,一直沒有支持對 UDS 的支持,但是到了 JDK 16 這一局面將迎來改觀,但是為什麼 Java 到現在才加入對 UDS 的支持呢?原因我覺得還是雲原生場景的衝擊。在 Kubnernetes 的場景下,在一個 POD 內編排多個容器一起使用的方式(sidecar 模式)將會變的越來越流行,在同一個 POD 內部的多個容器中進行數據傳輸時,因為默認都是在同一命名空間的文件系統下,UDS 的加入會大大提升同一個  POD 內容器間數據傳輸的效率。

結語

本篇主要從運維和運行時上進行解讀,下一篇我們來講講內存。如果有感興趣的內容,歡迎留言或加入釘羣:21958624 與我們進行溝通與交流;預祝大家新春快樂、闔家幸福、“兔”飛猛進!