一文看懂 JVM 內存佈局及 GC 原理

“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.

最後會輸出:

[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.jav

最後會輸出:

[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:+HeapDumpBefor

最後會輸出:

[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:

4.2.2 驗證 stack 溢出

java -Xmx10M -Xss128k -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xlog

4.2.3 驗證 metaspace OOM

java -Xmx20M -XX:+UseConcMarkSweepGC -XX:MaxMetaspaceSize=10M -XX:+PrintGCDetails -XX:+PrintGCDateS

4.2.4 驗證堆外內存 OOM

java -Xmx20M -XX:+UseConcMarkSweepGC -XX:MaxDirectMemorySize=10M -XX:+PrintGCDetails -XX:+PrintGCDa

4.3 GC 日誌查看工具
生成的 gc 日誌文件,可以用開源工具 GCViewer 查看,這是一個純 java 寫的 GUI 程序,使用很簡單,File→Open File 選擇 gc 日誌文件即可。目前支持 CMS/G1 生成的日誌文件,另外如果 GC 文件過大時,可能打不開。

在這裏插入圖片描述
在這裏插入圖片描述

GCViewer 可以很方便的統計出 GC 的類型,次數,停頓時間,年青代 / 老年代的大小等,還有圖表顯示,非常方便。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章