JDK9-19 JVM內(nèi)存框架歷史演進

前言

在JDK9之前,Java基本上平均每三年出一個版本。但是自從2017年9月份推出JDK9到現(xiàn)在,Java開始了瘋狂更新的模式,基本上保持了每年兩個大版本的節(jié)奏。從2017年至今,已經(jīng)發(fā)布了一個版本到了JDK19。其中包括了兩個LTS版本(JDK11與JDK17)。除了版本更新節(jié)奏明顯加快之外,JDK也圍繞著云原生場景的能力,推出并增強了一系列諸如容器內(nèi)資源動態(tài)感知、無停頓GC(ZGC、Shenandoah)、運維等等云原生場景方面的能力。這篇文章是EDAS團隊的同學(xué)在服務(wù)客戶的過程中,從云原生的角度將相關(guān)的功能進行整理和提煉而來。希望能和大家一起認識一個新的Java形態(tài)。

JVM GC 發(fā)展回顧

JVM自從誕生以來,以"內(nèi)存自動管理"和"一次編譯到處運行"兩個殺手锏能力,外加Spring這個超級生態(tài),在企業(yè)應(yīng)用開發(fā)領(lǐng)域中一直處于"人人模仿,從未超越"的江湖地位。內(nèi)存的自動管理從技術(shù)角度,用一句通俗的語言進行簡述就是:"根據(jù)設(shè)計好的堆內(nèi)存布局模型,采用一定的跟蹤識別與清理的算法,達到內(nèi)存自動整理及回收的效果"。而一代代內(nèi)存管理技術(shù)不斷演進的目標,就是在不斷提升并發(fā)與降低延時的同時,尋找資源利用最優(yōu)的方案,從某種意義上說,如果我們不帶來一些突破性的算法,這個三者的關(guān)系如同分布式中的CAP定理一樣,很難兼得。如下圖所示:


Throughput(吞吐量)-Latency(延時)-Resource(資源利用).png

在JVM中,內(nèi)存管理趨近等同于GC。其中CMS從1.4版本(2002年)開始引入,一度成為最為經(jīng)典的GC算法。然而從JDK9開始發(fā)起棄用CMS的JEP提案,到2020年初發(fā)布的JDK14完全從代碼中抹除,意味著在他成年之際正式宣告了他歷史使命的結(jié)束。那么到現(xiàn)在我們又應(yīng)該從什么角度上去理解這一技術(shù)領(lǐng)域的發(fā)展方向,不管技術(shù)如何演進,能確定的是變化主線是圍繞著三個方向進行,分別是:堆內(nèi)存布局、線程模型、收集行為。我們將從這三個點出發(fā)進行分析。

堆內(nèi)存布局的變化

JVM堆內(nèi)存布局最為經(jīng)典的是分代模型,即年輕代和老年代進行區(qū)分,不同的區(qū)域采用的回收算法和策略也完全不一樣。在一個在線應(yīng)用(如微服務(wù)形態(tài))的request<->response模型中,所產(chǎn)生的對象(Object)絕大多數(shù)是瞬時存活的對象,所以大部分的對象在年輕代就會被相對簡單、輕量、且高頻的MinorGC所回收。在年輕代中經(jīng)過幾次MinorGC若依然存活則會將其晉升到老年代。在老年代中,相比較而言由于對象存活多、內(nèi)存容量大,所以所需要的GC時間相對也會很長,同時由于每一次的回收會伴隨著長時間的Stop-The-World(簡稱STW)出現(xiàn)。在內(nèi)存需求比較大且對于時延和吞吐要求很高的應(yīng)用中,其老年代的表現(xiàn)就會顯得捉襟見肘。而且由于不同的分代所采用的回收算法一般都不一樣,隨著業(yè)務(wù)復(fù)雜度的增加,GC行為變得越來越難以理解,調(diào)優(yōu)處理也就愈發(fā)的復(fù)雜。

分代模型.png

單純從堆內(nèi)存布局來理解,一個簡單的邏輯是內(nèi)存區(qū)域越小,回收效率越高,經(jīng)典分代模型中的Young區(qū)已經(jīng)印證了這一點。為了解決上述問題,G1算法橫空出世,引出基于區(qū)域(Region)的布局模型,帶來的變化是內(nèi)存在物理上不再根據(jù)對象的"年齡"來劃分布局,而是默認全部劃分成等大小的Region和專門用來管理超級大對象的獨占Region,年輕代和老年代不再是一個物理劃分,只是一個Region的一個屬性。直觀理解上,除了能管理的內(nèi)存更大(G1理論值64G)之外,這樣帶來一個顯而易見的好處就是可以預(yù)控制一次FullGC的STW的時間,因為Region大小一致,則可以根據(jù)停頓時間來推算這次GC需要回收的Region個數(shù),而沒有必要每次都將所有的Region全部清理完畢。
固定分區(qū)模型.png

隨著這項技術(shù)的進一步發(fā)展,到了現(xiàn)代化的Pauseless(ZGC)的算法場景中,有些算法暫時沒有了分代的概念,同時Region按照大小劃分了Small/Medium/Large三個等級,更精細的Region管理,也進一步來更少的內(nèi)存碎片和內(nèi)存利用率的提升、及其STW停頓時間更精準的預(yù)測與管理。
靈活分區(qū)模型.png

線程模型變化

說線程模型之前,先簡單提一下GC線程與業(yè)務(wù)線程,GC線程是指JVM專門用來處理GC相關(guān)任務(wù)的線程,這在JVM啟動時就已經(jīng)決定。在傳統(tǒng)的串行算法中,是指只有一個GC線程在工作。在并行(Parallel)的算法中,存在多個GC線程一起工作的情況(CMS中GC線程個數(shù)默認是CPU的核數(shù))。同時一些算法的某些階段中(如: CMS的并發(fā)標記階段),GC線程也可以和業(yè)務(wù)線程一起工作;這個機制就縮短了整體STW的時間,這也是我們所說的并發(fā)(Concurrent)模式。


線程模型變化.png

在現(xiàn)代化的GC算法中,并不是所有和GC相關(guān)的任務(wù)都只能由GC線程完成,如ZGC中的Remap階段,業(yè)務(wù)線程可以通過內(nèi)存讀屏障(ReadBarrier),來矯正對象在此階段因為被重新分配到新區(qū)域后的指針變化,進而進一步減少STW的時間。

收集行為變化

收集行為是指的在識別出需要被收集的對象之后,JVM對于對象和所在內(nèi)存區(qū)域如何進行處理的行為。從早期版本至今,大致分為以下幾個階段:

  1. Mark Copy: 是指直接將存活對象從原來的區(qū)域拷貝至另外一個區(qū)域。這是一種典型的空間換時間的策略,好處顯而易見:算法簡單、停頓時間短、且調(diào)參優(yōu)化容易;但同時也帶來了近乎一倍的空間閑置。在早期的GC算法使用的是經(jīng)典的分代模型。其中對于年輕代Survivor區(qū)的收集行為便是這種策略。


    Mark Copy.png
  2. Mark Sweep:為了減少空間成倍的浪費,其中一個策略就是在原有的區(qū)域直接對對象 Mark 后進行擦除。但由于是在原來的內(nèi)存區(qū)域直接進行對象的擦除,應(yīng)用進程運行久了之后,會帶來很多的內(nèi)存碎片,其結(jié)果是內(nèi)存持續(xù)增長,但真實利用率趨低。


    Mark Sweep.png
  3. Mark Sweep-Compact: 這是對于Mark Sweep的一個改良行為,即擦除之后會對內(nèi)存進行重新的壓縮整理,用以減少碎片從而提升內(nèi)存利用率。但是如果每次都進行整理,就會延長每次 FullGC 后的 STW 時間。所以 CMS 的策略是通過一個開關(guān)(-XX:+UseCMSCompactAtFullCollection,默認開起) 和一個計數(shù)器(-XX:CMSFullGCsBeforeCompaction,默認值為 0) 進行控制,表示 FullGC 是否需要做壓縮,以及在多少次 FullGC 之后再做壓縮。這個兩個配置配合業(yè)務(wù)形態(tài)去做調(diào)優(yōu)能起到很好的效果。
    Mark Sweep-Compact.png
  4. Mark Sweep-Compact-Free: JVM的應(yīng)用有一個“內(nèi)存吞噬器”的惡名,原因之一就是在進程運行起來之后,他只會向操作系統(tǒng)要內(nèi)存從來不會歸還(典型只借不還的渣男)。不過這些在現(xiàn)代化的分區(qū)模型算法中開始有了改善,這些算法在FullGC之后,可以將整理之后的內(nèi)存以區(qū)域(Region)為粒度歸還給操作系統(tǒng),從而降低這一個進程的資源水位,以此來提升整個宿主機的資源利用率。


    Mark Sweep-Compact-Free.png

擴展-內(nèi)存優(yōu)化

JEP 345: G1 NUMA-Aware

現(xiàn)代化的服務(wù)器大多是屬于多Node的架構(gòu),下圖表示有4個Node,每一個Node內(nèi)部都會有相應(yīng)的CPU(有的架構(gòu)會有多個CPU)和對應(yīng)的物理內(nèi)存條。當CPU訪問訪問本Node內(nèi)部的物理內(nèi)存進行"本地訪問"時,其速度是通過QPI訪問其他節(jié)點內(nèi)存時的速度接近兩倍,同時不同遠近Node的訪問速度也都不一樣。在開啟NUMA的情況下,每個Node內(nèi)的CPU將優(yōu)先使用同Node內(nèi)的"本地"內(nèi)存,否則系統(tǒng)將所有Node內(nèi)的內(nèi)存統(tǒng)一對待進行隨機分配和訪問。


Node架構(gòu)圖.png

既然Numa的作用是CPU將盡量訪問"本地"內(nèi)存以加速內(nèi)存訪問速度,常規(guī)場景下如果我們需要使用這個能力,在系統(tǒng)開啟Numa的前提下,我們還需要對運行的程序進行綁核調(diào)優(yōu)等操作,以將應(yīng)用程序運行的進程和CPU有一個綁定關(guān)系。要達到這一效果,除了系統(tǒng)提供了一些運維管理工具(如linux中的taskset命令)之外,程序也可以通過調(diào)用系統(tǒng)API(如linux中的pthread_setaffinity)。在JVM多線程的模型中,如果想要通過自動編程的方式來進行CPU綁定,當下只能選擇帶有特定能力的商業(yè)版本,在OpenJDK中還不能很方便的完成這一能力。

那JVM內(nèi)對于Numa能做什么呢?這里有一個假設(shè),在一個線程內(nèi)運行的對象大部分都是瞬時的(即這個對象的作用域跟隨創(chuàng)建它的線程(或Runnable)的運行結(jié)束而消亡),原因和我們在上面介紹堆內(nèi)存布局模型時的新生代的選擇是一樣邏輯。基于這個假設(shè),JVM主要聚焦在了解決新生代的內(nèi)存分配和訪問的Numa感知上。其實JVM對于Numa的支持很多年前就開始了,在YoungGC的并行(Parrallel)收集器(通過-XX:+UseParallelGC開啟)中。開啟Numa之后,JVM優(yōu)先選擇Node內(nèi)部的"本地"內(nèi)存進行新對象的創(chuàng)建。

在云原生場景下,一個Kubernetes集群通常托管高規(guī)格的機器、同時高密的部署的小規(guī)格的工作負載,這個場景下,一個工作負載一直運行在同一個CPU或固定幾個 CPU 的場景會變得越來越普遍。如果JVM再把整個Worker的內(nèi)存不加區(qū)分的對待并進行分配,我們的內(nèi)存訪問性能勢必會急劇下跌。如下圖所示:

圖片.png

G1 算法通過 JEP 345在JDK14中得到了這一能力的支持,可通過參數(shù)-XX:+UseNUMA開啟,開啟之后,G1會盡量將固定大小的各個Region均攤在所有能分配的CPU Node中,在分配新對象時,將優(yōu)先使用同一Node 內(nèi)的"本地"內(nèi)存的 Region,如果"本地"內(nèi)存Region不夠時,將對此Region觸發(fā)一次GC;如果還不夠,再按照CPU的遠近盡量獲取相鄰Node的Region。此策略只針對G1中新生代的內(nèi)存區(qū)域生效。老年代區(qū)域和大對象區(qū)域還是沿用默認的策略。

JEP 387: Elastic Metaspace

Metaspace是用來存儲JVM中類的元數(shù)據(jù)信息,包括類中的運行時數(shù)據(jù)結(jié)構(gòu)、類中使用到的成員以及方法信息。他的前身是永久代,也就是PermGen。這一變化是JDK8中重要的一個升級的能力之一。從JEP122中提議并落地。這個JEP帶來的具體的變化可以參考下圖:


Elastic Metaspace.png

取消了永久代之后,帶來兩個變化如下:

  1. 存儲信息調(diào)整:將類中定義的常量和字符串常量池(Interned String)放入到了堆中,Metaspace 只存儲類元數(shù)據(jù)信息,即:
    • Klass信息,描述類的基礎(chǔ)屬性和類的繼承關(guān)系等;
    • NonKlass信息,包含方法、內(nèi)部類信息、成員變量定義等。
  2. 內(nèi)存布局調(diào)整:與之前在堆中開辟一塊區(qū)域相比,Metaspace 是直接使用操作系統(tǒng)的本地內(nèi)存進行分配,本地內(nèi)存劃分成多個 Chunk,以 ClassLoader 為維度進行分配和管理。

當一個 ClassLoader 加載一個對象時,所需要的空間從空閑的 Chunk 中分配一個或多個固定大小的塊,如未找到則向操作系統(tǒng)重新申請一個 Chunk。當某一個 ClassLoader 中所有的類都被卸載的時候,就可以將它所引用的內(nèi)存塊都歸還給 Chunk。等到對應(yīng) Chunk 完全處于"空閑"狀態(tài)的時候,這個 Chunk 也就就可以被操作系統(tǒng)回收。

看到這里我們先暫停一下,思考兩個問題:

  1. 他為什么這么調(diào)整?
    從JEP的描述,只提到了因為需要和JRockit(原OracleBEAJVM)做融合,而JRockit的設(shè)計中并沒有永久代。而從時間上看,正好是發(fā)生在Oracle收購Sun之后。所以一個猜想就是這個變化的根因應(yīng)該是組織推動大于技術(shù)驅(qū)動。當然從技術(shù)上這樣帶來的好處也很顯而易見:不再有負載的Perm設(shè)置;元空間和堆空間完全隔離后,兩邊的GC不會相互影響;單次FullGC因為掃描區(qū)域更小而使得STW時間更短;按照Chunk設(shè)計的構(gòu)想,在類被卸載時,有助于JVM釋放一些內(nèi)存給操作系統(tǒng)等等。
  2. 有沒有帶來新問題?
    有,就是在一些應(yīng)用程序中會出現(xiàn)多種類頻繁的加載/卸載的場景下, 導(dǎo)致 Metaspace所管理的Chunk會不停的更新和釋放而造成很嚴重的內(nèi)存碎片,碎片整理機制的缺失導(dǎo)致理想中的效果并未達到。最終造成了更多的內(nèi)存浪費。

在JDK 16中發(fā)布的JEP 387中,專門針對帶來的新問題做了一些改進:

  • 首先:減少碎片,內(nèi)存管理從內(nèi)置的Arena Chunk內(nèi)存管理算法,改為了簡單且經(jīng)典的伙伴算法,對,Linux 操作系統(tǒng)的內(nèi)存管理就是基于伙伴算法的。

伙伴系統(tǒng)把所有的空閑頁框分組為固定個數(shù)的塊鏈表,每個塊鏈表分別包含固定大小為 1K, 2K, 4K, .... 4M 大小的塊。當應(yīng)用程序向系統(tǒng)申請對應(yīng)的內(nèi)存大小時,系統(tǒng)將從最接近所需大小的鏈表中進行分配。

  • 其次:按需使用,等到真正使用內(nèi)存的時候才向操作系統(tǒng)發(fā)起內(nèi)存申請,而不是一開始就申請出來一塊很大的空間。

有一些 ClassLoader(如:BoostrapClassLoader)往往需要很多的空間,但是他真正使用并不是從一開始啟動就需要,而且甚至是永遠都不需要。

  • 第三:增加策略,為了防止頻繁的向操作系統(tǒng) 申請/釋放 內(nèi)存帶來額外的系統(tǒng)開銷,新引入了一個命令行參數(shù) -XX:MetaspaceReclaimPolicy=(balanced|aggressive|none)來進行調(diào)整。

其中 balance 是默認選項,會在系統(tǒng)回收和時間消耗之間做平衡,更多是兼容之前的行為。aggressive 是一種最為 “激進” 的回收策略,通過在回收時降低對應(yīng)頁框大小至 16K(默認64K),使回收內(nèi)存粒度更細來降低碎片。而 none 則是關(guān)閉回收行為。

JEP 351: ZGC Uncommit Unused Memory

ZGC在JDK 11時被引入,它是一款基于內(nèi)存區(qū)域(Region) 布局的垃圾回收器,我們可以通過 -XX:+UseZGC進行開啟。作為一款主打Pauseless的現(xiàn)代化的收集器,ZGC 相比于G1除了提供了三個不同大小的Region (2M/4M/8M,而G1為一個固定大小的值)進行管理之外,還因為在GC整理階段提供了內(nèi)存讀屏障來矯正對象指針的技術(shù)使得最終的 STW 時間更短。但是在JDK 14之前,被清理的 Region 還是無法歸還給操作系統(tǒng),相比G1在JDK9中就提供了類似的能力滯后了兩年多。

簡單概述一下,這個JEP指的是每次GC結(jié)束,JVM都會嘗試將釋放一部分內(nèi)存歸還給操作系統(tǒng)。但是如上一章節(jié)介紹Elastic Metaspace章節(jié)一樣,頻繁的向操作系統(tǒng)申請/歸還只能帶來更多的系統(tǒng)開銷,如何取舍是一門藝術(shù)。那么該如何選擇是否有操作手段呢?請先看下面這張圖:

圖片.png

首先,系統(tǒng)提供了一個額外的JVM的調(diào)整參數(shù)(SoftMaxHeapSize)來控制回收的行為,這個值應(yīng)該在 -Xms 和 -Xmx 之間,當系統(tǒng)使用的內(nèi)存低于這個值時,就是正常的收集行為,即只會進行清理和壓縮。而大于這個值但是小于 -Xmx 時,F(xiàn)ullGC 結(jié)束之后就會嘗試回收空閑的內(nèi)存區(qū)域(Region) 歸還給操作系統(tǒng)。達到的效果是 ZGC 將盡量保證整體堆內(nèi)存水位處于這個值之下。默認情況下這個值和 -Xmx 的大小是一致的。同時由于這個值是一個可動態(tài)調(diào)整(managable)的變量,隨著系統(tǒng)的運行,當我們發(fā)現(xiàn)需要進行調(diào)整的時,在認真評估之后,可以通過jcmd VM.set_flag SoftMaxHeapSize <bytes> 命令動態(tài)進行調(diào)整。

其次,上述方案雖然很完美的將選擇權(quán)交給了應(yīng)用管理人員,但是運行的過程中也會出來一種情況:如果應(yīng)用真實的使用量如果恰好在 SoftMaxHeapSize上下徘徊的時候,會造成很頻繁的系統(tǒng)內(nèi)存的申請和釋放。這個時候提供了另外一個策略,就是可以通過-XX:ZUncommitDelay來設(shè)置一個回收之前的延時,即不在GC結(jié)束馬上進行嘗試回收,而是等一段時間(默認5分鐘)后再進行回收,以免造成誤傷。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容