【轉】一文看懂JVM內存布局及GC原理
- 2019 年 10 月 8 日
- 筆記
作者簡介
楊俊明,攜程雲客服平台研發部軟件技術專家。從事IT行業10餘年,騰訊雲+社區、阿里雲棲社區、華為雲社區認證專家。近年來主要研究分佈式架構、微服務、Java技術等方向。
java的內存布局以及GC原理」是java開發人員繞不開的話題,也是面試中常見的高頻問題之一。
java發展歷史上出現過很多垃圾回收器,各有各的適應場景,很多網上的舊文章已經跟不上的變化。本文詳細介紹了java的內存布局以及各種垃圾回收器的原理(包括的ZGC),希望閱讀完後,大家對這方面的知識不再陌生,有所收穫,同時也歡迎大家留言討論。
一、JVM運行時內存布局
按java 8虛擬機規範的原始表達:(jvm)Run-Time Data Areas, 暫時翻譯為「jvm運行時內存布局」。
從概念上大致分為6個(邏輯)區域,參考下圖。註:Method Area中還有一個常量池區,圖中未明確標出。

這6塊區域按是否被線程共享,可以分為兩大類:

一類是每個線程所獨享的:
1)PC Register:也稱為程序計數器, 記錄每個線程當前執行的指令信。eg:當前執行到哪一條指令,下一條該取哪條指令。
2)JVM Stack:也稱為虛擬機棧,記錄每個棧幀(Frame)中的局部變量、方法返回地址等。註:這裡出現了一個新名詞「棧幀」,它的結構如下:

線程中每次有方法調用時,會創建Frame,方法調用結束時Frame銷毀。
3)Native Method Stack:本地(原生)方法棧,顧名思義就是調用操作系統原生本地方法時,所需要的內存區域。
上述3類區域,生命周期與Thread相同,即:線程創建時,相應的區域分配內存,線程銷毀時,釋放相應內存。
另一類是所有線程共享的:
1)Heap:即鼎鼎大名的堆內存區,也是GC垃圾回收的主站場,用於存放類的實例對象及Arrays實例等。
2)Method Area:方法區,主要存放類結構、類成員定義,static靜態成員等。
3)Runtime Constant Pool:運行時常量池,比如:字符串,int -128~127範圍的值等,它是Method Area中的一部分。
Heap、Method Area 都是在虛擬機啟動時創建,虛擬機退出時釋放。
註:Method Area 區,虛擬機規範只是說必須要有,但是具體怎麼實現(比如:是否需要垃圾回收? ),交給具體的JVM實現去決定,邏輯上講,視為Heap區的一部分。所以,如果你看見類似下面的圖,也不要覺得畫錯了。

上述6個區域,除了PC Register區不會拋出StackOverflowError或OutOfMemoryError ,其它5個區域,當請求分配的內存不足時,均會拋出OutOfMemoryError(即:OOM),其中thread獨立的JVM Stack區及Native Method Stack區還會拋出StackOverflowError。
最後,還有一類不受JVM虛擬機管控的內存區,這裡也提一下,即:堆外內存。

可以通過Unsafe和NIO包下的DirectByteBuffer來操作堆外內存。如上圖,雖然堆外內存不受JVM管控,但是堆內存中會持有對它的引用,以便進行GC。
提一個問題:總體來看,JVM把內存劃分為「棧(stack)」與「堆(heap)」兩大類,為何要這樣設計?
個人理解,程序運行時,內存中的信息大致分為兩類,一是跟程序執行邏輯相關的指令數據,這類數據通常不大,而且生命周期短;一是跟對象實例相關的數據,這類數據可能會很大,而且可以被多個線程長時間內反覆共用,比如字符串常量、緩存對象這類。
將這兩類特點不同的數據分開管理,體現了軟件設計上「模塊隔離」的思想。好比我們通常會把後端service與前端website解耦類似,也更便於內存管理。
二、GC垃圾回收原理
2.1 如何判斷對象是垃圾 ?
有兩種經典的判斷方法,借用網友的圖(文中最後有給出鏈接):

引用計數法,思路很簡單,但是如果出現循環引用,即:A引用B,B又引用A,這種情況下就不好辦了,所以JVM中使用了另一種稱為「可達性分析」的判斷方法:

還是剛才的循環引用問題(也是某些公司面試官可能會問到的問題),如果A引用B,B又引用A,這2個對象是否能被GC回收?
答案:關鍵不是在於A、B之間是否有引用,而是A、B是否可以一直向上追溯到GC Roots。如果與GC Roots沒有關聯,則會被回收,否則將繼續存活。

上圖是一個用「可達性分析」標記垃圾對象的示例圖,灰色的對象表示不可達對象,將等待回收。
2.2 哪些內存區域需要GC ?

在第一部分JVM內存布局中,我們知道了thread獨享的區域:PC Regiester、JVM Stack、Native Method Stack,其生命周期都與線程相同(即:與線程共生死),所以無需GC。線程共享的Heap區、Method Area則是GC關注的重點對象。
2.3 常用的GC算法
1)mark-sweep 標記清除法

如上圖,黑色區域表示待清理的垃圾對象,標記出來後直接清空。該方法簡單快速,但是缺點也很明顯,會產生很多內存碎片。
2)mark-copy 標記複製法

思路也很簡單,將內存對半分,總是保留一塊空着(上圖中的右側),將左側存活的對象(淺灰色區域)複製到右側,然後左側全部清空。避免了內存碎片問題,但是內存浪費很嚴重,相當於只能使用50%的內存。
3)mark-compact 標記-整理(也稱標記-壓縮)法

避免了上述兩種算法的缺點,將垃圾對象清理掉後,同時將剩下的存活對象進行整理挪動(類似於windows的磁盤碎片整理),保證它們佔用的空間連續,這樣就避免了內存碎片問題,但是整理過程也會降低GC的效率。
4)generation-collect 分代收集算法
上述三種算法,每種都有各自的優缺點,都不完美。在現代JVM中,往往是綜合使用的,經過大量實際分析,發現內存中的對象,大致可以分為兩類:有些生命周期很短,比如一些局部變量/臨時對象,而另一些則會存活很久,典型的比如websocket長連接中的connection對象,如下圖:

縱向y軸可以理解分配內存的位元組數,橫向x軸理解為隨着時間流逝(伴隨着GC),可以發現大部分對象其實相當短命,很少有對象能在GC後活下來。因此誕生了分代的思想,以Hotspot為例(JDK 7):

將內存分成了三大塊:年青代(Young Genaration),老年代(Old Generation),永久代(Permanent Generation),其中Young Genaration更是又細為分eden,S0,S1三個區。
結合我們經常使用的一些jvm調優參數後,一些參數能影響的各區域內存大小值,示意圖如下:

註:jdk8開始,用MetaSpace區取代了Perm區(永久代),所以相應的jvm參數變成-XX:MetaspaceSize 及 -XX:MaxMetaspaceSize。
以Hotspot為例,我們來分析下GC的主要過程:
剛開始時,對象分配在eden區,s0(即:from)及s1(即:to)區,幾乎是空着。

隨着應用的運行,越來越多的對象被分配到eden區。

當eden區放不下時,就會發生minor GC(也被稱為young GC),第1步當然是要先標識出不可達垃圾對象(即:下圖中的黃色塊),然後將可達對象,移動到s0區(即:4個淡藍色的方塊挪到s0區),然後將黃色的垃圾塊清理掉,這一輪過後,eden區就成空的了。
註:這裡其實已經綜合運用了「【標記-清理eden】 + 【標記-複製 eden->s0】」算法。

隨着時間推移,eden如果又滿了,再次觸發minor GC,同樣還是先做標記,這時eden和s0區可能都有垃圾對象了(下圖中的黃色塊),注意:這時s1(即:to)區是空的,s0區和eden區的存活對象,將直接搬到s1區。然後將eden和s0區的垃圾清理掉,這一輪minor GC後,eden和s0區就變成了空的了。

繼續,隨着對象的不斷分配,eden空可能又滿了,這時會重複剛才的minor GC過程,不過要注意的是,這時候s0是空的,所以s0與s1的角色其實會互換,即:存活的對象,會從eden和s1區,向s0區移動。然後再把eden和s1區中的垃圾清除,這一輪完成後,eden與s1區變成空的,如下圖。

對於那些比較「長壽」的對象一直在s0與s1中挪來挪去,一來很佔地方,而且也會造成一定開銷,降低gc效率,於是有了「代齡(age)」及「晉陞」。
對象在年青代的3個區(edge,s0,s1)之間,每次從1個區移到另1區,年齡+1,在young區達到一定的年齡閾值後,將晉陞到老年代。下圖中是8,即:挪動8次後,如果還活着,下次minor GC時,將移動到Tenured區。

下圖是晉陞的主要過程:對象先分配在年青代,經過多次Young GC後,如果對象還活着,晉陞到老年代。

如果老年代,最終也放滿了,就會發生major GC(即Full GC),由於老年代的的對象通常會比較多,因為標記-清理-整理(壓縮)的耗時通常會比較長,會讓應用出現卡頓的現象,這也是為什麼很多應用要優化,盡量避免或減少Full GC的原因。

註:上面的過程主要來自oracle官網的資料,但是有一個細節官網沒有提到,如果分配的新對象比較大,eden區放不下,但是old區可以放下時,會直接分配到old區(即沒有晉陞這一過程,直接到老年代了)。
下圖引自阿里出品的《碼出高效-Java開發手冊》一書,梳理了GC的主要過程。

三、垃圾回收器
不算出現的神器ZGC,歷史上出現過7種經典的垃圾回收器。

這些回收器都是基於分代的,把G1除外,按回收的分代劃分,橫線以上的3種:Serial ,ParNew, Parellel Scavenge都是回收年青代的,橫線以下的3種:CMS,Serial Old, Parallel Old 都是回收老年代的。
3.1 Serial 收集器
單線程用標記-複製算法,快刀斬亂麻,單線程的好處避免上下文切換,早期的機器,大多是單核,也比較實用。但執行期間,會發生STW(Stop The World)。
3.2 ParNew 收集器
Serial的多線程版本,同樣會STW,在多核機器上會更適用。
3.3 Parallel Scavenge 收集器
ParNew的升級版本,主要區別在於提供了兩個參數:-XX:MaxGCPauseMillis 較大垃圾回收停頓時間;-XX:GCTimeRatio 垃圾回收時間與總時間佔比,通過這2個參數,可以適當控制回收的節奏,更關注於吞吐率,即總時間與垃圾回收時間的比例。
3.4 Serial Old 收集器
因為老年代的對象通常比較多,佔用的空間通常也會更大,如果採用複製算法,得留50%的空間用於複製,相當不划算,而且因為對象多,從1個區,複製到另1個區,耗時也會比較長,所以老年代的收集,通常會採用「標記-整理」法。從名字就可以看出來,這是單線程(串行)的, 依然會有STW。
3.5 Parallel Old 收集器
一句話:Serial Old的多線程版本。
3.6 CMS 收集器
全稱:Concurrent Mark Sweep,從名字上看,就能猜出它是並發多線程的。這是JDK 7中廣泛使用的收集器,有必要多說一下,借一張網友的圖說話:

相對3.4 Serial Old收集器或3.5 Parallel Old收集器而言,這個明顯要複雜多了,分為4個階段:
1)Inital Mark 初始標記:主要是標記GC Root開始的下級(註:僅下一級)對象,這個過程會STW,但是跟GC Root直接關聯的下級對象不會很多,因此這個過程其實很快。
2)Concurrent Mark 並發標記:根據上一步的結果,繼續向下標識所有關聯的對象,直到這條鏈上的最盡頭。這個過程是多線程的,雖然耗時理論上會比較長,但是其它工作線程並不會阻塞,沒有STW。
3)Remark 再標誌:為啥還要再標記一次?因為第2步並沒有阻塞其它工作線程,其它線程在標識過程中,很有可能會產生新的垃圾。
試想下,高鐵上的垃圾清理員,從車廂一頭開始吆喝「有需要扔垃圾的乘客,請把垃圾扔一下」,一邊工作一邊向前走,等走到車廂另一頭時,剛才走過的位置上,可能又有乘客產生了新的空瓶垃圾。所以,要完全把這個車廂清理乾淨的話,她應該喊一下:所有乘客不要再扔垃圾了(STW),然後把新產生的垃圾收走。當然,因為剛才已經把收過一遍垃圾,所以這次收集新產生的垃圾,用不了多長時間(即:STW時間不會很長)。
4)Concurrent Sweep:並行清理,這裡使用多線程以「Mark Sweep-標記清理」算法,把垃圾清掉,其它工作線程仍然能繼續支行,不會造成卡頓。
等等,剛才我們不是提到過「標記清理」法,會留下很多內存碎片嗎?確實,但是也沒辦法,如果換成「Mark Compact標記-整理」法,把垃圾清理後,剩下的對象也順便排整理,會導致這些對象的內存地址發生變化,別忘了,此時其它線程還在工作,如果引用的對象地址變了,就天下大亂了。
另外,由於這一步是並行處理,並不阻塞其它線程,所以還有一個副使用,在清理的過程中,仍然可能會有新垃圾對象產生,只能等到下一輪GC,才會被清理掉。
雖然仍不完美,但是從這4步的處理過程來看,以往收集器中最讓人詬病的長時間STW,通過上述設計,被分解成二次短暫的STW,所以從總體效果上看,應用在GC期間卡頓的情況會大大改善,這也是CMS一度十分流行的重要原因。
3.7 G1 收集器
G1的全稱是Garbage-First,為什麼叫這個名字,呆會兒會詳細說明。鑒於CMS的一些不足之外,比如: 老年代內存碎片化,STW時間雖然已經改善了很多,但是仍然有提升空間。G1就橫空出世了,它對於heap區的內存劃思路很新穎,有點算法中分治法「分而治之」的味道。
如下圖,G1將heap內存區,劃分為一個個大小相等(1-32M,2的n次方)、內存連續的Region區域,每個region都對應Eden、Survivor 、Old、Humongous四種角色之一,但是region與region之間不要求連續。
註:Humongous,簡稱H區是專用於存放超大對象的區域,通常>= 1/2 Region Size,且只有Full GC階段,才會回收H區,避免了頻繁掃描、複製/移動大對象。
所有的垃圾回收,都是基於1個個region的。JVM內部知道,哪些region的對象最少(即:該區域最空),總是會優先收集這些region(因為對象少,內存相對較空,肯定快),這也是Garbage-First得名的由來,G即是Garbage的縮寫, 1即First。

G1 Young GC
young GC前:

young GC後:

理論上講,只要有一個Empty Region(空區域),就可以進行垃圾回收。

由於region與region之間並不要求連續,而使用G1的場景通常是大內存,比如64G甚至更大,為了提高掃描根對象和標記的效率,G1使用了二個新的輔助存儲結構:
Remembered Sets:簡稱RSets,用於根據每個region里的對象,是從哪指向過來的(即:誰引用了我),每個Region都有獨立的RSets。(Other Region -> Self Region)。
Collection Sets :簡稱CSets,記錄了等待回收的Region集合,GC時這些Region中的對象會被回收(copied or moved)。

RSets的引入,在YGC時,將年青代Region的RSets做為根對象,可以避免掃描老年代的region,能大大減輕GC的負擔。註:在老年代收集Mixed GC時,RSets記錄了Old->Old的引用,也可以避免掃描所有Old區。
Old Generation Collection(也稱為 Mixed GC)
按oracle官網文檔描述分為5個階段:Initial Mark(STW) -> Root Region Scan -> Cocurrent Marking -> Remark(STW) -> Copying/Cleanup(STW && Concurrent)
註:也有很多文章會把Root Region Scan省略掉,合併到Initial Mark里,變成4個階段。

存活對象的「初始標記」依賴於Young GC,GC 日誌中會記錄成young字樣。
2019-06-09T15:24:37.086+0800: 500993.392: [GC pause (G1 Evacuation Pause) (young), 0.0493588 secs]
[Parallel Time: 41.9 ms, GC Workers: 8]
[GC Worker Start (ms): Min: 500993393.7, Avg: 500993393.7, Max: 500993393.7, Diff: 0.1]
[Ext Root Scanning (ms): Min: 1.5, Avg: 2.2, Max: 4.4, Diff: 2.8, Sum: 17.2]
[Update RS (ms): Min: 15.8, Avg: 18.1, Max: 18.9, Diff: 3.1, Sum: 144.8]
[Processed Buffers: Min: 110, Avg: 144.9, Max: 163, Diff: 53, Sum: 1159]
[Scan RS (ms): Min: 4.7, Avg: 5.0, Max: 5.1, Diff: 0.4, Sum: 39.7]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Object Copy (ms): Min: 16.4, Avg: 16.5, Max: 16.6, Diff: 0.2, Sum: 132.0]
[Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Termination Attempts: Min: 1, Avg: 4.9, Max: 7, Diff: 6, Sum: 39]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.3]
[GC Worker Total (ms): Min: 41.7, Avg: 41.8, Max: 41.8, Diff: 0.1, Sum: 334.1]
[GC Worker End (ms): Min: 500993435.5, Avg: 500993435.5, Max: 500993435.5, Diff: 0.0]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.2 ms]
[Other: 7.2 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 4.3 ms]
[Ref Enq: 0.1 ms]
[Redirty Cards: 0.1 ms]
[Humongous Register: 0.1 ms]
[Humongous Reclaim: 0.1 ms]
[Free CSet: 0.6 ms]
[Eden: 1340.0M(1340.0M)->0.0B(548.0M) Survivors: 40.0M->64.0M Heap: 2868.2M(12.0G)->1499.8M(12.0G)]
[Times: user=0.35 sys=0.00, real=0.05 secs]

並發標記過程中,如果發現某些region全是空的,會被直接清除。

進入重新標記階段。

並發複製/清查階段。這個階段,Young區和Old區的對象有可能會被同時清理。GC日誌中,會記錄為mixed字段,這也是G1的老年代收集,也稱為Mixed GC的原因。
2019-06-09T15:24:23.959+0800: 500980.265: [GC pause (G1 Evacuation Pause) (mixed), 0.0885388 secs]
[Parallel Time: 74.2 ms, GC Workers: 8]
[GC Worker Start (ms): Min: 500980270.6, Avg: 500980270.6, Max: 500980270.6, Diff: 0.1]
[Ext Root Scanning (ms): Min: 1.7, Avg: 2.2, Max: 4.1, Diff: 2.4, Sum: 17.3]
[Update RS (ms): Min: 11.7, Avg: 13.7, Max: 14.3, Diff: 2.6, Sum: 109.8]
[Processed Buffers: Min: 136, Avg: 141.5, Max: 152, Diff: 16, Sum: 1132]
[Scan RS (ms): Min: 42.5, Avg: 42.9, Max: 43.1, Diff: 0.5, Sum: 343.1]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
[Object Copy (ms): Min: 14.9, Avg: 15.2, Max: 15.4, Diff: 0.5, Sum: 121.7]
[Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
[Termination Attempts: Min: 1, Avg: 8.2, Max: 11, Diff: 10, Sum: 66]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.2]
[GC Worker Total (ms): Min: 74.0, Avg: 74.0, Max: 74.1, Diff: 0.1, Sum: 592.3]
[GC Worker End (ms): Min: 500980344.6, Avg: 500980344.6, Max: 500980344.6, Diff: 0.0]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.5 ms]
[Other: 13.9 ms]
[Choose CSet: 4.1 ms]
[Ref Proc: 1.8 ms]
[Ref Enq: 0.1 ms]
[Redirty Cards: 0.2 ms]
[Humongous Register: 0.1 ms]
[Humongous Reclaim: 0.1 ms]
[Free CSet: 5.6 ms]
[Eden: 584.0M(584.0M)->0.0B(576.0M) Survivors: 28.0M->36.0M Heap: 4749.3M(12.0G)->2930.0M(12.0G)]
[Times: user=0.61 sys=0.00, real=0.09 secs]

上圖是,老年代收集完後的示意圖。
通過這幾個階段的分析,雖然看上去很多階段仍然會發生STW,但是G1提供了一個預測模型,通過統計方法,根據歷史數據來預測本次收集,需要選擇多少個Region來回收,盡量滿足用戶的預期停頓值(-XX:MaxGCPauseMillis參數可指定預期停頓值)。
註:如果Mixed GC仍然效果不理想,跟不上新對象分配內存的需求,會使用Serial Old GC(Full GC)強制收集整個Heap。
小結:與CMS相比,G1有內存整理過程(標記-壓縮),避免了內存碎片;STW時間可控(能預測GC停頓時間)。
3.8 ZGC (截止目前史上較好的GC收集器)
在G1的基礎上,做了很多改進(JDK 11開始引入)
3.8.1 動態調整大小的Region
G1中每個Region的大小是固定的,創建和銷毀Region,可以動態調整大小,內存使用更高效。

3.8.2 不分代,幹掉了RSets
G1中每個Region需要藉助額外的RSets來記錄「誰引用了我」,佔用了額外的內存空間,每次對象移動時,RSets也需要更新,會產生開銷。
註:ZGC沒有為止,沒有實現分代機制,每次都是並發的對所有region進行回收,不象G1是增量回收,所以用不着RSets。不分代的帶來的可能性能下降,會用下面馬上提到的Colored Pointer && Load Barrier來優化。
3.8.3 帶顏色的指針 Colored Pointer

這裡的指針類似java中的引用,意為對某塊虛擬內存的引用。ZGC採用了64位指針(註:目前只支持Linux 64位系統),將42-45這4個bit位置賦予了不同的含義,即所謂的顏色標誌位,也換為指針的metadata。
finalizable位:僅finalizer(類比c++中的析構函數)可訪問;
remap位:指向對象當前()的內存地址,參考下面提到的relocation;
marked0 && marked1 位:用於標誌可達對象;
這4個標誌位,同一時刻只會有1個位置是1。每當指針對應的內存數據發生變化,比如內存被移動,顏色會發生變化。
3.8.4 讀屏障 Load Barrier
傳統GC做標記時,為了防止其它線程在標記期間修改對象,通常會簡單的STW。而ZGC有了Colored Pointer後,引入了所謂的讀屏障,當指針引用的內存正被移動時,指針上的顏色就會變化,ZGC會先把指針更新成狀態,然後再返回。(大家可以回想下java中的volatile關鍵字,有異曲同工之妙),這樣僅讀取該指針時可能會略有開銷,而不用將整個heap STW。
3.8.5 重定位 relocation

如上圖,在標記過程中,先從Roots對象找到了直接關聯的下級對象1,2,4。

然後繼續向下層標記,找到了5,8對象, 此時已經可以判定 3,6,7為垃圾對象。

如果按常規思路,一般會將8從最右側的Region移動或複製到中間的Region,然後再將中間Region的3幹掉,最後再對中間Region做壓縮compact整理。但ZGC做得更高明,它直接將4,5複製到了一個空的新Region就完事了,然後中間的2個Region直接廢棄,或理解為「釋放」,做為下次回收的「新」Region。這樣的好處是避免了中間Region的compact整理過程。

最後,指針重新調整為正確的指向(即:remap),而且上一階段的remap與下一階段的mark是混在一起處理的,相對更高效。
Remap的流程圖如下:

3.8.6 多重映射 Multi-Mapping
這個優化,說實話沒完全看懂,只能談下自己的理解(如果有誤,歡迎指正)。虛擬內存與實際物理內存,OS會維護一個映射關係,才能正常使用。如下圖:

zgc的64位顏色指針,在解除映射關係時,代價較高(需要屏蔽額外的42-45的顏色標誌位)。考慮到這4個標誌位,同1時刻,只會有1位置成1(如下圖),另外finalizable標誌位,永遠不希望被解除映射綁定(可不用考慮映射問題)。
所以剩下3種顏色的虛擬內存,可以都映射到同1段物理內存。即映射復用,或者更通俗點講,本來3種不同顏色的指針,哪怕0-41位完全相同,也需要映射到3段不同的物理內存,現在只需要映射到同1段物理內存即可。


3.8.7 支持NUMA架構
NUMA是一種多核服務器的架構,簡單來講,一個多核服務器(比如2core),每個cpu都有屬於自己的存儲器,會比訪問另一個核的存儲器會慢很多(類似於就近訪問更快)。
相對之前的GC算法,ZGC首次支持了NUMA架構,申請堆內存時,判斷當前線程屬是哪個CPU在執行,然後就近申請該CPU能使用的內存。
小結:革命性的ZGC經過上述一堆優化後,每次GC總體卡頓時間按官方說法<10ms。註:啟用zgc,需要設置-XX:+UnlockExperimentalVMOptions -XX:+UseZGC。
四、實戰練習
前面介紹了一堆理論,最後來做一個小的練習,下面是一段模擬OOM的測試代碼,我們在G1、CMS這二種常用垃圾回收器上試驗一下。
import sun.misc.Unsafe;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
public class OOMTest {
public static void main(String[] args) {
OOMTest test = new OOMTest();
//heap區OOM測試
//test.heapOOM();
//虛擬機棧和本地方法棧溢出
//test.stackOverflow();
//metaspace OOM測試
//test.metaspaceOOM();
//堆外內存 OOM測試
//test.directOOM();
}
/**
* heap OOM測試
*/
public void heapOOM() {
List<OOMTest> list = new ArrayList<>();
while (true) {
list.add(new OOMTest());
}
}
private int stackLength = 1;
public void stackLeak() {
stackLength += 1;
stackLeak();
}
/**
* VM Stack / Native method Stack 溢出測試
*/
public void stackOverflow() {
OOMTest test = new OOMTest();
try {
test.stackLeak();
} catch (Throwable e) {
System.out.println("stack length:" + test.stackLength);
throw e;
}
}
public void genString() {
List<String> list = new ArrayList<>();
int i = 0;
while (true) {
list.add("string-" + i);
i++;
}
}
/**
* metaspace/常量池 OOM測試
*/
public void metaspaceOOM() {
OOMTest test = new OOMTest();
test.metaspaceOOM();
}
public void allocDirectMemory() {
final int _1MB = 1024 * 1024;
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = null;
try {
unsafe = (Unsafe) unsafeField.get(null);
} catch (IllegalArgumentException | IllegalAccessException e) {
e.printStackTrace();
}
while (true) {
unsafe.allocateMemory(_1MB);
}
}
/**
* 堆外內存OOM測試
*/
public void directOOM() {
OOMTest test = new OOMTest();
test.allocDirectMemory();
}
}
4.1 openjdk 11.0.3 環境:+ G1回收
4.1.1 驗證heap OOM
把main方法中的test.heapOOM()行,注釋打開,然後命令行下運行:
java -Xmx10M -XX:+UseG1GC -Xlog:gc* -Xlog:gc:gc.log -XX:+HeapDumpBeforeFullGC OOMTest.java
最後會輸出:
[1.892s][info][gc ] GC(42) Concurrent Cycle 228.393ms
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.Arrays.copyOf(Arrays.java:3689)
at java.base/java.util.ArrayList.grow(ArrayList.java:237)
at java.base/java.util.ArrayList.grow(ArrayList.java:242)
at java.base/java.util.ArrayList.add(ArrayList.java:485)
at java.base/java.util.ArrayList.add(ArrayList.java:498)
at oom.OOMTest.heapOOM(OOMTest.java:37)
at oom.OOMTest.main(OOMTest.java:16)
[1.895s][info][gc,heap,exit ] Heap
其中 OutOfMemoryError:Java heap space即表示heap OOM。
4.1.2 驗證stack溢出
把main方法中的test.stackOverflow()行,注釋打開,然後命令行下運行:
java -Xmx20M -Xss180k -XX:+UseG1GC -Xlog:gc* -Xlog:gc:gc.log -XX:+HeapDumpBeforeFullGC OOMTest.java
最後會輸出:
[0.821s][info][gc ] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 12M->7M(20M) 5.245ms
[0.821s][info][gc,cpu ] GC(4) User=0.00s Sys=0.00s Real=0.00s
stack length:1699
Exception in thread "main" java.lang.StackOverflowError
at oom.OOMTest.stackLeak(OOMTest.java:45)
at oom.OOMTest.stackLeak(OOMTest.java:45)
其中 StackOverflowError 即表示stack棧區內存不足,導致溢出。
4.1.3 驗證metaspace OOM
把main方法中的test.metaspaceOOM()行,注釋打開,然後命令行下運行:
java -Xmx20M -XX:MaxMetaspaceSize=10M -XX:+UseG1GC -Xlog:gc* -Xlog:gc:gc.log -XX:+HeapDumpBeforeFullGC OOMTest.java
最後會輸出:
[0.582s][info][gc,metaspace,freelist,oom]
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
[0.584s][info][gc,heap,exit ] Heap
其中 OutOfMemoryError: Metaspace 即表示Metaspace區OOM。
4.1.4 驗證堆外內存OOM
把main方法中的test.directOOM()行,注釋打開,然後命令行下運行:
最後會輸出:
[0.842s][info][gc,cpu ] GC(4) User=0.06s Sys=0.00s Real=0.01s
Exception in thread "main" java.lang.OutOfMemoryError
at java.base/jdk.internal.misc.Unsafe.allocateMemory(Unsafe.java:616)
…
其中OutOfMemoryError行並沒有輸出具體哪個區(註:堆外內存不屬於JVM內存中的任何一個區,所以無法輸出),但緊接着有一行jdk.internal.misc.Unsafe.allocateMemory 可以看出是「堆外內存直接分配」導致的異常。
4.2 openjdk 1.8.0_212 + CMS回收
jdk1.8下,java命令無法直接運行.java文件,必須先編譯,即:
javac OOMTest.java -encoding utf-8
(註:-encoding utf-8 是為了防止中文注釋javac無法識別)成功後,會生成OOMTest.class文件, 然後再可以參考下面的命令進行測試。
4.2.1 heap OOM測試:
java -Xmx10M -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log -XX:+HeapDumpOnOutOfMemoryError OOMTest
4.2.2 驗證stack溢出
java -Xmx10M -Xss128k -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log -XX:+HeapDumpOnOutOfMemoryError OOMTest
4.2.3 驗證metaspace OOM
java -Xmx20M -XX:+UseConcMarkSweepGC -XX:MaxMetaspaceSize=10M -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log -XX:+HeapDumpOnOutOfMemoryError OOMTest
4.2.4 驗證堆外內存OOM
java -Xmx20M -XX:+UseConcMarkSweepGC -XX:MaxDirectMemorySize=10M -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log -XX:+HeapDumpOnOutOfMemoryError OOMTest
4.3 GC日誌查看工具
生成的gc日誌文件,可以用開源工具GCViewer查看,這是一個純java寫的GUI程序,使用很簡單,File→Open File 選擇gc日誌文件即可。目前支持CMS/G1生成的日誌文件,另外如果GC文件過大時,可能打不開。


GCViewer可以很方便的統計出GC的類型,次數,停頓時間,年青代/老年代的大小等,還有圖表顯示,非常方便。
參考文章:
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5
https://blog.csdn.net/heart_mine/article/details/79495032
https://javapapers.com/core-java/java-jvm-run-time-data-areas/
https://javapapers.com/core-java/java-jvm-memory-types/
https://cloud.tencent.com/developer/article/1152616
https://www.jianshu.com/p/17e72bb01bf1
http://calvin1978.blogcn.com/articles/directbytebuffer.html
https://www.cnkirito.moe/nio-buffer-recycle/
https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.html
http://inbravo.github.io/html/jvm.html
https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/G1GettingStarted/index.html
https://segmentfault.com/a/1190000009783873
https://segmentfault.com/a/1190000016551339
https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/cms.html
https://tech.meituan.com/2016/09/23/g1.html
https://mp.weixin.qq.com/s/KUCs_BJUNfMMCO1T3_WAjw
https://www.baeldung.com/jvm-zgc-garbage-collector
http://xxfox.perfma.com/jvm/
https://wiki.openjdk.java.net/display/zgc/Main
http://cr.openjdk.java.net/~pliden/slides/ZGC-FOSDEM-2018.pdf
http://www.ishenping.com/ArtInfo/43701.html
http://likehui.top/2019/04/11/ZGC-%E7%89%B9%E6%80%A7%E8%A7%A3%E8%AF%BB/
【原文鏈接】http://java.dataguru.cn/article-15078-1.html