為什麼Java有GC調優而沒聽說過有CLR的GC調優?

2022-07-26 12:02:09

前言

在很多的場合我都遇到過一些群友提這樣的一些問題:

  • 為什麼Java有GC調優而CLR沒有聽說過有GC調優呢?
  • 到底是Java的JVM GC比較強還是C#使用的.NET CLR的GC比較強呢?
    其實業內已經有幾位大佬的高贊文章和大家分享一下,主要討論JVM和CLR還有GC調優,今天就借用大佬的回答給大家分享一下。

R大的回答

首先給大家介紹一下R大,R大網名叫RednaxelaFX,南京大學畢業巨佬,主攻高階程式語言虛擬機器器的設計與實現,對於C# .NET CLR有非常深入的研究和了解。先後加入阿里、Oracle、Azul System從事JVM虛擬機器器開發的工作(HotSpot JVM and Zing JVM)。現在在Databricks從事Spark開發工作。

R大社交主頁

iteye部落格:https://www.iteye.com/blog/user/rednaxelafx
知乎主頁: https://www.zhihu.com/people/rednaxelafx
GitHub: https://github.com/rednaxelafx

R大回答相關問題摘抄

為什麼聽說過 JVM 調優,卻沒聽說過 CLR 調優?

RednaxelaFX的回答 - 知乎 https://www.zhihu.com/question/52900051/answer/132583244

這裡R大主要就CLR上幾種調優方式和為什麼CLR不需要xmx、xmn等引數給出了原因

因為題主您就是沒聽說過 >_<

假定題主說的是下面三個層面的調優的頭兩種情況:

  1. 對VM的設定引數調優(例如GC引數)
  2. 對應用程式自身的程式碼調優
  3. 對VM自身的實現調優

(2)的話無論什麼語言什麼環境都好,追求效能的人肯定都有在做。Java和.NET都有好用的profiler可以幫助這方面的調查和調優。

有時候引數調優還不夠的地方,也只能自己改自己的應用程式碼來解決問題了。請看一個經典案例:

In managed code we trust, our recent battles with the .NET Garbage Collector

(3)的話,CLR還沒開源的時候,也無從調起。倒是有不少人給Mono貢獻改進效能的patch,也算是廣義上「.NET」的VM實現層面調優吧。

在CoreCLR開源後,也有不少人給CoreCLR貢獻各種patch呢,包括「調優」。

.NET碼農們以前時不時會中招的一種地方是程式進入GC的耗時(time-to-GC),也就是從CLR說「我要開始做GC了」到「真正開始做GC」之間的耗時。這裡主要開銷來自請求所有應用執行緒暫停(SuspendEE),這些執行緒要多久才完成對該請求的響應。聽說過這個過程中會發生「250ms的倍數的等待時間」不?請跳傳送門:

Garbage Collection Thread Suspension Delay (250ms Multiples)

像這種問題就是不進到VM內部做修改的話無法解決的。

(1)的話,其實就算當年CLR還沒開源的時候,CLR也是有調優引數可以設定的呢。

最經典的就是選擇試用Workstation GC(WKS GC)或者Server GC(SVR GC)。見過<gcServer>引數不?

後來可以設定使用Concurrent GC、Background Workstation GC、Background Server GC等。

使用者還可以在程式碼裡通過 GCSettings.LatencyMode 屬性來影響GC的行為。

看,調優參數列之一:
Runtime Settings Schema
不過CLR跟HotSpot VM在設定上有一個顯著的區別,就是CLR不需要使用者指定一個「GC堆的最大大小」。這跟CLR的GC堆的基礎設計思路有關係。

HotSpot VM的GC堆一定要使用連續的虛擬地址空間。VM在啟動的時候會一口氣reserve GC所需要的整個地址空間,然後再按需commit。-Xmx會參與到GC堆最大大小的計算中。

CLR的GC堆則是分段式的(segemented),GC堆所用的空間會一個個segment分配,用滿了一個再去分配一個新的;segment不需要在連續的地址空間上。這樣GC堆可以按需自動增長或者縮減,可以一直增長到耗盡虛擬地址空間或者達到配額。

CLR這種分段式GC堆的好處是,在Windows上,特別是32位元Windows上,虛擬地址空間中使用者程式可以用的部分是比較零碎的,想要用到盡就不能對「連續的地址空間」有太多要求,這種條件下CLR跑在Windows上就可以充分利用資源。

而且這樣一來,使用者就不用頭疼實現想好要設定多大的堆給CLR用了。反正它需要用多少會自己去增長。這使用者體驗就比絞盡腦汁想個好-Xmx要爽。

這種做法的壞處…怎能沒有壞處呢。壞處也有若干。其中一個就是在這樣的堆上實現的分代式GC的write barrier效率會比HotSpot那種用連續地址空間的要差一些。而且segmented heap實現起來也稍微複雜一些。

CLR 相比 JVM有哪些先進之處?

CLR 相比 JVM有哪些先進之處? - RednaxelaFX的回答 - 知乎 https://www.zhihu.com/question/39400401/answer/81293750

這裡R大主要介紹了下CLR和JVM的不同,和下面hez大佬的回答可以對應起來

雖然只寫了一半但還是先發出來免得坑掉。後面邊討論邊更新吧…

這個問題原文是:

CLR 相比 JVM有哪些先進之處?

留下備份。

首先這個問題按其原樣無法回答,因為CLR與JVM不是可比較的物件。

CLR(Common Language Runtime)是CLI(Common Language Infrastructure)規範中的VES(Virtual Execution System)的一種具體實現,而「JVM」不指定實現的話只能說是一種抽象的規範。

適合比較的物件是:

  • 規範層面:CLI vs JVM
  • 實現層面:CLR / Mono 等 vs HotSpot VM / J9 VM / JRockit VM 等

從規範層面看的話,CLI規範

ECMA-335

當前最新的版本是2012年出的第6版。我沒太跟進新的CLI規範所以不確定這個版本的CLI與哪個版本的.NET Framework裡的CLR對應。

JVM規範

The Java® Virtual Machine Specification

當前最新的版本則是2015年出的Java SE 8版。

在規範層面上,當前的CLI完勝當前的JVM。

《Virtual Machines: Versatile Platforms for Systems and Processes》

一書中有一章專門對比介紹了CLI與JVM的設計,值得一讀。

JVM規範由Sun最初的JVM實現(後來稱為Classic VM)抽象而來,然後幾乎沒有大的改動。後來的更新主要新增了Class檔案對泛型資訊的有限記錄、註解(annotation)支援、位元組碼校驗器的更新(split verifier / type checking verifier)、invokedynamic及MethodHandle支援、default method支援等。

最初的JVM規範雖然也提到它「可以支援多種語言」,但主要目的還是支援Java一種語言的執行,直到Java 7新增了JSR 292(invokedynamic與MethodHandle)後才有了專門為Java以外的語言設計的功能。

CLI由.NET最初的CLR實現抽象而來,然後與CLR一起逐漸進化。它出現的時間畢竟比JVM規範晚許多,而且一開始就以支援多種語言、多種範型的執行與互操作為設計目的,自然設計得更完善。對歷史有興趣的同學可以搜搜「Project 7」看。

Interviewer: I've heard that there was a project where Microsoft started to inte...

Don Syme: That's a small part of the sequence. The visional design of the .NET platform was very much expected to be a multi-language platform from the start. Right back in 1998, just in fact as our research group in programming languages started at Microsoft and I joined the team and then other 10 of us joined the team, we were approached by a guy called James Plamondon, who started the project called Project 7, which was about getting 7 academic and 7 industrial programming languages on each side to target the .NET common language runtime and really check out if it was good enough, to see if design changes could be made early on in the design process of .NET to make sure it was good enough for a range of programming languages.

Project 7的參與方嘗試了將許多種語言移植到CLR上,包括C、Pascal、Cobol (Fujitsu)、Fortran (Salford)、Haskell、Standard ML、Eiffel、Active Oberon for .NET (ETH)、Gardens Point Component Pascal (QUT)等等。

後來還有更進一步的「Project 7+」。

Technical Overview of the Common Language Runtime

要追尋CLR更早的黑歷史就不得不提微軟的Visual J++。請跳傳送門:

微軟當年的 J++ 究竟是什麼?為什麼 Sun 要告它? - RednaxelaFX 的回答

下面先列舉一些點提醒我回頭更新…

Assembly vs Class檔案

  • 常數池以stream的方式存在
  • 可以有strong name

CIL(Common Intermediate Language) / MSIL vs Java位元組碼

  • CIL不僅有二進位制形式的規範,而且還有標準的文字形式語法;Java位元組碼只有二進位制形式的規範
  • 區域性變數區裡無論什麼型別的值都使用1個slot,而JVM則是long和double用相鄰的2個slot
  • 例外處理支援filter
  • 支援具現化泛型(reified generics)
  • 泛型支援宣告點協變/逆變(declaration-site variance)
    • Java語言層面的泛型則是支援使用點協變/逆變(use-site variance)
  • 支援無符號整數型別(unsigned)
  • 支援使用者自定義值型別(value type)
  • 支援對使用者自定義值型別指定顯式記憶體佈局,可以模擬C的struct和union以便互操作(StructLayout)
  • 支援檢查溢位的算術運算(checked arithmetic)
  • 支援有保證的尾呼叫(tail call)
  • 支援unsafe verifiable程式碼
  • 支援輕量程式碼生成(LCG,Lightweight Code Generation)
  • 支援委託型別(Delegate / MulticastDelegate)
  • CLI Delegate的single cast版本與Java 7的MethodHandle有相似之處
  • 支援方便的P/Invoke和Reverse P/Invoke
  • 不支援自定義類載入器(custom class loader)。JVM上過於靈活的ClassLoader簡直是頭疼的要命,不支援這個簡直好。

CLI裡藏的私貨:Assembly採用PE(Portable Executable)格式。PE是Windows上原生的可執行檔案格式。

CLR與Windows的整合

  • Windows Loader可以直接識別.NET Assembly並啟動CLR來執行程式。
  • Windows對GAC(Global Assembly Cache)有檔案系統層面的特殊處理,「Fusion」。
  • 與COM有良好的互操作能力。畢竟CLR原本就是希望設計成下一代COM,原本還用過COR這個名字(COM Object Runtime)。
  • 分塊式GC堆(Chunked GC Heap),可以自動擴張和收縮GC堆的大小,並且在Windows這種地址空間比較破碎的環境中可以有效的利用地址空間裡的空隙。對應的,JRockit在Windows上則是用filler object來利用空隙。
  • GC堆分為多個空間,特別是高頻堆/低頻堆(high-frequency heap / low-frequency heap)的劃分很有趣。
  • GC支援固定住物件(Object Pinning)
  • GC提供API讓應用程式可以指定native物件的記憶體壓力,以便GC與native協調工作(System.GC.AddMemoryPressure Method (Int64)
  • 支援Windows的結構化例外處理(SEH)。

CLR 與 一些JVM實現之間有對應物的

  • CLR:DAC / SOS擴充套件;HotSpot VM:Serviceability Agent;J9 VM:DTFJ / jdmpview
  • CLR:CAS(Code Access Security)與 JVM的沙箱

hez大佬的回答

再來給大家介紹一下hez2010大佬,今年剛從中山大學畢業,對於C++、.NET、C#、Rust等平臺語言都有深入的研究,經常在部落格園看文章的小夥伴應該早就看過hez佬的博文。他是.NET Runtime開源專案的持續貢獻者、Microsoft Student Partner、可以說年少有為。

hez大佬的社交主頁

個人部落格:https://hez2010.com/
部落格園:https://www.cnblogs.com/hez2010/
知乎:https://www.zhihu.com/people/hez2010
GitHub:https://github.com/hez2010

hez大佬回答相關問題摘抄

.NET Core已經開源好幾年了, 為什麼不像JVM那樣很多人研究和調優其GC演演算法?

hez2010的回答 - 知乎 https://www.zhihu.com/question/365482363/answer/2389471084

這和虛擬機器器(這裡指 JVM 和 CoreCLR)本身的特性有關係,由於基礎設施的不同造成開發人員解決問題的取向不同。

當開發者遇到 GC 導致的問題時:

Java 開發者選擇調優 GC,例如設定堆大小、調整各代和 region 的預算等等,最終使得「滿足 GC 觸發的條件」變得不那麼容易滿足來緩解這個問題,代價自然是記憶體佔用更高,畢竟只要記憶體是無限的那就不需要 GC,同理只要 GC 對堆大小容忍度夠高不去回收或者少回收,自然就能減輕 GC 帶來的問題。因此開發者需要根據自身的 workload 來調配這些引數,最終達到一個既不會佔用大的離譜甚至導致 OOM 錯誤,又不會導致頻繁 GC 的平衡點。

而 .NET 開發者則會選擇優化自己的程式碼,既然熱路徑中物件在託管堆記憶體上大量分配造成了頻繁的 GC 導致出了效能問題,那乾脆不用 class,轉而去用 struct、stackalloc、fixed buffer、NativeMemory、Span 甚至指標,小物件什麼的全都在棧上分配,用完自動隨著棧的銷燬而銷燬,甚至 JIT 可以直接把小值型別物件優化到暫存器裡面,連記憶體分配都不需要;堆記憶體也不需要 GC 託管堆了,直接用 NativeMemory 自己分配自己回收管理,全程完全不需要 GC 介入。這麼做的指導思想很簡單,既然是 GC 導致的問題,那麼直接從源頭解決:不分配託管堆記憶體就不需要 GC。

由於 CoreCLR 從一開始就是支援值型別、指標和非託管堆的,因此有能力的開發者可以在需要低延時的場景中(例如遊戲)選擇區域性自行管理記憶體從而無需 GC;而其他大多數情況下 GC 並不會導致問題,因此這些時候開發者也可以充分利用 GC 帶來的便利提升開發效率。

而 JVM 並沒有這樣的設施,因此開發者需要將解決問題的方法聚焦到如何讓 GC 適應自己的 workload 上,通過調優一系列的引數來緩解因為 GC 導致的問題,再有就是通過 JVM 團隊改進 GC 的演演算法來儘可能讓 GC 暫停執行緒的時間變短。

另外,這也使得 JVM 和 CoreCLR 的工作重心完全不同,在 CoreCLR 上一個軟實時 GC(ZGC)的重要性遠遠沒有 JVM 上來的高,因此優先順序就會變低。在 JVM 上 ZGC 是理所當然的重要,而在 CoreCLR 上卻並不是,相反,.NET 工作重心則聚焦在改進 struct 相關的底層程式碼編寫體驗上:例如允許棧物件(ref struct)持有其他棧物件的參照(ref field),允許 ref struct 作泛型引數和約束,允許在棧上分配任何物件,以及棧物件生命週期的管理等等。

不過最近 CoreCLR 的 GC 團隊也逐漸開始有興趣實現軟實時 GC 以改進遊戲等低延時場景的開發體驗(注意我說的是開發體驗,因為讓 GC 全盤管理堆記憶體可以很大程度上提升開發效率並避免記憶體安全問題,只是單純要求低延時甚至 0 延時的話現在完全可以通過繞開 GC 做到),這也是建立在其他高優先順序工作(例如 region 改造和 DPAD 等)基本都完成的基礎上才輪到的。

至於某些極端情況,例如要求可預測的效能、絕對 0 GC 延時的,唯一辦法只有繞開 GC,這種情況即使 ZGC 也無法對應。

更新:

補充一下,.NET 的 GC 是可以直接替換實現的,通過設定環境變數 DOTNET_GCName 指向你按照介面自己實現的 GC 的路徑,就能做到替換掉自帶的 GC:

https://docs.microsoft.com/en-us/dotnet/core/runtime-config/garbage-collector#standalone-gc​docs.microsoft.com/en-us/dotnet/core/runtime-config/garbage-collector#standalone-gc

所以要是真的對自帶 GC 不滿的話,可以考慮自己寫 GC 來用。

總結

兩位大佬回答的比較好,基本原因給說的比較明白了,在我看來兩者的比較如下:

  • 從大多數人所認為「調優」選項來說,其實.NET CLR GC也是有調優選項的,只是因為CLR的GC預設使用了Segment方式來組織記憶體,會動態的申請和歸還記憶體空間,所以不需要像JVM一些GC一樣去人為的設定-xmx-xmn-xms這些選項。
  • 現在的.NET的GC和JVM上的GC(指G1)其實是平分秋色,因為都是為高吞吐量設計的分代並行GC,在高吞吐量下都有著不錯的表現,當然各自也有優化空間。
  • 受制於JVM平臺沒有自定義值型別、stackalloc、NativeMemory、Span和指標等特性,.NET這邊能無分配或者棧上分配地方JVM上需要堆分配。所以JVM平臺上的GC壓力會比CLR上的壓力更大一些(雖然有逃逸分析、標量替換等等JIT輔助技術,但是沒有原生那麼快)。
  • JVM平臺開源的比較早,所以它的GC有非常多的選擇,雖然造成初學者面臨很多調優和選擇的問題,但是GC的豐富性這一點是.NET平臺上所缺失的。
  • JVM平臺上有單獨低延時的C4和ZGC。目前.NET平臺上沒有單獨低延時GC實現,.NET平臺這邊低延時第一是調整GCMode為GCLatencyMode.SustainedLowLatency,另外通過值型別、ref、棧上分配、非託管操作還有在關鍵路徑暫時關閉GC等方式來實現低延時。

GC演演算法從提出到現在歷經了幾十年的時間,筆者也關注了一些近年的論文,其實總得來說在馮諾依曼架構體系下,GC垃圾回收演演算法已經沒有顛覆性的變化。

在這個算力沒有指數級增長的時代,各個語言的GC演演算法都開始趨同(C# Java Js Go等等),在沒有更創新性的GC演演算法出來之前,從源頭上避免分配和Rust語言那樣的RAII管理記憶體可能是各個語言後面都會走的路。

如果大家想了解更多GC相關的知識,可以看下方的文章連結和推薦的書。

https://www.cnblogs.com/InCerry/p/dotnet-gc-workflow-1.html
https://www.cnblogs.com/InCerry/p/maoni-mem-doc.html
https://www.cnblogs.com/InCerry/p/put-a-dpad-on-that-gc.html