筆記:追隨雲原生的Java

泊浮目發表於2022-07-19
本文首發於泊浮目的掘金:https://juejin.cn/user/146860...
版本日期備註
1.02022.7.4文章首發
1.12022.7.4根據反饋修改內容與標題

0. 前言

前陣子在B站刷到了周志明博士的視訊,主題是雲原生時代下java,主要內容是雲原生時代下的挑戰與Java社群的對策。這個視訊我在兩年前看到過,當時也是印象深刻。現在筆者也是想和大家一起看看相關專案的推進以及一些細節。這篇筆記會大量參考視訊中提到的內容,如果讀者看過相關視訊,可以跳過這篇筆記。

視訊分享中提到,Java與雲原生的矛盾大概原因有二:

首當其衝的是Java的“一次編寫,到處執行”(Write Once,Run Anywhere)。在當年是非常好的做法,直接開啟了許多託管語言的興盛期。但云原生時代大家會選擇以隔離的方式,通過容器實現的不可變基礎設施去解決。雖然容器的“一次構建,到處執行”(Build Once,Run Anywhere)和Java的“一次編寫,到處執行”(Write Once,Run Anywhere)並不是一個Level的——容器只能提供環境相容性和有侷限的平臺無關性(指系統核心功能以上的 ABI 相容),但服務端的應用都跑在Linux上,所以對於業務來說也無傷大雅。

其二,則是Java 總體上是面向長時間的“巨塔式”服務端應用而設計的

  • 靜態型別動態連結的語言結構,利於多人協作開發,讓軟體觸及更大規模;
  • 即時編譯器、效能制導優化、垃圾收集子系統等 Java 最具代表性的技術特徵,都是為了便於長時間執行的程式能享受到硬體規模發展的紅利。

但在微服務時代是提倡服務圍繞業務能力(不同的語言適合不同的業務場景)而非技術來構建應用,不再追求實現上的一致,一個系統由不同語言、不同技術框架所實現的服務來組成是完全合理的。服務化拆分後,很可能單個微服務不再需要再面對數十、數百 GB 乃至 TB 的記憶體。有了高可用的服務叢集,也無須追求單個服務要 7×24 小時不可間斷地執行,它們隨時可以中斷和更新。不僅如此,微服務對映象體積、記憶體消耗、啟動速度,以及達到最高效能的時間等方面提出了新的要求。這兩年的網紅概念 Serverless(以及衍生出來的Faas) 也進一步增加這些因素的考慮權重,而這些卻正好都是 Java 的弱項:哪怕再小的 Java 程式也要帶著厚重的Rumtime(Vm和StandLibrary)——基於 Java 虛擬機器的執行機制,使得任何 Java 的程式都會有固定的記憶體開銷與啟動時間,而且 Java 生態中廣泛採用的依賴注入進一步將啟動時間拉長,使得容器的冷啟動時間很難縮短。

舉兩個例子。軟體工業中已經出現過不止一起因 Java 這些弱點而導致失敗的案例。如 JRuby 編寫的 Logstash,原本是同時承擔部署在節點上的收集端(Shipper)和專門轉換處理的服務端(Master)的職責,後來因為資源佔用的原因,被 Elstaic.co 用 Golang 的 Filebeat 代替了 Shipper 部分的職能。又如 Scala 語言編寫的邊車代理 Linkerd,作為服務網格概念的提出者,卻最終被 Envoy 所取代,其主要弱點之一也是由於 Java 虛擬機器的資源消耗所帶來的劣勢。

1.變革之火

1.1 Complie Native Code

顯然,如果將位元組碼直接編譯成可以脫離 Java 虛擬機器的原生程式碼則可以解決所有問題。

如果真的能夠生成脫離 Java 虛擬機器執行的原生程式,將意味著啟動時間長的問題能夠徹底解決,因為此時已經不存在初始化虛擬機器和類載入的過程。也意味著程式馬上就能達到最佳的效能,因為此時已經不存在即時編譯器執行時編譯,所有程式碼都是在編譯期編譯和優化好的。同理,厚重的Runtime也不會出現在映象中。

Java 並非沒有嘗試走過這條路。從GCJ到 Excelsior JET再到 GraalVM 中的 SubstrateVM 模組再到 2020 年中期建立的 Leyden 專案,都在朝著提前編譯(Ahead-of-Time Compilation,AOT)生成原生程式這個目標邁進。Java 支援提前編譯最大的困難在於它是一門動態連結的語言,它假設程式的程式碼空間是開放的(Open World),允許在程式的任何時候通過類載入器去載入新的類,作為程式的一部分執行。要進行提前編譯,就必須放棄這部分動態性,假設程式的程式碼空間是封閉的(Closed World),所有要執行的程式碼都必須在編譯期全部可知。

這一點不僅僅影響到了類載入器的正常運作,除了無法再動態載入外,反射(通過反射可以呼叫在編譯期不可知的方法)、動態代理、位元組碼生成庫(如 CGLib)等一切會執行時產生新程式碼的功能都不再可用——如果將這些基礎能力直接抽離掉,Hello world 還是能跑起來,大部分的生產力工具都跑不起來,整個 Java 生態中絕大多數上層建築都會轟然崩塌。隨便列兩個Case:Flink的SQL API會解析SQL並生成執行計劃,這個時候會通過JavaCC動態生成類載入到程式碼空間中去;Spring也有類似的情況,當AOP通過動態代理的方式去生成相關邏輯時,本質還是在Runtime時生成程式碼並載入進去。

要獲得有實用價值的提前編譯能力,只有依靠提前編譯器、元件類庫和開發者三方一起協同才可能辦到——可以參考Quarkus。

Quarkus和我們上述的方法如出一轍,以Dependency Inject為例:所有要執行的程式碼都必須在編譯期全部可知,在編譯期就推匯出來相關的Bean,最後交給 GraalVM來執行。

1.2 Memory Access Efficiency Improvement

Java 即時編譯器的優化效果拔群,但是由於 Java“一切皆為物件”的前提假設,導致它在處理一系列不同型別的小物件時,記憶體訪問效能很差。這點是 Java 在遊戲、圖形處理等領域一直難有建樹的重要制約因素,也是 Java 建立 Valhalla 專案的目標初衷。

這裡舉個例子來說明此問題,如果我想描述空間裡面若干條線段的集合,在 Java 中定義的程式碼會是這樣的:

public record Point(float x, float y, float z) {}
public record Line(Point start, Point end) {}
Line[] lines;

物件導向的記憶體佈局中,物件識別符號(Object Identity)存在的目的是為了允許在不暴露物件結構的前提下,依然可以引用其屬性與行為,這是物件導向程式設計中多型性的基礎。在 Java 中堆記憶體分配和回收、空值判斷、引用比較、同步鎖等一系列功能都會涉及到物件識別符號,記憶體訪問也是依靠物件識別符號來進行鏈式處理的,譬如上面程式碼中的“若干條線段的集合”,在堆記憶體中將構成如下圖的引用關係:

計算機硬體經過 25 年的發展,記憶體與處理器雖然都在進步,但是記憶體延遲與處理器執行效能之間的馮諾依曼瓶頸(Von Neumann Bottleneck)不僅沒有縮減,反而還在持續加大,“RAM Is the New Disk”已經從嘲諷梗逐漸成為了現實。

一次記憶體訪問(將主記憶體資料調入處理器 Cache)大約需要耗費數百個時鐘週期,而大部分簡單指令的執行只需要一個時鐘週期而已。因此,在程式執行效能這個問題上,如果編譯器能減少一次記憶體訪問,可能比優化掉幾十、幾百條其他指令都來得更有效果。

額外知識:馮諾依曼瓶頸
不同處理器(現代處理器都整合了記憶體管理器,以前是在北橋晶片中)的記憶體延遲大概是 40-80 納秒(ns,十億分之一秒),而根據不同的時脈頻率,一個時鐘週期大概在 0.2-0.4 納秒之間,如此短暫的時間內,即使真空中傳播的光,也僅僅能夠行進 10 釐米左右。

資料儲存與處理器執行的速度矛盾是馮諾依曼架構的主要侷限性之一,1977 年的圖靈獎得主 John Backus 提出了“馮諾依曼瓶頸”這個概念,專門用來描述這種侷限性。

Java編譯器的確在努力減少記憶體訪問,從 JDK 6 起,HotSpot 的即時編譯器就嘗試通過逃逸分析來做標量替換(Scalar Replacement)和棧上分配(Stack Allocations)優化,基本原理是如果能通過分析,得知一個物件不會傳遞到方法之外,那就不需要真實地在物件中建立完整的物件佈局,完全可以繞過物件識別符號,將它拆散為基本的原生資料型別來建立,甚至是直接在棧記憶體中分配空間(HotSpot 並沒有這樣做),方法執行完畢後隨著棧幀一起銷燬掉。

不過,逃逸分析是一種過程間優化(Interprocedural Optimization),非常耗時,也很難處理那些理論上有可能但實際不存在的情況。這意味著它是Runtime時發生的。而相同的問題在 C、C++ 中卻並不存在,上面場景中,程式設計師只要將 Point 和 Line 都定義為 struct 即可,C# 中也有 struct,是依靠 .NET 的值型別(Value Type)來實現的。這些語言在編譯期就解決了這些問題。

而Valhalla的目標就是提供類似的值型別支援,提供一個新的關鍵字(inline),讓使用者可以在不需要向方法外部暴露物件、不需要多型性支援、不需要將物件用作同步鎖的場合中,將類標識為值型別。此時編譯器就能夠繞過物件識別符號,以平坦的、緊湊的方式去為物件分配記憶體。

Valhalla目前還處於Preview階段。可以在這裡看到推進的情況。希望能在下個LTS版本正式用上它吧。

1.3 Coroutine

Java 語言抽象出來隱藏了各種作業系統執行緒差異性的統一執行緒介面,這曾經是它區別於其他程式語言的一大優勢。不過,這也是曾經。

Java 目前主流的執行緒模型是直接對映到作業系統核心上的 1:1 模型,這對於計算密集型任務這很合適,既不用自己去做排程,也利於一條執行緒跑滿整個處理器核心。但對於 I/O 密集型任務,譬如訪問磁碟、訪問資料庫佔主要時間的任務,這種模型就顯得成本高昂,主要在於記憶體消耗和上下文切換上。

舉個例子。64 位 Linux 上 HotSpot 的執行緒棧容量預設是 1MB,執行緒的核心後設資料(Kernel Metadata)還要額外消耗 2-16KB 記憶體,所以單個虛擬機器的最大執行緒數量一般只會設定到 200 至 400 條,當程式設計師把數以百萬計的請求往執行緒池裡面灌時,系統即便能處理得過來,其中的切換損耗也相當可觀。

Loom 專案的目標是讓 Java 支援額外的 N:M 執行緒模型,而不是像當年從綠色執行緒過渡到核心執行緒那樣的直接替換,也不是像 Solaris 平臺的 HotSpot 虛擬機器那樣通過引數讓使用者二選其一。

Loom 要做的是一種有棧協程(Stackful Coroutine),多條虛擬執行緒可以對映到同一條物理執行緒之中,在使用者空間中自行排程,每條虛擬執行緒的棧容量也可由使用者自行決定。此外,還有兩個重點:

  • 儘量相容所有原介面。這意味著原來所有的執行緒介面都可以當作協程使用。但我覺得挺難的——假如裡面的程式碼調到Native方法,這個Stack就和這個執行緒繫結了,畢竟Coroutine是個使用者態的東西。
  • 支援結構化併發:簡單來說就是非同步的程式碼寫起來像同步的程式碼,這點Go做的很好。畢竟巢狀的回撥函式著實讓人痛苦。

上述的內容如果拆開來細說,基本就是:

  • 協程的排程;
  • 協程的同步、互斥與通訊;
  • 協程的系統呼叫包裝,尤其是網路 IO 請求的包裝;
  • 協程堆疊的自適應。

小知識:每個協程,都有一個自己專享的協程棧。這種需要一個輔助的棧來執行協程的機制,叫做 Stackful Coroutine;而在主棧上執行協程的機制,叫做 Stackless Coroutine。

Stackless Coroutine意味著:

  • 執行時:活動記錄放在主執行緒的棧上
  • 暫停時:堆中保留活動記錄
  • 可以呼叫其他函式
  • 只能在頂層暫停執行,不可以在子函式/子協程裡暫停

而Stackfull Coroutine意味著:

  • 執行時:單獨的執行棧
  • 可以在呼叫棧的任何一級暫停
  • 生命週期可以超過它的建立者
  • 可以從一執行緒上跑到另一個執行緒上

因此,一個完備的協程庫基本頂得上一個作業系統裡的程式部分了。只是它在使用者態,程式在核心態。

這個專案可以在這裡看到。目測JDK19就可以嚐嚐鮮了。

2.小結

目前在雲原生領域,Java可能未必是好的選擇——在這個領域最讓人難以忍受的就是其龐大的Runtime以及較長的Startup時間,在以前這是Java優點的來源,但到了雲原生時代,則成了Java顯而易見弱點。因此Java想在雲原生時代繼續保持前幾十年的趨勢,解決這個問題迫在眉睫。從這個點來看,我很看好Quarkus。

Valhalla帶來的優化很多場景都可以用上,一些長時間執行應用也可以獲得更多的效能收益。

而協程針對的是IO密集型場景,本身也可以通過NIO、AIO方式來避免執行緒的大量消耗。因此Loom在筆者看來更像是錦上添花的事。

相關文章