一些雜想:Java老矣,尚能飯否?

ITPUB社群發表於2023-05-06

一些雜想:Java老矣,尚能飯否?

來源:阿里開發者


阿里妹導讀:本文就Java真的老了嗎展開講述,詮釋了作者作為一名Java開發者的所思所感。


最近抽空看了Go、Rust等一些語言的新版本特性,還有云原生的一些基礎設施(Docker,Kubernetes,ServiceMesh,Dapr,Serverless),有點感慨Go真的是雲原生的“一等公民”,像是啟動速度快、依賴少、記憶體佔用少、Goroutine 併發等無一不是擊中Java的軟肋。然後突發奇想在Google上搜了下“Java老矣”,能搜出520,000條結果。不禁想問:Java真的老了嗎?

“落寞”的Java

自1995年出生以來,Java已經有27年曆史了,曾經的風流雨打風吹去,一些優秀的設計在今天看來似乎並不那麼重要甚至過時了。比方說:

  • "Write Once, Run Everywhere"的平臺無關特性在當年確實是真香,但現在這種部署的便利性已經完全可以交由Docker為代表的的容器提供了(從某種意義上說,JVM也是位元組碼的容器),而且做得更好,可以將整個執行環境進行打包。想想Docker的口號也是:"Build Once, Run Anywhere"。

  • Java 總體上是面向大規模、長時間執行的服務端應用而設計的。在語法層面,Java+Spring框架寫出的程式碼一致性很高;在執行期,有JIT編譯、GC等元件保障應用穩定可靠。這些特性對於企業級應用十分關鍵,曾經是Java最大的優勢之一。但在微服務化甚至Serverless化的部署形態下,有了高可用的服務叢集,也無須追求單個服務要 7×24 小時不可間斷地執行,它們隨時可以中斷和更新,Java的這一優勢無形中被削弱了。

另一個廣為詬病的是Java的資源佔用問題,這主要包含兩方面:靜態的程式大小和動態的記憶體佔用。

  • 不管多大的應用,都要隨身帶一個臃腫的JRE環境(這裡先不討論模組化改造),加上各種複雜的Jar包依賴,看了下我們團隊的每個Java應用的容器映象大小都輕鬆上G。

  • 應用的執行期記憶體佔用居高不下,這個是Java天生的缺陷,很難克服。

一些雜想:Java老矣,尚能飯否?

Java的啟動時間也是一大心病,主要原因在於啟動時虛擬機器初始化和大量類載入的時間開銷(當然還有一個罪魁禍首是Spring的bean初始化,我之前寫了個非同步初始化Spring Bean的starter rhino-boot-turbo,把序列改並行啟動速度會快很多)。本身映象體積大,拉取時間就長,再加上分鐘級的啟動時間,部署應用就更顯得慢了。傳統的企業應用更看重長時間執行的穩定性,重啟和釋出頻率相對較低,對啟動時間相對沒那麼敏感,然而對於需要快速迭代、水平擴充套件的微服務應用而言,更快的的啟動速度就意味著更高的交付效率和更加快速的回滾。尤其是對於Serverless應用或函式,冷啟動速度至關重要,之前看AWS Lambda函式允許最多執行5分鐘,很難想象還要花一分鐘時間先啟動。
雲原生的潮流滾滾而來,Java的這些缺陷在要求快速交付的大環境下顯得格格不入,難怪Java與Go、Rust等原生語言相比,會顯得“落寞”了。
作為一個Java程式設計師,肯定想問,Java還有機會嗎?想起有位長者說過:一個人的命運啊,當然要靠自我的奮鬥,另一方面,也要考慮歷史的程式。我想把它改成:Java的命運啊,當然要靠自身的努力,另一方面,也要考慮隊友們給不給力。

JDK的演進

我們的大部分系統都還跑在Java 8之上,因此作為開發同學對Java 8也是最熟悉的。從Java 9開始,JDK的版本號堪比版本狂魔Chrome漲得飛快,除去開發者能夠肉眼感知的語法和API的變動(Productivity)之外,Java也在效能(Performance)上一直努力。
我捋了一下OpenJDK官網[1]從Java 9開始的JEP列表,按照個人理解列出了關鍵的一些特性。

Java 9:難產的模組化

在數次delay之後,Java 9終於正式引入了Java平臺模組系統(JPMS),專案代號Jigsaw。在這之前,Java以package對程式碼進行組織,再將package和資源打成Jar包,模組則在package的概念上將多個邏輯上、功能上相關的包以及相關的資原始檔封裝成模組。關於模組的詳細介紹,可以參考下官方的介紹文件:Understanding Java 9 Modules[2]。
此前,Java Runtime的龐大臃腫一直為人詬病(一個rt.jar就有60多M,整個JRE環境可以達到上百M),瘦身正是Project Jigsaw的目標[3]之一。此外,還有Jar Hell、安全性等等問題。

一些雜想:Java老矣,尚能飯否?

不過模組化看著很好,也隱藏著陷阱:

  • 不可忽視的改造成本

    雖然提供了未命名模組和自動模組,Oracle也提供了遷移指南和工具[4]供參考,但改造的成本依舊很大,特別是梳理模組之間的依賴關係,較為繁瑣。

  • 小心使用內部API

    模組化的最大賣點之一是強大的封裝性,它確保非public類以及非匯出包中的類無法從模組外部訪問。但在這之前,jar包中類的訪問是沒有限制的(即使是private也可以透過反射訪問)。比如JDK中的大部分com.sun.* 和 sun.*包是內部無法訪問的,但這之前被用得很多(出於效能/向前相容等等原因),雖然Oracle的建議是不要使用這些類:Why Developers Should Not Write Programs That Call 'sun' Packages[5]。

  • 小心使用內部JAR

    像lib/rt.jar和lib/tools.jar等內部 JAR不能再訪問了。不過正常來說,應該只有IDE或類似工具會直接依賴?

  • 小心使用JAR中的資源

    一些API會在執行期獲取JAR中的資原始檔(例如透過ClassLoader.getSystemResource),在Java9之前會拿到 jar:file:<path-to-jar>!<path-to-file-in-jar>這類格式的URL Schema,而Java9之後則變成了 jrt:/<module-name>/<path-to-file-in-module>

  • 其他一些問題[6]

對於新的專案,使用模組構建似乎是值得的,但現狀是,大多數開發者會忽略模組系統,尤其是對於已經執行了多年的大型專案,改造的成本令人望而卻步。我猜測肯定會有人吐槽類似的問題:

  • 我已經分成不同jar包了,我感覺這樣就可以了,有必要更進一步嗎?
  • 我又不是開發中介軟體和框架的,我開發業務應用,為什麼要關心這些?
  • 就算我有二方包要開放出去,為二方包維護模組定義似乎也帶不來多少收益?
  • 該如何分離每個模組,基於什麼原則?就跟DDD一樣,我知道這東西很美好,有最佳實踐可以參考嗎?

搜了一下,似乎國外網友也有一樣的疑惑[7]。不過,我認為讓程式設計師可以定義應用程式的模組是什麼,它們將如何被其他模組使用,以及它們依賴於哪些其他模組,這些事情還是有必要做的。
當然Java9除了模組化之外,還有一些其他特性也值得關注:

  • compact strings[8],透過對底層儲存的最佳化來減少String的記憶體佔用。String物件往往是堆記憶體的大頭(通常來說可以達到25%),compact string可以減少最多一倍的記憶體佔用;

  • AOT編譯[9],一個實驗性的AOT編譯工具jaotc[10]。它藉助了Graal編譯器,將所輸入的Java類檔案轉換為機器碼,並存放至生成的動態共享庫之中。jaotc的一大應用便是編譯java.base module(也就是模組化後Java核心類庫中最為基礎的類)。這些類很有可能會被應用程式所呼叫,但呼叫頻率未必高到能夠觸發即時編譯。

  • JVMCI[11]( JVM 編譯器介面),另一個experimental的編譯特性。用Java寫Java編譯器,Java也可以說我能自舉了!

關於 JVMCI 多介紹一些。相比用 C 或 C++ 編寫的現有編譯器(說的就是你,C2),用Java寫編譯器更容易維護和改進。JVMCI的API 提供了訪問 JVM 結構、安裝編譯程式碼和插入 JVM 編譯系統的機制,後面講到的Graal正是基於JVMCI。

JVMCIJIT編譯器與JVM的互動可以分為如下三個方面。
  1. 響應編譯請求;
  2. 獲取編譯所需的後設資料(如類、方法、欄位)和反映程式執行狀態的profile;
  3. 將生成的二進位制碼部署至程式碼快取(code cache)裡。

即時編譯器透過這三個功能組成了一個響應編譯請求、獲取編譯所需的資料,完成編譯並部署的完整編譯週期。

傳統情況下,即時編譯器是與Java虛擬機器緊耦合的。也就是說,對即時編譯器的更改需要重新編譯整個Java虛擬機器。這對於開發相對活躍的Graal來說顯然是不可接受的。

為了讓Java虛擬機器與Graal解耦合,引入 JVMCI 將上述三個功能抽象成一個Java層面的介面。這樣一來,在Graal所依賴的JVMCI版本不變的情況下,我們僅需要替換Graal編譯器相關的jar包(Java 9以後的jmod檔案),便可完成對Graal的升級。

其實JVMCI介面就長這樣:

public interface JVMCICompiler {
   /**
    * Services a compilation request. This object should compile the method to machine code and
    * install it in the code cache if the compilation is successful.
    */
   CompilationRequestResult compileMethod(CompilationRequest request);
}


Java 10:小升級

Java10的效能提升點並不多(6個月一次的版本節奏難免要擠擠牙膏):

  • G1的多執行緒併發mark-sweep-compact:這個feature的背景是G1垃圾回收器在Java9中引入,但那會還使用單執行緒做mark-sweep-compact。

  • Application Class-Data Sharing[12]:透過在不同Java程式間共享應用類的後設資料來降低啟動時間和記憶體佔用,算是對Java 5引入的CDS的擴充套件,在這之前只支援Bootstrap Classloader載入的系統類。

    其實這個特性還挺有用的,因為Java啟動慢很大一部分時間耗在類載入上,CDS生成的存檔類似於一個快照,在執行時可以直接做記憶體對映,還可以在多個JVM之間共享存檔檔案來減少記憶體佔用。這個JEP中也提了一嘴:對Serverless雲服務的分析表明,其中許多在啟動時載入了數千個應用程式類,AppCDS 可以讓這些服務快速啟動並提高整體系統響應時間。

  • Docker的支援[13]更好了,能認出Docker環境了。

Java 11:ZGC閃亮登場

Java 11是LTS版本,也可能是企業選擇從萬年Java 8升級到的第一個版本。Java11最大的改動是引入了新一代的垃圾回收器-ZGC[14]。ZGC的首要目標是實現低停頓(暫停時間不超過10ms)、高併發的垃圾回收,ZGC回收器與G1一樣基於Region記憶體佈局,使用了讀屏障、染色指標和記憶體多重對映等技術來實現可併發的標記-整理。
但ZGC並不是完美的,逃不過記憶體佔用(Footprint)、吞吐量(Throughput)和延遲(Latency)的三元悖論。與G1相比,它的強項是低延遲,缺點是記憶體佔用更高,吞吐量比G1稍低(不過這強依賴於測試用例,我也看到一些benchmark顯示ZGC的吞吐量高於G1),另外還有一些其他問題[15]也值得注意。總的來說,如果考慮使用ZGC替代CMS,建議是使用Java 15之後的版本。

一些雜想:Java老矣,尚能飯否?

資料來源:Understanding the JDK’s New Superfast Garbage Collectors[16]
另一個容易被人忽略的特性是Java 11中引入了一個號稱無操作的垃圾回收器Epsilon[17],即不會做GC的垃圾回收器。這個很有意思,但確實對於一些不需要長時間執行、小規模的程式來說,會更關注啟動時間、記憶體佔用等指標,很典型的就比如Serverless函式。只要JVM能正確分配記憶體,然後在堆耗盡之前退出,那顯然執行負載極小、沒有任何回收行為的Epsilon便是很恰當的選擇。


Java 12:Shenandoah和記憶體返還

Java 12中引入了一個新的實驗性的垃圾回收器-Shenandoah[18],與ZGC一樣是以低停頓為目標(注意這裡說的是OpenJDK,因為非親生的緣故,OracleJDK中並沒有包含)。
另一個是G1上的改動,能夠自動將未使用的堆記憶體返還給作業系統[19]。我們經常看到,Java程式佔用的記憶體比實際應用本身執行產生的物件佔用要多,即使在應用本身沒有流量時也是如此,原因是多方面的(這裡不談JVM、類的後設資料、編譯後的原生程式碼等等對記憶體的額外佔用):

  • 一方面,Java是一門有GC的語言,垃圾物件會持續佔用記憶體,直到下一次GC為止
  • 另一方面,GC演算法也決定了更多的記憶體佔用,例如:
  • 標記-複製的演算法需要有兩塊記憶體區域,一個典型的例子是新生代的Survivor區;標記-清除的演算法很多時候同樣需要更大的記憶體區域,因為在GC結束時會有大量的空間碎片,在分配大物件時會很麻煩。
    像CMS/G1這樣的併發回收器,因為在垃圾收集階段使用者執行緒還需要持續執行,那就需要預留足夠記憶體空間提供給使用者執行緒使用。
    CMS的做法是在老年代達到指定的佔用率後(Java 6後預設為92%)開始GC,可以透過-XX:CMSInitiatingOccupancyFraction引數調高這個值,但調得太高又容易碰到Concurrent Mode Failure;
    G1的解法則是為每一個Region設計了兩個名為TAMS(Top at Mark Start)的指標,把Region中的一部分空間劃分出來用於併發回收過程中的新物件分配,併發回收時新分配的物件地址都必須要在這兩個指標位置以上,並且預設不回收在這個地址以上的物件。

一些雜想:Java老矣,尚能飯否?

一般來說,JVM在啟動時就會一次性申請大塊記憶體(上圖的Reserved Heap),然後傾向於在執行期保留這些記憶體。雖然一次GC結束後可能會空出很多記憶體,但JVM在記憶體返還策略上有時會左右為難,因為這些記憶體有可能很快就需要被拿來分配物件,如果頻繁進行歸還,再而觸發 page fault 反而帶來效能下降。折中的策略是動態地根據負載來決定是否返還。
在這之前,G1只有在Full-GC或併發週期期間才能返還記憶體,而G1的目標之一是避免Full-GC,並且僅根據 Java 堆佔用和分配活動觸發併發迴圈,因此多數場景下,除非強制觸發,並不會有記憶體返回行為。在Java 12後,G1會在應用不活動的空閒期間定期嘗試繼續或觸發併發迴圈以確定整體 Java 堆使用情況,並自動將 Java 堆中未使用的部分返回給作業系統。
JEP中舉了一個Tomcat伺服器的示例,伺服器在白天提供HTTP請求,而在夜間大部分時間處於空閒狀態,新的記憶體返還特性可以使得JVM提交的記憶體減少85%。

Java 13:小升級+1

同Java 10一樣,Java 13也是一個小升級版本:

  • ZGC的增強[20]:同G1和Shenandoah一樣,可以將未使用的記憶體返還給作業系統了

  • AppCDS的增強[21]:在Java10的AppCDS基礎上支援動態歸檔,可以在程式退出時自動建立


Java 14:小升級+2

  • ZGC支援Mac和Windows了(不過大部分生產環境應該不會用這倆?)

  • G1支援Numa-Aware的記憶體分配[22]:NUMA(Non-Uniform Memory Access,非統一記憶體訪問架構)的介紹可以參考下這篇文章:【計算機體系結構】NUMA架構詳解[23]。在NUMA架構下,G1收集器會優先嚐試在請求執行緒當前所處的處理器的本地記憶體上分配物件,以保證高效記憶體訪問。在G1之前的收集器就只有針對吞吐量設計的Parallel Scavenge支援NUMA記憶體分配,如今G1也成為另外一個選擇。

Java 15:ZGC和Shenandoah轉正

從Java 11和Java 12分別引入ZGC和Shenandoah以來,一直是Experimental的兩大垃圾回收器終於Production了。


Java 16:Alipine Linux的支援

Java 16中跟效能提升相關的特性主要包括:

  • ZGC支援併發執行緒堆疊處理[24]

  • 彈性元空間[25]:一般Java程式裡元空間(metaspace)的記憶體佔用相比起堆來說不算高,但也很容易出現出現記憶體浪費。Java 16最佳化了元空間的記憶體分配機制來減少記憶體佔用。

另外值得一提的是Java 16將JDK移植到了Alpine Linux[26]。Alipine Linux[27]是一個非常輕量的Linux發行版,其Docker映象只有5MB左右(對比Ubuntu系列映象接近200 MB)。更小的映象意味著容器環境中更小的磁碟佔用和更快的映象拉取速度,正因如此,Docker 官方已開始推薦使用 Alpine 替代之前的 Ubuntu 作為基礎映象。為了瘦身,Alpine Linux預設是用musl[28]而非傳統的glibc作為C標準庫,因此之前的JDK並不直接支援Alpine,而是需要在Alpine基礎上安裝glibc。
基於Alpine Linux基礎映象,再結合Java 9引入的模組化能力,如果程式只依賴 java.base模組,Docker映象的大小可以小至38 MB。

Java 17:最新的LTS版本

激進的團隊可能會跳過Java 11,直接從Java 8升級到Java 17,因為這是最新的LTS版本。Java 17(包括最新的Java 18)本身並沒有包含太多的效能提升特性,更多的是語法和API的變動,也沒啥好列的了。

Project X

標題的Projext X只是代稱,代表了Java官方或社群所推進的一系列專案。這些專案出於不同的動機,但最終的目的都是為了讓Java更適應新的時代。完整的專案列表可以看這裡[29],其中比較有代表性的有:

  • Project Amber[30]:旨在探索和孵化更小的、以生產力為導向的 Java 語言功能,每個提案的特性都不大,很多已經落地到不同JDK版本中了,像是Records[31]、Sealed Class[32]、Pattern Matching、Text Blocks[33]等等。

  • Project Leyden[34]:旨在解決Java的啟動時間、TTP(Time to Peak)效能、記憶體佔用等頑疾。一個特性即是AOT編譯,但難度太大,短期內指望不上,先寄希望於GraalVM。

  • Project Loom[35]:Java的協程和結構化併發[36]。

  • Project Valhalla[37]:旨在探索和孵化高階Java VM和語言特性,例如值型別(Value types)[38]和基於值型別的泛型[39]。

  • Project Portola[40]:將 OpenJDK 向 Alpine Linux 移植,在Java 16中已經得到了落地。

  • Project Panama[41]: 更好地跟原生程式碼(主要是C程式碼)互動。

  • Project Lilliput[42]:將物件頭縮減到64bit來降低記憶體佔用。

一些雜想:Java老矣,尚能飯否?

圖片來源:周志明(就是寫《深入理解Java虛擬機器》的大牛)的文章:雲原生時代,Java 的危與機[43]
截至今天,最新的Java 18中僅包含了Project Amber和Project Portola的一些特性,像Project Loom、Project Valhalla等並沒有包含,更別提難度最大的Project Leyden了,確實是有點落後了。不管如何,瞭解下這些專案做的事情可以讓我們更好地理解Java未來的發展方向。


提前編譯-AOT

我們一直說Java速度慢,我覺得這是一個不嚴謹的誤會,因為實際上經過JIT編譯後Java執行並不慢。為什麼Java給人“更慢”的印象?可能這兩方面因素是罪魁禍首:

  • 啟動慢,Java啟動需要初始化虛擬機器,載入大量的類

  • 預熱慢,在JIT編譯器介入前,需要在解釋模式下執行

Java是一門跨平臺語言,但JVM並不是跨平臺的,Java將原始碼編譯成位元組碼,交給JVM執行,這中間裝載的開銷很高。

一些雜想:Java老矣,尚能飯否?

一段程式想要被載入需要經過的流程:

  • new 位元組碼或者 static 相關位元組碼觸發類載入
  • 從一系列 jar 包中找到感興趣的 class 檔案
  • 將 class 檔案的讀取到記憶體裡的 byte 陣列
  • defineClass,包括了 class 檔案的解析、校驗、連結
  • 類初始化(static 塊,或者靜態變數初始化)
  • 開始解釋執行
  • 2000 次解釋後被 client compiler JIT 編譯,隨後 15000 次執行後被 server compiler JIT 編譯

一些雜想:Java老矣,尚能飯否?

上面這張圖能夠清晰地看出Java從啟動到達到最佳效能的不同階段。
如果跳過位元組碼,直接將Java程式碼編譯成原生程式碼,那麼所有程式碼都是在編譯期編譯和最佳化好的,是不是就不存在JVM初始化和類載入的開銷問題,也不用等預熱到JIT編譯(編譯時還要耗費額外的執行期CPU資源),馬上就能達到最大效能?這就是AOT(Ahead-Of-Time Compilation)提前編譯的思想。
當然AOT編譯也有劣勢:

  • 峰值效能:AOT編譯不像JIT編譯一樣能收集程式執行時的資訊,因此也無法進行一些更激進的最佳化,例如基於類層次分析的完全虛方法內聯,或者基於程式profile的投機性最佳化(不過這並非硬性限制,我們可以透過限制執行範圍,或者利用上一次執行的程式profile來繞開這些限制)。

  • 構建時長:從目前的實測資料看,像Graal編譯器花的構建時間都比正常編譯時間要長。不過這個也在情理之中,畢竟一個只需要把程式碼編譯成位元組碼,一個則需要掃描然後分析程式所有的依賴做靜態編譯。

  • 在生產的本地映象(Native Image)中使用Java agents,JMX,JVMTI,JFR等元件會有一些限制。

  • (最關鍵的)動態特性的支援:AOT編譯很美好,但是在Java中實現起來卻很困難,主要的原因在於Java雖然是一門靜態語言,但是也包含了很多動態特性,比如反射、動態代理、動態類載入、位元組碼Instrument (BCI) 等等,而提前編譯要求滿足封閉世界假設( closed world assumption),在編譯期就確定程式用到的類。

    這是一個很簡單的取捨問題,因為動態特性在Java中用得實在是太普遍了,不管是Spring、Hibernate這些應用框架還是CGLib這類位元組碼生成庫,大部分生產力工具都依賴這些動態特性,所以Java的提前編譯至今還是Experimental狀態。

目前來看使用AOT難免需要有一些折中,例如後面要講到的Substrate VM就要求以配置的方式明確告知編譯器程式程式碼中有哪些方法是隻透過反射來訪問的,哪些類會被動態載入等等。然而另一些功能可能只能妥協或者放棄了,就像動態生成位元組碼這類十分常用的功能,我們熟知的Spring預設就會使用CGLib生成動態代理。從 Spring Framework 5.2 開始增加了@proxyBeanMethods註解來排除對 CGLib 的依賴,僅使用標準的動態代理去增強類,但這也就限制了動態代理的能力。

要獲得有實用價值的提前編譯能力,只有依靠提前編譯器、元件類庫和開發者三方一起協同才有可能辦到。這就要靠後面說的隊友的助攻了。

協程(虛擬執行緒)

協程[44](Coroutine,有的地方也稱為纖程/Fiber)並不算一個新鮮的概念,但與執行緒相比一直讓開發者感覺陌生,我覺得最主要的原因是大多數程式語言對於協程的支援並不像執行緒一樣“原生”。直到Go和Kotlin這些熱門的語言直接內建了協程,協程才成為“一等公民”被開發者重新審視。
對於協程的定義,不僅在不同語言中有差異,隨著時代的變化定義也在變化,我試著將主流印象中的協程和執行緒做一個不嚴謹的對比:

  • 協程是協作式的,執行緒是搶佔式;

  • 協程在使用者模式下,由應用程式排程管理,而執行緒則由作業系統核心管理;

  • (有棧)協程擁有自己的暫存器上下文和棧,但比執行緒要小得多(MB和KB級別的差距),切換也快得多;

  • 一個執行緒可以包含一個或多個協程,即不同的協程可以在一個執行緒上被排程。協程也被稱為輕量級執行緒,有意思的是執行緒有時候也被成為輕量級程式;

回到Java,基本上執行緒模型分成1:1、N:1,N:M三種,雖然說JVM並沒有限定 Java 執行緒需要使用哪種執行緒模型來實現,但一般來說Java目前主流的執行緒模型是直接對映到作業系統核心上的1:1 模型[45],即一個使用者執行緒就唯一地對應一個核心執行緒(這裡不談在遙遠的JDK1.2之前,那會也使用過稱為“綠色執行緒”的N:1模型)。

1:1的模型對於計算密集型任務這很合適,既不用自己去做排程,也利於一條執行緒跑滿整個處理器核心;但對於 I/O 密集型任務,譬如訪問磁碟、訪問資料庫佔主要時間的任務,這種模型就顯得成本高昂,主要在於記憶體消耗和上下文切換上:64 位 Linux 上 HotSpot 的執行緒棧容量預設是 1MB,執行緒的核心後設資料(Kernel Metadata)還要額外消耗 2-16KB 記憶體,所以單個虛擬機器的最大執行緒數量一般只會設定到 200 至 400 條,當程式設計師把數以百萬計的請求往執行緒池裡面灌時,系統即便能處理得過來,其中的切換損耗也是相當可觀的。

Project Loom 專案的目標是讓 Java 支援額外的N:M 執行緒模型[46],實際上是將 JVM 執行緒與 OS 執行緒解耦。Loom專案新增加一種使用者態的“虛擬執行緒”(Virtual Thread)[47],本質上它是一種有棧協程(Stackful Coroutine)[48],多條虛擬執行緒可以對映到同一條物理執行緒之中。

在此之前,Java中已經有一些三方的實現支援協程,比如Quasar[49]和Coroutines[50],貌似都是需要掛載agent利用位元組碼注入的方式實現,我沒有細看,有興趣的可以瞭解下。

一些雜想:Java老矣,尚能飯否?
虛擬執行緒並不是萬能的,雖然可以顯著提高應用程式吞吐量,但也有前提:

  1. 併發任務的數量很高(超過幾千個)

  2. 工作負載不受 CPU 限制,換句話說是I/O密集型的任務。如果是計算密集型任務,擁有比處理器核心多得多的執行緒並不能提高吞吐量

舉個例子,假設有這樣一個場景,需要同時啟動10000個任務做一些事情:

// 建立一個虛擬執行緒的Executor,該Executor每執行一個任務就會建立一個新的虛擬執行緒try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {    IntStream.range(0, 10_000).forEach(i -> {        executor.submit(() -> {            doSomething();            return i;        });    });}  // executor.close() is called implicitly, and waits

如果doSomething()裡執行的是某類I/O操作,那麼使用虛擬執行緒是非常合適的,因為虛擬執行緒建立和切換的代價很低,底層對應的可能只需要幾個OS執行緒。如果沒有虛擬執行緒,使用執行緒的話可能要這樣寫了:

  • Executors.newVirtualThreadPerTaskExecutor()換成Executors.newCachedThreadPool()。結果是程式會崩潰,因為大多數作業系統和硬體不支援這種規模的執行緒數。
  • 換成Executors.newFixedThreadPool(200)或者其他自定義的執行緒池,那這10000個任務將會共享200個執行緒,許多工將按順序執行而不是同時執行,並且程式需要很長時間才能完成。

如果doSomething()裡執行的是某類計算任務,例如給一個大陣列排序,那麼虛擬執行緒還是平臺執行緒都無濟於事。JEP中提到了很關鍵的一點就是:虛擬執行緒不是更快的執行緒—它們執行程式碼的速度並不比平臺執行緒快。它們的存在是為了提供scale(更高的吞吐量),而不是speed(更低的延遲)。

虛擬執行緒的提案[51]目前還是Preview狀態,因此我們還無從知曉其最終形態,也許可以確定的幾點:

  • 虛擬執行緒會保持原有統一執行緒模型的互動方式,通俗地說就是原有的 ThreadExecutorFutureForkJoinPool 等多執行緒工具都應該能以同樣的方式支援新的虛擬執行緒。使用虛擬執行緒的程式碼可能長這樣:











// 直接建立一個虛擬執行緒Thread thread = Thread.ofVirtual().start(() -> System.out.println("Hello"));
// 透過builder建立一個虛擬執行緒Thread virtualThread = Thread.builder().virtual().task(() -> {            System.out.println("Fiber Thread: " + Thread.currentThread().getName());        }).start();
// 建立一個基於虛擬執行緒的ExecutorServiceExecutorService executor = Executors.newVirtualThreadExecutor()

  • 虛擬執行緒既便宜又量大管飽,因此永遠不應該被池化。大多數虛擬執行緒將是短暫的並且具有淺層呼叫棧,執行的任務像是單個 HTTP 客戶端呼叫或單個 JDBC 查詢這樣的I/O操作。相比之下,執行緒是重量級且昂貴的,因此通常必須被池化。
  • JDK的虛擬執行緒排程會藉助ForkJoinPool[52],以 FIFO 模式執行。


值型別

在Java架構師Brian Goetz的演講[53]中講到,Project Valhalla的目標是"reboot the layout of data in memory"。他提到Java的一些設計在剛開始是完全OK的,但過去25年中硬體發生了很大變化:

  • 記憶體延遲與處理器執行效能之間的馮諾依曼瓶頸[54](Von Neumann Bottleneck)增加了100-2000倍(也就是說,如果以CPU算術計算的速度為基準看,讀記憶體的速度沒有變快反而更慢了);

  • 指標的間接獲取對效能的影響變得更大,因為對指標的解引用是昂貴的操作,尤其是當指標或它指向的物件不在處理器的快取中時(沒辦法,只能讀記憶體了);

Java是一門重指標("pointery")的語言,除了基本型別,可以說“一切皆為物件”,每個物件都有其物件識別符號[55](Object Identity)。物件導向的記憶體佈局中,物件識別符號存在的目的是為了允許在不暴露物件結構的前提下,依然可以引用其屬性與行為,是Java實現多型性、可變性、鎖等一系列功能的基礎。尷尬的是,不管你需不需要什麼多型、可變性、鎖,物件識別符號就在那裡,也就是演講中說的:Not all objects need that! But all objects pay for it。

Java透過物件識別符號進行鏈式訪問,與之相對的是集中訪問模式,例如C/C++中的struct會將物件在記憶體中拍平。兩者的關鍵區別在於,鏈式訪問需要讀多次記憶體才能命中,而集中訪問一次就可以將相關資料全部取出。打個比方,類A中包含類B,類B中包含類C,從A->B->C,鏈式訪問在最壞情況下要讀3次記憶體;而集中訪問只需要讀一次。

以一個常見的Point類為例:





final class Point {  final int x;  final int y;}

一個Point物件陣列在記憶體中的佈局是長這樣的:

一些雜想:Java老矣,尚能飯否?

為了提升效能,有的小夥伴可能會用“曲線救國”的方法,把Point[] pts變成兩個int陣列int[] xsint[] ys,這就成"Good Code"和"Performace Code"的兩難選擇了。
Valhalla引入的值型別有點向C#中的struct偷師的味道。值型別的想法是,像Point一類的物件,本質上是純資料的聚合,只有資料,沒有標識。沒有標識意味著不再有多型性、可變性,不能在物件上加鎖,不能為Null,只能基於狀態做物件比較,但優勢是:

  • 值型別的記憶體佈局可以像基礎型別一樣平坦緊湊,其他物件或陣列在引用值型別時更簡單;

  • 同樣也不需要object header了,可以省去記憶體佔用和分配的開銷;

  • 甚至JVM可以在棧上直接分配值型別,而不必在堆上分配它們;

可以使用inline關鍵詞定義一個值型別:










inline public class Point {    public int x;    public int y;
   public Point(int x, int y) {        this.x = x;        this.y = y;    }}

值型別的記憶體佈局長這樣:

一些雜想:Java老矣,尚能飯否?

看上去值型別跟基礎型別很像(某些小夥伴要說了,這跟我之前乾的用兩個int[]來代替Point[]的方式有什麼區別?),不同之處在於可以將其看做一種可以快速訪問的帶限制的特殊物件,因此有物件的特徵(Codes like a class, works like an int),比如:

  • 可以有變數+方法

  • 可以繼承介面,例如Point可以從某個Shape介面繼承而來

  • 可以透過封裝來隱藏內部實現

  • 可以作為泛型使用,可以有泛型引數

有了值型別的支援後,Valhalla的另一個JEP: Generics over Primitive Types [56]就很自然了,Java 泛型中令人詬病的不支援原資料型別(Primitive Type)、頻繁裝箱等問題也能迎刃而解了。想象一下你只是需要一個數字列表,然後只能被定義成一個ArrayList<Integer>。對於API設計者,也不用再搞什麼IntSteam<T>ToIntFunction<T>了。
最後說一點,一個值型別看似簡單,實際上建立一種新的資料型別需要對編譯器、類檔案結構和 JVM 都進行更改,還要支援現有的庫,譬如CollectionsStreams等。從14年到現在,Java 團隊已經對六種同的解決方案進行了原型設計,值型別(value types)這一術語也被重新命名為內聯類(inline classes),然後又變成原始類(primitive classes)。總之,耐心等待吧…

隊友的助攻

Java最牛逼的是什麼,是它的生態圈和圈裡的隊友們啊。我列了幾個我覺得比較有代表性的。

GraalVM

一些雜想:Java老矣,尚能飯否?

Oracle在18年官宣了GraalVM[57]的1.0版本。雖然名字裡帶著VM,但實際上它既是 HotSpot 的新型 JIT 編譯器[58],又可以用作AOT編譯器,也是一個新的多語言虛擬機器。GraalVM有3個關鍵的元件:

  • Graal - 用Java寫的編譯器,既可以作為 JIT 編譯器取代C2在傳統的OpenJDK JVM上執行,又可以當做AOT編譯器使用。

  • Substrate VM - 是一個構建在Graal編譯器之上的,支援AOT編譯的執行框架。它的設計初衷是提供一個快速啟動,低記憶體佔用,以及能無縫銜接C程式碼(與JNI相比)的runtime,並能完美適配Truffle[59]語言實現。

  • Truffle - 即下圖中的語言實現框架(Language Implementation Framework),用來支援多種語言跑在GraalVM上。

一些雜想:Java老矣,尚能飯否?

GraalVM算是近年來的明星Java專案,發展很快。這裡我只做個簡單的介紹,感興趣的同學建議直接上官網[60]看官方文件。
Graal

我們熟知的HotSpot有兩個JIT編譯器,C1和C2。Java 程式首先在解釋模式下啟動,執行一段時間後,經常被呼叫的方法會被識別出來,並使用 JIT 編譯器進行編譯——先是使用 C1,如果 HotSpot 檢測到這些方法有更多的呼叫,就使用 C2 重新編譯這些方法。這種策略被稱為“分層編譯”,是 HotSpot 預設採用的方式。經過這麼多年最佳化下來,C2編譯後的程式碼效率非常出色,可以與 C++ 相媲美(甚至更快)。不過,近年來 C2 並沒有帶來多少重大的改進。不僅如此,C2 中的程式碼變得越來越難以維護和擴充套件,新加入的工程師很難修改使用 C++ 特定方言編寫的程式碼。

Graal編譯器的目標之一就是替代C2,因此這兩者難免會拿來做比較。可以說最明顯的區別就是Graal是用Java寫的,C2則是C++。一種普遍的看法(來自Twitter 等公司和 Cliff Click 等專家)認為,C2在當前設計中不可能再進行重大改進,而Graal使用Java開發的一大優勢在於可以很方便地將C2的新最佳化移植到Graal中,反之則不然,比如,在Graal中被證實有效的部分逃逸分析(partial escape analysis)至今未被移植到C2中。
從我目前搜到的一些測試結果來看,總的來說Graal編譯結果的效能與C2相比略優但相差不大。Graal在基於假設的最佳化手段上相對更激進,因此在某些場景下優勢會更明顯(比如這篇文章[61],再比如Twitter的報告[62]講的Scala程式碼效能上Graal有10%的優勢)。最關鍵的是,Graal還在不斷演進中,未來可期。
Substrate VM
Substrate VM簡單來說就是native image builder + SubstrateVM Runtime,分別對應原生映象(Native Image)[63]的build time和run time。

  • native image builder:使用Graal編譯器做靜態編譯的工具,它處理應用程式的所有類和依賴項(包括來自JDK的部分),透過指標分析(Points-To Analysis)來確定在應用程式執行期間可以訪問哪些類和方法,然後提前將可訪問的程式碼和資料編譯為特定作業系統和架構的可執行檔案或者動態連結庫。

  • SubstrateVM Runtime:一個特殊的精簡過的VM Runtime,包括了deoptimizer、GC、執行緒排程等元件。因為已經做了AOT編譯,比傳統的Runtime少了類載入、直譯器、JIT等元件。

一些雜想:Java老矣,尚能飯否?

官網放了一張圖來展示Graal Native Image的兩大優勢:快速啟動和低記憶體佔用。不過我看到的其他一些資料上說在低時延和高吞吐(Latency/Throughput)場景下並不佔優。

一些雜想:Java老矣,尚能飯否?

Substrate VM的限制其實就是前面說的AOT編譯的限制,要求目標程式滿足"closed-world"假設,即所有程式碼在編譯器已知。如果不滿足,那隻能同時構建一個fallback image了(使用傳統JVM執行,需要JDK依賴)。一些限制條件可以透過在映象構建時進行配置[64]來繞過,其中最關鍵的就是類的後設資料(Metadata)相關的一些限制:

  • 動態類載入:對於像Class.forName("myClass”)一類動態按照類名載入的操作,必須在配置檔案裡配上myClass,否則執行期就是一個ClassNotFoundException

  • 反射:構建時會透過檢測對反射 API 的呼叫做靜態分析,對於無法透過靜態分析獲知的,那也只能配置了;

  • 動態代理:這裡指的是使用了java.lang.reflect.Proxy API的動態代理。要求動態代理的介面列表在構建期就是已知的,構建時會簡單地攔截對java.lang.reflect.Proxy.newProxyInstance(ClassLoader, Class<?>[], InvocationHandler)java.lang.reflect.Proxy.getProxyClass(ClassLoader, Class<?>[])的呼叫來確定介面列表。同樣,如果分析失敗,那也只能配置了;

  • JNI:本機程式碼可以按名稱訪問 Java 物件、類、方法和欄位,其方式類似於在 Java 程式碼中使用反射 API。一種替代的方式是可以考慮使用GraalVM提供的原生介面org.graalvm.nativeimage.c[65],更簡單開銷更低,缺點是不允許從 C 程式碼訪問 Java 資料結構;

  • 序列化:Java 序列化需要類的後設資料資訊才能起作用,因此也需要提前配置(不過,你的程式碼裡還在用 Java 序列化嗎?);
還有一些限制條件,像是invokedynamic位元組碼和Security Manager,是直接無法相容的。還有一些功能跟HotSpot有區別,具體可以參考這篇文件[66]。

Truffle

Truffle是一個用Java寫的語言實現框架,也可以說是一套通用語言設計的框架和API。除了像 Java、Scala、Groovy、Kotlin 等基於JVM的語言外,官方在此之上還支援了JavaScript[67]、Ruby[68]、R[69]、Python[70]、Sulong[71](LLVM-based C/C++等),也就是說這些語言都可以“跑在”GraalVM上,號稱"Run Programs Faster Anywhere"。

完整的列表參考這裡[72]。

這是我找到的一份17年的效能資料,可以看到除了C/C++和JS之外,GraalVM的效能優勢還是挺大的,尤其是對於Ruby、R這類解釋型語言。

一些雜想:Java老矣,尚能飯否?

Truffle提供了一套API,基於Truffle的語言實現僅需用Java實現詞法分析、語法分析以及針對語法分析所生成的抽象語法樹(AST)的直譯器,理論上實現一個直譯器要比開發一個最佳化的編譯器要容易得多。Truffle將這些語言的原始碼或原始碼編譯後的中間格式(例如,LLVM 位元組碼、Class 位元組碼)透過直譯器轉換為能被 GraalVM 接受的中間表示(Intermediate Representation,IR),然後就可以使用Graal編譯器對這些直譯器進行最佳化,因此效能上有時候比傳統編譯器反而還有優勢。
此外,Truffle的精華之處在於,執行時所有的直譯器都透過同樣的協議來互相操作不同程式語言中的物件,這就為所有生態系統下的庫和模組都敞開了大門,你只需要選擇最合適的語言去解決你要解決的問題就可以了,而不用為了專案所用的某個語言去專門實現一些缺少的模組。
這是一個官方的示例,展示了多語言如何直接進行互動:

































const express = require('express')const app = express()
const BigInteger = Java.type('java.math.BigInteger')

app.get('/', function (req, res) {  var text = 'Hello World from Graal.js!<br> '
 // Using Java standard library classes  text += BigInteger.valueOf(10).pow(100)          .add(BigInteger.valueOf(43)).toString() + '<br>'
 // Using R methods to return arrays  text += Polyglot.eval('R',      'ifelse(1 > 2, "no", paste(1:42, c="|"))') + '<br>'
 // Using R interoperability to create graphs  text += Polyglot.eval('R',    `svg();     require(lattice);     x <- 1:100     y <- sin(x/10)     z <- cos(x^1.3/(runif(1)*5+10))     print(cloud(x~y*z, main="cloud plot"))     grDevices:::svg.off()    `);
 res.send(text)})app.listen(3001, function () {  console.log('Example app listening on port 3001!')})


Spring Native
Spring是Java生態圈的絕對大佬,曾幾何時,Spring也稱得上一個輕量級框架(相比EJB?),然而現在看看,Spring的模組量級、啟動速度、記憶體佔用恐怕都談不上多輕量了。Spring是一個動態性很強的框架,其核心的IoC和AOP功能大量使用了反射、動態位元組碼生成等技術,這與前面說的AOT編譯的封閉世界假設是衝突的。所以尷尬的事情出現了,我想要使用AOT或者說GraalVM,但是第一個難題居然是程式碼中的Spring框架不支援…
基於此,社群中出現了spring-native[73]和spring-fu[74]這樣的專案(目前都還是實驗階段),其中spring-native基本確定會在Spring Framework 6和Spring Boot 3中直接整合。

關於spring-native,ATA上已經有大佬們做過比較深入的分析了,比如:讓Spring啟動提速95.5倍,專案解讀之Spring-Graalvm-Native,也可以參考下官方的announcing-spring-native-beta[75]。

我理解Spring Native做的事情關鍵就是用 AOT 外掛(Maven/Gradle)生成 GraalVM 的配置(反射、資源、動態代理、Native-Image選項):

一些雜想:Java老矣,尚能飯否?

從benchmark測試結果看,Spring Native的啟動速度、映象大小、記憶體佔用與傳統Spring Boot相比有非常明顯的提升,但峰值效能、構建時長等方面還處於劣勢(同樣的話好像說了好幾次了?)

其他:Quarkus/Micronut/Helidon等等

近幾年來,開源社群湧現了Quarkus[76]、Micronaut[77]、Helidon[78]等一批以提升 Java 在雲原生環境下的適應性為賣點的微服務框架,從他們的slogan中可以提取到一些高頻關鍵詞:

  • Cloud Native
  • Container First
  • GraalVM
  • Reactive
  • Fast Boot And Low Memory Footprint

相比更常見的Spring Boot,這些新的框架天生對 GraalVM 有更好的適配,更輕量、啟動更快、記憶體佔用更低,非常適合容器化交付。雖然目前看起來尚顯稚嫩,生態系統相比Spring還不算成熟,但就我個人而言,非常願意在小的專案裡使用這些框架。
其他的,像Apache、JBoss還有Eclipse等等社群,其實都很活躍,仍然充滿活力。

未來?

捋完這麼多,我發現對於Java的未來我還是充滿迷茫。一方面,在新生語言的挑戰下,Java似乎不可避免地慢慢變成一種“傳統”,“老舊”,“經典”的語言;另一方面,Java和它的隊友們一直在努力開創或者吸納各種新特性、新功能,包括但不限於:

  • 更具生產力的語法和API改進
  • 以ZGC為代表的更先進的GC
  • 在啟動速度、記憶體佔用等短板上的各種最佳化
  • 以GraalVM為代表的新編譯器+Native Image+多語言程式設計
  • 更好的雲原生支援

雖然很多特性短期內還不能落地,但道阻且長,行則將至。至少就目前看來,Java在傳統的企業級和服務端應用領域構築的堡壘還是牢不可破,再加上由強大生態所構建的護城河,留給Java的時間還有很多。
最後,作為一個Java開發者,很誠實地希望,在可見的未來,Java能一直流行下去。

參考連結:

[1]

[2]

[3]projects/jigsaw

[4]

[5]

[6]https://nipafx.dev/java-9-migration-guide

[7]

[8]jeps/254

[9]

[10]

[11]

[12]jeps/310

[13]https://www.docker.com/blog/improved-docker-container-integration-with-java-10

[14]jeps/333

[15]

[16]https://blogs.oracle.com/javamagazine/post/understanding-the-jdks-new-superfast-garbage-collectors

[17]jeps/318

[18]jeps/189

[19]jeps/346

[20]jeps/351

[21]jeps/350

[22]jeps/345

[23]

[24]jeps/376

[25]jeps/387

[26]jeps/386

[27]

[28]

[29]projects

[30]projects/amber

[31]jeps/395

[32]jeps/409

[33]jeps/378

[34]https://mail.openjdk.java.net/pipermail/discuss/2020-April/005429.html

[35]

[36]jeps/8277129

[37]projects/valhalla

[38]jeps/169

[39]jeps/218

[40]projects/portola

[41]projects/panama

[42]

[43]

[44]協程

[45](computing)

[46](computing)

[47]jeps/425

[48]

[49]

[50]

[51]jeps/425

[52]https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/util/concurrent/ForkJoinPool.html

[53]

[54]

[55](object-oriented_programming)

[56]jeps/218

[57]

[58]

[59]

[60]

[61]

[62]

[63]/native-image

[64]https://github.com/oracle/graal/blob/master/docs/reference-manual/native-image/BuildConfiguration.md

[65]

[66]https://github.com/oracle/graal/blob/master/docs/reference-manual/native-image/Limitations.md

[67]

[68]

[69]

[70]

[71]

[72]/22.0/graalvm-as-a-platform/language-implementation-framework/Languages

[73]

[74]

[75]https://spring.io/blog/2021/03/11/announcing-spring-native-beta

[76]

[77]

[78]

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024420/viewspace-2950392/,如需轉載,請註明出處,否則將追究法律責任。

相關文章