最美情侣中文字幕电影,在线麻豆精品传媒,在线网站高清黄,久久黄色视频

歡迎光臨散文網 會員登陸 & 注冊

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

2023-05-10 19:36 作者:Python阿罡  | 我要投稿


阿里妹導讀


本文就Java真的老了嗎展開講述,詮釋了作者作為一名Java開發(fā)者的所思所感。

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

“落寞”的Java

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

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

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

另一個廣為詬病的是Java的資源占用問題,這主要包含兩方面:靜態(tài)的程序大小和動態(tài)的內存占用。

  • 不管多大的應用,都要隨身帶一個臃腫的JRE環(huán)境(這里先不討論模塊化改造),加上各種復雜的Jar包依賴,看了下我們團隊的每個Java應用的容器鏡像大小都輕松上G。

  • 應用的運行期內存占用居高不下,這個是Java天生的缺陷,很難克服。

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

JDK的演進

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

Java 9:難產的模塊化

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

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

  • 不可忽視的改造成本

    雖然提供了未命名模塊和自動模塊,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]

對于新的項目,使用模塊構建似乎是值得的,但現狀是,大多數開發(fā)者會忽略模塊系統(tǒng),尤其是對于已經運行了多年的大型項目,改造的成本令人望而卻步。我猜測肯定會有人吐槽類似的問題:

  • 我已經分成不同jar包了,我感覺這樣就可以了,有必要更進一步嗎?

  • 我又不是開發(fā)中間件和框架的,我開發(fā)業(yè)務應用,為什么要關心這些?

  • 就算我有二方包要開放出去,為二方包維護模塊定義似乎也帶不來多少收益?

  • 該如何分離每個模塊,基于什么原則?就跟DDD一樣,我知道這東西很美好,有最佳實踐可以參考嗎?

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

  • compact strings[8],通過對底層存儲的優(yōu)化來減少String的內存占用。String對象往往是堆內存的大頭(通常來說可以達到25%),compact string可以減少最多一倍的內存占用;

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

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

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

JVMCIJIT編譯器與JVM的交互可以分為如下三個方面。
  1. 響應編譯請求;

  2. 獲取編譯所需的元數據(如類、方法、字段)和反映程序執(zhí)行狀態(tài)的profile;

  3. 將生成的二進制碼部署至代碼緩存(code cache)里。

即時編譯器通過這三個功能組成了一個響應編譯請求、獲取編譯所需的數據,完成編譯并部署的完整編譯周期。
傳統(tǒng)情況下,即時編譯器是與Java虛擬機緊耦合的。也就是說,對即時編譯器的更改需要重新編譯整個Java虛擬機。這對于開發(fā)相對活躍的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個月一次的版本節(jié)奏難免要擠擠牙膏):

  • G1的多線程并發(fā)mark-sweep-compact:這個feature的背景是G1垃圾回收器在Java9中引入,但那會還使用單線程做mark-sweep-compact。

  • Application Class-Data Sharing[12]:通過在不同Java進程間共享應用類的元數據來降低啟動時間和內存占用,算是對Java 5引入的CDS的擴展,在這之前只支持Bootstrap Classloader加載的系統(tǒng)類。

    其實這個特性還挺有用的,因為Java啟動慢很大一部分時間耗在類加載上,CDS生成的存檔類似于一個快照,在運行時可以直接做內存映射,還可以在多個JVM之間共享存檔文件來減少內存占用。這個JEP中也提了一嘴:對Serverless云服務的分析表明,其中許多在啟動時加載了數千個應用程序類,AppCDS 可以讓這些服務快速啟動并提高整體系統(tǒng)響應時間。

  • Docker的支持[13]更好了,能認出Docker環(huán)境了。

Java 11:ZGC閃亮登場

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

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


Java 12:Shenandoah和內存返還

Java 12中引入了一個新的實驗性的垃圾回收器-Shenandoah[18],與ZGC一樣是以低停頓為目標(注意這里說的是OpenJDK,因為非親生的緣故,OracleJDK中并沒有包含)。另一個是G1上的改動,能夠自動將未使用的堆內存返還給操作系統(tǒng)[19]。我們經??吹剑琂ava程序占用的內存比實際應用本身運行產生的對象占用要多,即使在應用本身沒有流量時也是如此,原因是多方面的(這里不談JVM、類的元數據、編譯后的本地代碼等等對內存的額外占用):

  • 一方面,Java是一門有GC的語言,垃圾對象會持續(xù)占用內存,直到下一次GC為止

  • 另一方面,GC算法也決定了更多的內存占用,例如:

  • 標記-復制的算法需要有兩塊內存區(qū)域,一個典型的例子是新生代的Survivor區(qū);標記-清除的算法很多時候同樣需要更大的內存區(qū)域,因為在GC結束時會有大量的空間碎片,在分配大對象時會很麻煩。像CMS/G1這樣的并發(fā)回收器,因為在垃圾收集階段用戶線程還需要持續(xù)運行,那就需要預留足夠內存空間提供給用戶線程使用。
    CMS的做法是在老年代達到指定的占用率后(Java 6后默認為92%)開始GC,可以通過-XX:CMSInitiatingOccupancyFraction參數調高這個值,但調得太高又容易碰到Concurrent Mode Failure;
    G1的解法則是為每一個Region設計了兩個名為TAMS(Top at Mark Start)的指針,把Region中的一部分空間劃分出來用于并發(fā)回收過程中的新對象分配,并發(fā)回收時新分配的對象地址都必須要在這兩個指針位置以上,并且默認不回收在這個地址以上的對象。

一般來說,JVM在啟動時就會一次性申請大塊內存(上圖的Reserved Heap),然后傾向于在運行期保留這些內存。雖然一次GC結束后可能會空出很多內存,但JVM在內存返還策略上有時會左右為難,因為這些內存有可能很快就需要被拿來分配對象,如果頻繁進行歸還,再而觸發(fā) page fault 反而帶來性能下降。折中的策略是動態(tài)地根據負載來決定是否返還。在這之前,G1只有在Full-GC或并發(fā)周期期間才能返還內存,而G1的目標之一是避免Full-GC,并且僅根據 Java 堆占用和分配活動觸發(fā)并發(fā)循環(huán),因此多數場景下,除非強制觸發(fā),并不會有內存返回行為。在Java 12后,G1會在應用不活動的空閑期間定期嘗試繼續(xù)或觸發(fā)并發(fā)循環(huán)以確定整體 Java 堆使用情況,并自動將 Java 堆中未使用的部分返回給操作系統(tǒng)。JEP中舉了一個Tomcat服務器的示例,服務器在白天提供HTTP請求,而在夜間大部分時間處于空閑狀態(tài),新的內存返還特性可以使得JVM提交的內存減少85%。

Java 13:小升級+1

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

  • ZGC的增強[20]:同G1和Shenandoah一樣,可以將未使用的內存返還給操作系統(tǒng)了

  • AppCDS的增強[21]:在Java10的AppCDS基礎上支持動態(tài)歸檔,可以在程序退出時自動創(chuàng)建


Java 14:小升級+2

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

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

Java 15:ZGC和Shenandoah轉正

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


Java 16:Alipine Linux的支持

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

  • ZGC支持并發(fā)線程堆棧處理[24]

  • 彈性元空間[25]:一般Java程序里元空間(metaspace)的內存占用相比起堆來說不算高,但也很容易出現出現內存浪費。Java 16優(yōu)化了元空間的內存分配機制來減少內存占用。

另外值得一提的是Java 16將JDK移植到了Alpine Linux[26]。Alipine Linux[27]是一個非常輕量的Linux發(fā)行版,其Docker鏡像只有5MB左右(對比Ubuntu系列鏡像接近200 MB)。更小的鏡像意味著容器環(huán)境中更小的磁盤占用和更快的鏡像拉取速度,正因如此,Docker 官方已開始推薦使用 Alpine 替代之前的 Ubuntu 作為基礎鏡像。為了瘦身,Alpine Linux默認是用musl[28]而非傳統(tǒng)的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官方或社區(qū)所推進的一系列項目。這些項目出于不同的動機,但最終的目的都是為了讓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的協程和結構化并發(fā)[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 的危與機[43]截至今天,最新的Java 18中僅包含了Project Amber和Project Portola的一些特性,像Project Loom、Project Valhalla等并沒有包含,更別提難度最大的Project Leyden了,確實是有點落后了。不管如何,了解下這些項目做的事情可以讓我們更好地理解Java未來的發(fā)展方向。


提前編譯-AOT

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

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

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

Java是一門跨平臺語言,但JVM并不是跨平臺的,Java將源碼編譯成字節(jié)碼,交給JVM執(zhí)行,這中間裝載的開銷很高。

一段程序想要被加載需要經過的流程:
  • new 字節(jié)碼或者 static 相關字節(jié)碼觸發(fā)類加載

  • 從一系列 jar 包中找到感興趣的 class 文件

  • 將 class 文件的讀取到內存里的 byte 數組

  • defineClass,包括了 class 文件的解析、校驗、鏈接

  • 類初始化(static 塊,或者靜態(tài)變量初始化)

  • 開始解釋執(zhí)行

  • 2000 次解釋后被 client compiler JIT 編譯,隨后 15000 次執(zhí)行后被 server compiler JIT 編譯

上面這張圖能夠清晰地看出Java從啟動到達到最佳性能的不同階段。如果跳過字節(jié)碼,直接將Java代碼編譯成本地代碼,那么所有代碼都是在編譯期編譯和優(yōu)化好的,是不是就不存在JVM初始化和類加載的開銷問題,也不用等預熱到JIT編譯(編譯時還要耗費額外的運行期CPU資源),馬上就能達到最大性能?這就是AOT(Ahead-Of-Time Compilation)提前編譯的思想。當然AOT編譯也有劣勢:

  • 峰值性能:AOT編譯不像JIT編譯一樣能收集程序運行時的信息,因此也無法進行一些更激進的優(yōu)化,例如基于類層次分析的完全虛方法內聯,或者基于程序profile的投機性優(yōu)化(不過這并非硬性限制,我們可以通過限制運行范圍,或者利用上一次運行的程序profile來繞開這些限制)。

  • 構建時長:從目前的實測數據看,像Graal編譯器花的構建時間都比正常編譯時間要長。不過這個也在情理之中,畢竟一個只需要把代碼編譯成字節(jié)碼,一個則需要掃描然后分析程序所有的依賴做靜態(tài)編譯。

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

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

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

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

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

協程(虛擬線程)

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

  • 協程是協作式的,線程是搶占式;

  • 協程在用戶模式下,由應用程序調度管理,而線程則由操作系統(tǒng)內核管理;

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

  • 一個線程可以包含一個或多個協程,即不同的協程可以在一個線程上被調度。協程也被稱為輕量級線程,有意思的是線程有時候也被成為輕量級進程;

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

1:1的模型對于計算密集型任務這很合適,既不用自己去做調度,也利于一條線程跑滿整個處理器核心;但對于 I/O 密集型任務,譬如訪問磁盤、訪問數據庫占主要時間的任務,這種模型就顯得成本高昂,主要在于內存消耗和上下文切換上:64 位 Linux 上 HotSpot 的線程棧容量默認是 1MB,線程的內核元數據(Kernel Metadata)還要額外消耗 2-16KB 內存,所以單個虛擬機的最大線程數量一般只會設置到 200 至 400 條,當程序員把數以百萬計的請求往線程池里面灌時,系統(tǒng)即便能處理得過來,其中的切換損耗也是相當可觀的。

Project Loom 項目的目標是讓 Java 支持額外的N:M 線程模型[46],實際上是將 JVM 線程與 OS 線程解耦。Loom項目新增加一種用戶態(tài)的“虛擬線程”(Virtual Thread)[47],本質上它是一種有棧協程(Stackful Coroutine)[48],多條虛擬線程可以映射到同一條物理線程之中。

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

虛擬線程并不是萬能的,雖然可以顯著提高應用程序吞吐量,但也有前提:

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

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

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


  • 如果doSomething()里執(zhí)行的是某類I/O操作,那么使用虛擬線程是非常合適的,因為虛擬線程創(chuàng)建和切換的代價很低,底層對應的可能只需要幾個OS線程。如果沒有虛擬線程,使用線程的話可能要這樣寫了:

  • 把Executors.newVirtualThreadPerTaskExecutor()換成Executors.newCachedThreadPool()。結果是程序會崩潰,因為大多數操作系統(tǒng)和硬件不支持這種規(guī)模的線程數。

  • 換成Executors.newFixedThreadPool(200)或者其他自定義的線程池,那這10000個任務將會共享200個線程,許多任務將按順序運行而不是同時運行,并且程序需要很長時間才能完成。

如果doSomething()里執(zhí)行的是某類計算任務,例如給一個大數組排序,那么虛擬線程還是平臺線程都無濟于事。JEP中提到了很關鍵的一點就是:虛擬線程不是更快的線程—它們運行代碼的速度并不比平臺線程快。它們的存在是為了提供scale(更高的吞吐量),而不是speed(更低的延遲)。

虛擬線程的提案[51]目前還是Preview狀態(tài),因此我們還無從知曉其最終形態(tài),也許可以確定的幾點:

  • 虛擬線程會保持原有統(tǒng)一線程模型的交互方式,通俗地說就是原有的?Thread、Executor、Future、ForkJoinPool?等多線程工具都應該能以同樣的方式支持新的虛擬線程。使用虛擬線程的代碼可能長這樣:


  • 虛擬線程既便宜又量大管飽,因此永遠不應該被池化。大多數虛擬線程將是短暫的并且具有淺層調用棧,執(zhí)行的任務像是單個 HTTP 客戶端調用或單個 JDBC 查詢這樣的I/O操作。相比之下,線程是重量級且昂貴的,因此通常必須被池化。

  • JDK的虛擬線程調度會借助ForkJoinPool[52],以 FIFO 模式運行。

值類型

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

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

  • 指針的間接獲取對性能的影響變得更大,因為對指針的解引用是昂貴的操作,尤其是當指針或它指向的對象不在處理器的緩存中時(沒辦法,只能讀內存了);

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

Java通過對象標識符進行鏈式訪問,與之相對的是集中訪問模式,例如C/C++中的struct會將對象在內存中拍平。兩者的關鍵區(qū)別在于,鏈式訪問需要讀多次內存才能命中,而集中訪問一次就可以將相關數據全部取出。打個比方,類A中包含類B,類B中包含類C,從A->B->C,鏈式訪問在最壞情況下要讀3次內存;而集中訪問只需要讀一次。

以一個常見的Point類為例:


一個Point對象數組在內存中的布局是長這樣的:

為了提升性能,有的小伙伴可能會用“曲線救國”的方法,把Point[] pts變成兩個int數組int[] xs和int[] ys,這就成"Good Code"和"Performace Code"的兩難選擇了。Valhalla引入的值類型有點向C#中的struct偷師的味道。值類型的想法是,像Point一類的對象,本質上是純數據的聚合,只有數據,沒有標識。沒有標識意味著不再有多態(tài)性、可變性,不能在對象上加鎖,不能為Null,只能基于狀態(tài)做對象比較,但優(yōu)勢是:

  • 值類型的內存布局可以像基礎類型一樣平坦緊湊,其他對象或數組在引用值類型時更簡單;

  • 同樣也不需要object header了,可以省去內存占用和分配的開銷;

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

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


值類型的內存布局長這樣:

看上去值類型跟基礎類型很像(某些小伙伴要說了,這跟我之前干的用兩個int[]來代替Point[]的方式有什么區(qū)別?),不同之處在于可以將其看做一種可以快速訪問的帶限制的特殊對象,因此有對象的特征(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>了。最后說一點,一個值類型看似簡單,實際上創(chuàng)建一種新的數據類型需要對編譯器、類文件結構和 JVM 都進行更改,還要支持現有的庫,譬如Collections、Streams等。從14年到現在,Java 團隊已經對六種同的解決方案進行了原型設計,值類型(value types)這一術語也被重命名為內聯類(inline classes),然后又變成原始類(primitive classes)??傊?,耐心等待吧…

隊友的助攻

Java最牛逼的是什么,是它的生態(tài)圈和圈里的隊友們啊。我列了幾個我覺得比較有代表性的。

GraalVM

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

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

  • Substrate VM?- 是一個構建在Graal編譯器之上的,支持AOT編譯的運行框架。它的設計初衷是提供一個快速啟動,低內存占用,以及能無縫銜接C代碼(與JNI相比)的runtime,并能完美適配Truffle[59]語言實現。

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

GraalVM算是近年來的明星Java項目,發(fā)展很快。這里我只做個簡單的介紹,感興趣的同學建議直接上官網[60]看官方文檔。Graal

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

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

  • native image builder:使用Graal編譯器做靜態(tài)編譯的工具,它處理應用程序的所有類和依賴項(包括來自JDK的部分),通過指針分析(Points-To Analysis)來確定在應用程序執(zhí)行期間可以訪問哪些類和方法,然后提前將可訪問的代碼和數據編譯為特定操作系統(tǒng)和架構的可執(zhí)行文件或者動態(tài)鏈接庫。

  • SubstrateVM Runtime:一個特殊的精簡過的VM Runtime,包括了deoptimizer、GC、線程調度等組件。因為已經做了AOT編譯,比傳統(tǒng)的Runtime少了類加載、解釋器、JIT等組件。

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

Substrate VM的限制其實就是前面說的AOT編譯的限制,要求目標程序滿足"closed-world"假設,即所有代碼在編譯器已知。如果不滿足,那只能同時構建一個fallback image了(使用傳統(tǒng)JVM執(zhí)行,需要JDK依賴)。一些限制條件可以通過在鏡像構建時進行配置[64]來繞過,其中最關鍵的就是類的元數據(Metadata)相關的一些限制:

  • 動態(tài)類加載:對于像Class.forName("myClass”)一類動態(tài)按照類名加載的操作,必須在配置文件里配上myClass,否則運行期就是一個ClassNotFoundException;

  • 反射:構建時會通過檢測對反射 API 的調用做靜態(tài)分析,對于無法通過靜態(tài)分析獲知的,那也只能配置了;

  • 動態(tài)代理:這里指的是使用了java.lang.reflect.Proxy API的動態(tài)代理。要求動態(tài)代理的接口列表在構建期就是已知的,構建時會簡單地攔截對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字節(jié)碼和Security Manager,是直接無法兼容的。還有一些功能跟HotSpot有區(qū)別,具體可以參考這篇文檔[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的性能優(yōu)勢還是挺大的,尤其是對于Ruby、R這類解釋型語言。

Truffle提供了一套API,基于Truffle的語言實現僅需用Java實現詞法分析、語法分析以及針對語法分析所生成的抽象語法樹(AST)的解釋器,理論上實現一個解釋器要比開發(fā)一個優(yōu)化的編譯器要容易得多。Truffle將這些語言的源代碼或源代碼編譯后的中間格式(例如,LLVM 字節(jié)碼、Class 字節(jié)碼)通過解釋器轉換為能被 GraalVM 接受的中間表示(Intermediate Representation,IR),然后就可以使用Graal編譯器對這些解釋器進行優(yōu)化,因此性能上有時候比傳統(tǒng)編譯器反而還有優(yōu)勢。此外,Truffle的精華之處在于,運行時所有的解釋器都通過同樣的協議來互相操作不同編程語言中的對象,這就為所有生態(tài)系統(tǒng)下的庫和模塊都敞開了大門,你只需要選擇最合適的語言去解決你要解決的問題就可以了,而不用為了項目所用的某個語言去專門實現一些缺少的模塊。這是一個官方的示例,展示了多語言如何直接進行交互:



Spring NativeSpring是Java生態(tài)圈的絕對大佬,曾幾何時,Spring也稱得上一個輕量級框架(相比EJB?),然而現在看看,Spring的模塊量級、啟動速度、內存占用恐怕都談不上多輕量了。Spring是一個動態(tài)性很強的框架,其核心的IoC和AOP功能大量使用了反射、動態(tài)字節(jié)碼生成等技術,這與前面說的AOT編譯的封閉世界假設是沖突的。所以尷尬的事情出現了,我想要使用AOT或者說GraalVM,但是第一個難題居然是代碼中的Spring框架不支持…基于此,社區(qū)中出現了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 的配置(反射、資源、動態(tài)代理、Native-Image選項):

從benchmark測試結果看,Spring Native的啟動速度、鏡像大小、內存占用與傳統(tǒng)Spring Boot相比有非常明顯的提升,但峰值性能、構建時長等方面還處于劣勢(同樣的話好像說了好幾次了?)

其他:Quarkus/Micronut/Helidon等等

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

  • Cloud Native

  • Container First

  • GraalVM

  • Reactive

  • Fast Boot And Low Memory Footprint

相比更常見的Spring Boot,這些新的框架天生對 GraalVM 有更好的適配,更輕量、啟動更快、內存占用更低,非常適合容器化交付。雖然目前看起來尚顯稚嫩,生態(tài)系統(tǒng)相比Spring還不算成熟,但就我個人而言,非常愿意在小的項目里使用這些框架。其他的,像Apache、JBoss還有Eclipse等等社區(qū),其實都很活躍,仍然充滿活力。

未來?

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

  • 更具生產力的語法和API改進

  • 以ZGC為代表的更先進的GC

  • 在啟動速度、內存占用等短板上的各種優(yōu)化

  • 以GraalVM為代表的新編譯器+Native Image+多語言編程

  • 更好的云原生支持

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


一些雜想:Java老矣,尚能飯否?的評論 (共 條)

分享到微博請遵守國家法律
望奎县| 吐鲁番市| 育儿| 建德市| 土默特右旗| 普陀区| 澄迈县| 修水县| 万州区| 定日县| 湘西| 临猗县| 澄城县| 临清市| 清河县| 建水县| 苍南县| 冷水江市| 双鸭山市| 海原县| 吴江市| 米易县| 保康县| 崇阳县| 沛县| 景泰县| 正镶白旗| 上思县| 宕昌县| 老河口市| 石城县| 清镇市| 榆林市| 水富县| 乡宁县| 玉环县| 威海市| 汝阳县| 巴南区| 雷山县| 泸定县|