0. 前言
前陣子在 B 站刷到了周志明博士的視頻,主題是云原生時代下 Java,主要內(nèi)容是云原生時代下的挑戰(zhàn)與 Java 社區(qū)的對策。這個視頻我在兩年前看到過,當時也是印象深刻。現(xiàn)在筆者也是想和大家一起看看相關(guān)項目的推進以及一些細節(jié)。這篇筆記會大量參考視頻中提到的內(nèi)容,如果讀者看過相關(guān)視頻,可以跳過這篇筆記。
視頻分享中提到,Java 與云原生的矛盾大概原因有二:
首當其沖的是 Java 的“一次編寫,到處運行”(Write Once, Run Anywhere) 。在當年是非常好的做法,直接開啟了許多托管語言的興盛期。但云原生時代大家會選擇以隔離的方式,通過容器實現(xiàn)的不可變基礎(chǔ)設(shè)施去解決。雖然容器的“一次構(gòu)建,到處運行”(Build Once, Run Anywhere)和 Java 的“一次編寫,到處運行”(Write Once, Run Anywhere)并不是一個 Level 的——容器只能提供環(huán)境兼容性和有局限的平臺無關(guān)性(指系統(tǒng)內(nèi)核功能以上的 ABI 兼容),但服務(wù)端的應(yīng)用都跑在 Linux 上,所以對于業(yè)務(wù)來說也無傷大雅。
其二,則是 Java 總體上是面向長時間的“巨塔式”服務(wù)端應(yīng)用而設(shè)計的 :
靜態(tài)類型動態(tài)鏈接的語言結(jié)構(gòu),利于多人協(xié)作開發(fā),讓軟件觸及更大規(guī)模;
即時編譯器、性能制導(dǎo)優(yōu)化、垃圾收集子系統(tǒng)等 Java 最具代表性的技術(shù)特征,都是為了便于長時間運行的程序能享受到硬件規(guī)模發(fā)展的紅利。
但在,微服務(wù)時代是提倡服務(wù)圍繞業(yè)務(wù)能力(不同的語言適合不同的業(yè)務(wù)場景)而非技術(shù)來構(gòu)建應(yīng)用,不再追求實現(xiàn)上的一致。一個系統(tǒng)由不同語言、不同技術(shù)框架所實現(xiàn)的服務(wù)來組成是完全合理的。
服務(wù)化拆分后,很可能單個微服務(wù)不再需要再面對數(shù)十、數(shù)百 GB 乃至 TB 的內(nèi)存。有了高可用的服務(wù)集群,也無須追求單個服務(wù)要 7×24 小時不可間斷地運行,它們隨時可以中斷和更新。不僅如此,微服務(wù)對鏡像體積、內(nèi)存消耗、啟動速度,以及達到最高性能的時間等方面提出了新的要求。這兩年的網(wǎng)紅概念 Serverless(以及衍生出來的 Faas) 也進一步增加這些因素的考慮權(quán)重。
而這些卻正好都是 Java 的弱項:哪怕再小的 Java 程序也要帶著厚重的 Rumtime(Vm 和 StandLibrary)——基于 Java 虛擬機的執(zhí)行機制,使得任何 Java 的程序都會有固定的內(nèi)存開銷與啟動時間,而且 Java 生態(tài)中廣泛采用的依賴注入進一步將啟動時間拉長,使得容器的冷啟動時間很難縮短。
舉兩個例子。
軟件工業(yè)中已經(jīng)出現(xiàn)過不止一起因 Java 這些弱點而導(dǎo)致失敗的案例。如 JRuby 編寫的 Logstash,原本是同時承擔部署在節(jié)點上的收集端(Shipper)和專門轉(zhuǎn)換處理的服務(wù)端(Master)的職責,后來因為資源占用的原因,被 Elstaic.co 用 Golang 的 Filebeat 代替了 Shipper 部分的職能。
又如 Scala 語言編寫的邊車代理 Linkerd,作為服務(wù)網(wǎng)格概念的提出者,卻最終被 Envoy 所取代,其主要弱點之一也是由于 Java 虛擬機的資源消耗所帶來的劣勢。
基于 Spring Boot + MyBatis Plus + Vue & Element 實現(xiàn)的后臺管理系統(tǒng) + 用戶小程序,支持 RBAC 動態(tài)權(quán)限、多租戶、數(shù)據(jù)權(quán)限、工作流、三方登錄、支付、短信、商城等功能
項目地址:https://gitee.com/zhijiantianya/ruoyi-vue-pro
視頻教程:https://doc.iocoder.cn/video/
1. 變革之火
1.1 Complie Native Code
顯然,如果將字節(jié)碼直接編譯成可以脫離 Java 虛擬機的原生代碼則可以解決所有問題。
如果真的能夠生成脫離 Java 虛擬機運行的原生程序,將意味著啟動時間長的問題能夠徹底解決,因為此時已經(jīng)不存在初始化虛擬機和類加載的過程。也意味著程序馬上就能達到最佳的性能,因為此時已經(jīng)不存在即時編譯器運行時編譯,所有代碼都是在編譯期編譯和優(yōu)化好的。同理,厚重的 Runtime 也不會出現(xiàn)在鏡像中。
Java 并非沒有嘗試走過這條路。
從 GCJ 到 Excelsior JET 再到 GraalVM 中的 SubstrateVM 模塊再到 2020 年中期建立的 Leyden 項目,都在朝著提前編譯(Ahead-of-Time Compilation,AOT)生成原生程序這個目標邁進。Java 支持提前編譯最大的困難在于它是一門動態(tài)鏈接的語言,它假設(shè)程序的代碼空間是開放的(Open World),允許在程序的任何時候通過類加載器去加載新的類,作為程序的一部分運行。要進行提前編譯,就必須放棄這部分動態(tài)性,假設(shè)程序的代碼空間是封閉的(Closed World),所有要運行的代碼都必須在編譯期全部可知。
這一點不僅僅影響到了類加載器的正常運作。除了無法再動態(tài)加載外,反射(通過反射可以調(diào)用在編譯期不可知的方法)、動態(tài)代理、字節(jié)碼生成庫(如 CGLib)等一切會運行時產(chǎn)生新代碼的功能都不再可用——如果將這些基礎(chǔ)能力直接抽離掉,Hello world 還是能跑起來,大部分的生產(chǎn)力工具都跑不起來,整個 Java 生態(tài)中絕大多數(shù)上層建筑都會轟然崩塌。
隨便列兩個 Case:Flink 的 SQL API 會解析 SQL 并生成執(zhí)行計劃。這個時候會通過 JavaCC 動態(tài)生成類加載到代碼空間中去;Spring 也有類似的情況,當 AOP 通過動態(tài)代理的方式去生成相關(guān)邏輯時,本質(zhì)還是在 Runtime 時生成代碼并加載進去。
要獲得有實用價值的提前編譯能力,只有依靠提前編譯器、組件類庫和開發(fā)者三方一起協(xié)同才可能辦到——可以參考 Quarkus。
Quarkus 和我們上述的方法如出一轍,以 Dependency Inject 為例:所有要運行的代碼都必須在編譯期全部可知,在編譯期就推導(dǎo)出來相關(guān)的 Bean,最后交給 GraalVM 來運行。
1.2 Memory Access Efficiency Improvement
Java 即時編譯器的優(yōu)化效果拔群,但是由于 Java “一切皆為對象”的前提假設(shè),導(dǎo)致它在處理一系列不同類型的小對象時,內(nèi)存訪問性能很差。這點是 Java 在游戲、圖形處理等領(lǐng)域一直難有建樹的重要制約因素,也是 Java 建立 Valhalla 項目的目標初衷。
這里舉個例子來說明此問題,如果我想描述空間里面若干條線段的集合,在 Java 中定義的代碼會是這樣的:
publicrecordPoint(floatx,floaty,floatz){} publicrecordLine(Pointstart,Pointend){} Line[]lines;
面向?qū)ο蟮膬?nèi)存布局中,對象標識符(Object Identity)存在的目的是為了允許在不暴露對象結(jié)構(gòu)的前提下,依然可以引用其屬性與行為,這是面向?qū)ο?a href="http://m.hljzzgx.com/v/tag/1315/" target="_blank">編程中多態(tài)性的基礎(chǔ)。在 Java 中堆內(nèi)存分配和回收、空值判斷、引用比較、同步鎖等一系列功能都會涉及到對象標識符,內(nèi)存訪問也是依靠對象標識符來進行鏈式處理的,譬如上面代碼中的“若干條線段的集合”,在堆內(nèi)存中將構(gòu)成如下圖的引用關(guān)系:
計算機硬件經(jīng)過 25 年的發(fā)展,內(nèi)存與處理器雖然都在進步,但是內(nèi)存延遲與處理器執(zhí)行性能之間的馮諾依曼瓶頸(Von Neumann Bottleneck)不僅沒有縮減,反而還在持續(xù)加大,“RAM Is the New Disk”已經(jīng)從嘲諷梗逐漸成為了現(xiàn)實。
一次內(nèi)存訪問(將主內(nèi)存數(shù)據(jù)調(diào)入處理器 Cache)大約需要耗費數(shù)百個時鐘周期,而大部分簡單指令的執(zhí)行只需要一個時鐘周期而已。因此,在程序執(zhí)行性能這個問題上,如果編譯器能減少一次內(nèi)存訪問,可能比優(yōu)化掉幾十、幾百條其他指令都來得更有效果。
額外知識:馮諾依曼瓶頸 不同處理器(現(xiàn)代處理器都集成了內(nèi)存管理器,以前是在北橋芯片中)的內(nèi)存延遲大概是 40-80 納秒(ns,十億分之一秒),而根據(jù)不同的時鐘頻率,一個時鐘周期大概在 0.2-0.4 納秒之間,如此短暫的時間內(nèi),即使真空中傳播的光,也僅僅能夠行進 10 厘米左右。
數(shù)據(jù)存儲與處理器執(zhí)行的速度矛盾 是馮諾依曼架構(gòu)的主要局限性之一,1977 年的圖靈獎得主 John Backus 提出了“馮諾依曼瓶頸”這個概念,專門用來描述這種局限性。
Java 編譯器的確在努力減少內(nèi)存訪問,從 JDK 6 起,HotSpot 的即時編譯器就嘗試通過逃逸分析來做標量替換(Scalar Replacement)和棧上分配(Stack Allocations)優(yōu)化?;驹硎牵绻芡ㄟ^分析得知一個對象不會傳遞到方法之外,那就不需要真實地在對象中創(chuàng)建完整的對象布局。完全可以繞過對象標識符,將它拆散為基本的原生數(shù)據(jù)類型來創(chuàng)建,甚至是直接在棧內(nèi)存中分配空間(HotSpot 并沒有這樣做),方法執(zhí)行完畢后隨著棧幀一起銷毀掉。
不過,逃逸分析是一種過程間優(yōu)化(Interprocedural Optimization),非常耗時,也很難處理那些理論上有可能但實際不存在的情況。這意味著它是 Runtime 時發(fā)生的 。而相同的問題在 C、C++ 中卻并不存在,上面場景中,程序員只要將 Point 和 Line 都定義為 struct 即可,C# 中也有 struct,是依靠 .NET 的值類型(Value Type)來實現(xiàn)的。這些語言在編譯期就解決了這些問題。
而 Valhalla 的目標就是提供類似的值類型支持,提供一個新的關(guān)鍵字(inline),讓用戶可以在不需要向方法外部暴露對象、不需要多態(tài)性支持、不需要將對象用作同步鎖的場合中,將類標識為值類型。此時編譯器就能夠繞過對象標識符,以平坦的、緊湊的方式去為對象分配內(nèi)存。
Valhalla 目前還處于 Preview 階段??梢栽谶@里看到推進的情況。希望能在下個 LTS 版本正式用上它吧。
1.3 Coroutine
Java 語言抽象出來隱藏了各種操作系統(tǒng)線程差異性的統(tǒng)一線程接口,這曾經(jīng)是它區(qū)別于其他編程語言的一大優(yōu)勢。不過,這也是曾經(jīng)。
Java 目前主流的線程模型是直接映射到操作系統(tǒng)內(nèi)核上的 1:1 模型,這對于計算密集型任務(wù)這很合適,既不用自己去做調(diào)度,也利于一條線程跑滿整個處理器核心。但對于 I/O 密集型任務(wù),譬如訪問磁盤、訪問數(shù)據(jù)庫占主要時間的任務(wù),這種模型就顯得成本高昂,主要在于內(nèi)存消耗和上下文切換上。
舉個例子。64 位 Linux 上 HotSpot 的線程棧容量默認是 1MB,線程的內(nèi)核元數(shù)據(jù)(Kernel Metadata)還要額外消耗 2-16KB 內(nèi)存,所以單個虛擬機的最大線程數(shù)量一般只會設(shè)置到 200 至 400 條,當程序員把數(shù)以百萬計的請求往線程池里面灌時,系統(tǒng)即便能處理得過來,其中的切換損耗也相當可觀。
Loom 項目的目標是讓 Java 支持額外 的 N:M 線程模型,而不是像當年從綠色線程過渡到內(nèi)核線程那樣的直接替換,也不是像 Solaris 平臺的 HotSpot 虛擬機那樣通過參數(shù)讓用戶二選其一。
Loom 要做的是一種有棧協(xié)程(Stackful Coroutine),多條虛擬線程可以映射到同一條物理線程之中,在用戶空間中自行調(diào)度,每條虛擬線程的棧容量也可由用戶自行決定。
此外,還有兩個重點:
盡量兼容所有原接口。這意味著原來所有的線程接口都可以當作協(xié)程使用。但我覺得挺難的——假如里面的代碼調(diào)到 Native 方法,這個 Stack 就和這個線程綁定了,畢竟 Coroutine 是個用戶態(tài)的東西。
支持結(jié)構(gòu)化并發(fā):簡單來說就是異步的代碼寫起來像同步的代碼,這點 GO 做的很好。畢竟嵌套的回調(diào)函數(shù)著實讓人痛苦。
上述的內(nèi)容如果拆開來細說,基本就是:
協(xié)程的調(diào)度;
協(xié)程的同步、互斥與通訊;
協(xié)程的系統(tǒng)調(diào)用包裝,尤其是網(wǎng)絡(luò) IO 請求的包裝;
協(xié)程堆棧的自適應(yīng)。
小知識:每個協(xié)程,都有一個自己專享的協(xié)程棧。這種需要一個輔助的棧來運行協(xié)程的機制,叫做 Stackful Coroutine;而在主棧上運行協(xié)程的機制,叫做 Stackless Coroutine。
Stackless Coroutine意味著:
運行時:活動記錄放在主線程的棧上
暫停時:堆中保留活動記錄
可以調(diào)用其他函數(shù)
只能在頂層暫停運行,不可以在子函數(shù)/子協(xié)程里暫停
而Stackfull Coroutine意味著:
運行時:單獨的運行棧
可以在調(diào)用棧的任何一級暫停
生命周期可以超過它的創(chuàng)建者
可以從一線程上跑到另一個線程上
因此,一個完備的協(xié)程庫基本頂?shù)蒙弦粋€操作系統(tǒng)里的進程部分了。只是它在用戶態(tài),進程在內(nèi)核態(tài)。
這個項目可以在這里看到。目測 JDK 19 就可以嘗嘗鮮了。
基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 實現(xiàn)的后臺管理系統(tǒng) + 用戶小程序,支持 RBAC 動態(tài)權(quán)限、多租戶、數(shù)據(jù)權(quán)限、工作流、三方登錄、支付、短信、商城等功能
項目地址:https://gitee.com/zhijiantianya/yudao-cloud
視頻教程:https://doc.iocoder.cn/video/
2. 小結(jié)
目前在云原生領(lǐng)域,Java 可能未必是好的選擇——在這個領(lǐng)域最讓人難以忍受的就是其龐大的 Runtime 以及較長的 Startup 時間,在以前這是 Java 優(yōu)點的來源,但到了云原生時代,則成了 Java 顯而易見弱點。因此 Java 想在云原生時代繼續(xù)保持前幾十年的趨勢,解決這個問題迫在眉睫。從這個點來看,我很看好 Quarkus。
Valhalla 帶來的優(yōu)化很多場景都可以用上,一些長時間運行應(yīng)用也可以獲得更多的性能收益。
而協(xié)程針對的是 IO 密集型場景,本身也可以通過 NIO、AIO 方式來避免線程的大量消耗。因此 Loom 在筆者看來更像是錦上添花的事。
-
JAVA
+關(guān)注
關(guān)注
19文章
2966瀏覽量
104700 -
容器
+關(guān)注
關(guān)注
0文章
495瀏覽量
22060 -
編譯器
+關(guān)注
關(guān)注
1文章
1623瀏覽量
49107 -
云原生
+關(guān)注
關(guān)注
0文章
248瀏覽量
7947
原文標題:追隨云原生的 Java
文章出處:【微信號:芋道源碼,微信公眾號:芋道源碼】歡迎添加關(guān)注!文章轉(zhuǎn)載請注明出處。
發(fā)布評論請先 登錄
相關(guān)推薦
評論