JVM記憶體區域以及各區域的記憶體溢位異常,記憶體分代策略,垃圾收集演算法,各種垃圾收集器

Life_Goes_On發表於2020-09-05

本文整理自周志明老師的《深入理解Java虛擬機器-JVM高階特性與最佳實踐》第3版的第二章和第三章。
加上了一些網上拼拼湊湊的圖片,個人認為很多部落格複製來複制去,最後的東西都看不懂,所以從書裡碼了一下知識點,也用作自己記憶。

一、一個命令

JVM記憶體區域以及各區域的記憶體溢位異常,記憶體分代策略,垃圾收集演算法,各種垃圾收集器

上面的結果顯示了 jvm 的模式:

Client VM(-client),為在客戶端環境中減少啟動時間而優化;
Server VM(-server),為在伺服器環境中最大化程式執行速度而設計。

在檔案路徑:jdk-11.0.7+10\lib 下面可以更改 jvm.cfg 檔案來決定是採用哪個模式,具體操作就是更改檔案裡面 Client 和 Server 這兩行的位置,誰在上就是選擇誰。

二、JVM 的記憶體區域與記憶體溢位異常

JVM記憶體區域以及各區域的記憶體溢位異常,記憶體分代策略,垃圾收集演算法,各種垃圾收集器

如上圖所示,是 Java 虛擬機器規範規定的,jvm 管理的記憶體區域。

  • 灰色部分,即方法區這兩個資料區,是所有執行緒共享的資料區。
  • 而白色部分,包括程式計數器、java虛擬機器棧、本地方法棧,叫執行緒隔離的資料區,或者叫執行緒私有的記憶體。這三塊記憶體區域隨執行緒生,隨執行緒死

每個部分的詳細介紹如下:

2.1 pc 暫存器( Program Counter)

也可叫程式計數器。是一塊較小的記憶體空間,可以看作是當前執行緒執行的位元組碼的行號指示器

在虛擬機器的概念模型(注意只是概念)裡,位元組碼直譯器工作的時候就是通過改變這個計數器的值來選取嚇一跳需要執行的位元組碼指令,顯然,分支迴圈等基礎功能都要靠這個計數器。

由於多執行緒實際上是執行緒輪流切換實現的,所以執行緒切換後為了能恢復到正確的執行位置,每個執行緒都要有一個獨立的程式計數器。如果執行緒執行的是一個 java 方法,計數器記錄的是正在執行的虛擬機器位元組碼指令的地址;如果正在執行的是本地方法,計數器的值則為空(Undefined)。

此記憶體區域是唯一個在java虛擬機器規範裡沒有規定任何 OutOfMemoryError情況的區域。

2.2 java 虛擬機器棧

棧是方法執行的執行緒記憶體模型。每個方法執行的時候,jvm都會同步建立一個棧幀用於儲存區域性變數表、運算元棧等到,方法被呼叫直到執行完畢,就是對應一個棧幀在虛擬機器棧裡從入棧到出棧的過程。

大多情況棧主要指的是虛擬機器棧裡區域性變數表的部分(實際上的劃分要更復雜)。區域性變數表存放了各種基本java資料型別、物件引用和 returnAddress 型別(指向了一條位元組碼指令的地址)。這些資料型別在區域性變數表中以區域性變數槽(Slot)來表示,其中64位長的long和double型別佔用兩個槽,其他的佔一個。在編譯期間,區域性變數表的空間就會分配完成,方法執行期間不會改變區域性變數表的大小。

java虛擬機器規範對這個記憶體區域規定了兩種異常:如果執行緒請求的棧深度大於虛擬機器允許的深度,會丟擲StackOverflowError;如果Java棧容量可以動態擴充套件,當擴充套件的時候無法申請到足夠的記憶體會丟擲OutOfMemoryError。

2.3 本地方法棧

本地方法棧和 java 虛擬機器棧類似,區別只是虛擬機器棧為虛擬機器執行 Java 方法,本地方法棧是為虛擬機器使用到的本地方法服務。

因此本地方法棧也會在棧深度溢位或者棧擴充套件失敗時分別丟擲丟擲 StackOverflowError 和 OutOfMemoryError 異常。

2.4 java 堆

java 堆在虛擬機器啟動的時候建立,它是 java 程式最主要的記憶體工作區域。

java堆的唯一目的就是存放物件例項。在JVM所管理的記憶體中,堆區是最大的一塊,堆區也是Java GC機制所管理的記憶體區域。需要注意,java堆只是邏輯上的連續區域,物理上可以不連續。

提到垃圾回收的時候總會說堆的區域劃分,但是實際上java虛擬機器規範沒有規定,所謂的劃分是各種虛擬機器實現的風格決定的。這部分後面垃圾回收的時候還會講。

java堆可以固定大小,也可以實現成可擴充套件,當前主流的虛擬機器都是按照可擴充套件來實現,基於 -Xmx和-Xms引數來設定。

異常:如果堆記憶體不夠,並且堆也無法擴充套件,丟擲OutOfMemoryError。

2.5 方法區

用來儲存已經被虛擬機器載入的型別資訊、常量、靜態變數、即時編譯器編譯後的程式碼快取等資料。

在java虛擬機器裡把他描述為堆的一個邏輯部分,但是又要和堆區分開,還有一個別名叫“非堆”。類載入子系統負責從檔案系統或者網路中載入 Class 資訊( ClassLoader 就是這個區域下的元件),載入的類資訊就存放於方法區。(可以看到,這裡儲存的東西都是唯一份的東西)

關於垃圾回收的永久代,一般都是指的方法區,原因是當時的hotspot虛擬機器設計團隊把垃圾收集器的分代設計擴充套件到了這裡,或者說使用永久代實現了方法區,後來因為這種方法更容易記憶體溢位,永久代的設計已經被取消:到jdk8完全放棄永久代,使用本地記憶體的中元空間來替代這部分的功能。

異常:無法滿足新的記憶體分配需求,丟擲OutOfMemoryError。

  • 執行時常量池是方法區的一部分,用來存放編譯器生成的各種字面量和符號引用,在類載入後這些內容都會進入方法區的常量池。

既然是方法區的一部分,顯然是受到方法區記憶體的限制,如果常量池無法再申請到記憶體,會丟擲丟擲OutOfMemoryError。

2.6 直接記憶體

直接記憶體指的就已經不屬於虛擬機器執行時資料區域的部分了,java虛擬機器規範也沒有定義這塊記憶體。

java在jdk1.4 後,引入了 **NIO **類,允許 java 程式通過native函式庫直接分配堆外的記憶體,然後通過java堆裡的 DirectByteBuffer物件作為對這一塊記憶體的引用進行操作,在某些場景中能夠提高效能,因為避免了 java 堆和 native 堆的資料來回複製。

異常:頻繁使用也可能導致丟擲OutOfMemoryError。畢竟雖然沒有收到java堆的限制,可是還是會受到本機的記憶體、以及處理器定址空間的限制

三、垃圾回收演算法

3.1 概述

上面的記憶體區域裡,執行緒獨有的三個區域,並不需要過多考慮回收問題,因為分配和回收比較確定。

Java堆和方法區這兩個區域則有著很顯著的不確定性:一個介面的多個實現類需要的記憶體可能會不一樣,一個方法所執行的不同條件分支所需要的記憶體也可能不一樣,只有處於執行期間,我們才能知道程式究竟會建立哪些物件,建立多少個物件,這部分記憶體的分配和回收是動態的

對於方法區,永久代的遺留問題關注比較多,最主要的垃圾回收演算法還都是關注堆記憶體。

垃圾收集器所關注的正是這部分記憶體該如何管理,我們討論的相關演算法也是針對這部分記憶體。

從如何判定物件消亡的角度處罰,垃圾收集演算法可以分為“引用計數式”(Reference Counting GC)和 “ 追蹤式”(Tracing GC)兩類。主流的 java 虛擬機器都採用的第二種。所以下面講的演算法都是這種模式下面的。

3.2 判斷物件是否需要回收

垃圾回收第一件事要做的就是,確定哪些物件死了,哪些活著,死了的才要進行回收。對於判斷,一般有兩種演算法。

  1. 引用計數法(Reference Counting)

給物件新增一引用計數器,被引用一次計數器值就加 1;當引用失效時,計數器值就減 1;計數器為 0 時,物件就是不可能再被使用的,簡單高效。

存在問題:無法解決物件之間相互迴圈引用的問題,要想採用這個演算法,還需要很多的額外處理。

  1. 可達性分析演算法

通過一系列的稱為 "GC Roots" 的物件作為起始節點集合,從這些節點開始,根據引用關係向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到 GC Roots 沒有任何引用鏈相連時,則證明此物件是不可能再被使用的。

JVM記憶體區域以及各區域的記憶體溢位異常,記憶體分代策略,垃圾收集演算法,各種垃圾收集器

可達性分析演算法是當前主流商用程式語言的記憶體管理子系統採用的演算法。

  1. 哪些物件可以作為 GC Roots 呢?

java 技術體系裡,固定可作為 GC Roots 的物件包括以下幾種:

  • 在虛擬機器棧中引用的物件。比如各個執行緒被呼叫的方法堆疊中使用到的引數、區域性變數、臨時變數等;
  • 方法區中類靜態屬性引用的物件,比如java類的引用型別靜態變數;
  • 方法區中常量引用的物件,比如字串常量池裡的引用;
  • 本地方法棧JNI(也就是通常說的本地方法)中引用的物件;
  • java虛擬機器內部的引用,比如基本資料型別對應的 Class 物件,一些常駐的異常物件,和系統類載入器;
  • 所有被同步鎖(synchronized關鍵字)持有的物件;
  • 反應 java 虛擬機器內部情況的 JMXBean、JVMTI 中註冊的回撥、原生程式碼快取等。

除了這些,還會有一些臨時加入的物件,共同構成 GC Roots 集合。

  1. 方法區的垃圾回收

前面已經說過,主要的收集區域是堆,而且方法區垃圾收集的價效比也比較低。比如在 hotspot 虛擬機器採用了元空間來實現永久代,在這個區域沒有垃圾收集行為。

如果要回收,方法區的垃圾收集主要回收兩部分內容:廢棄的常量和不再使用的型別。

回收廢棄常量與回收Java堆中的物件非常類似。都是基於判斷是否還有物件引用指向這個常量。常量池中其他類(介面)、方法、欄位的符號引用也與此類似。

而判斷型別的回收要滿足三個條件:

  1. 該類的所有例項已經被回收,也就是堆中不再存在該類以及任何派生子類的例項;
  2. 載入該類的類載入器已經被回收(這個條件很難達成);
  3. 該類對應的 java.lang.Class 物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

3.3 分代收集理論

網上很多都說是分代收集演算法,但是顯然這並不是具體的演算法,更像一種策略,選擇組合各種具體的演算法,周志明老師的書上寫的是分代收集理論。

分代就是結合堆的區域劃分,然後講回收物件根據年齡不同放到不同區域,這樣的基礎上可以對某一區域單獨進行垃圾回收,當然,分代收集策略、對應的記憶體分代,都有消亡的趨勢

分代至少會分為新生代和老年代兩個區域,新生代垃圾收集結束後,還存活的物件會逐步晉升到老年代存放,具體結合這一節的收集演算法,區域的劃分下一節會講解。

在記憶體分出不同的區域後,對不同區域的回收也起了不同的名字:

  • Minor GC / Young GC(新生代收集):目標只是新生代區域的收集;
  • Major GC / Old GC(老年代收集):目標只是老年代的垃圾收集;
  • Mix GC(混合收集):目標是收集整個新生代+部分老年代的垃圾收集。目前只有 G1 收集器有這種行為。

以上三個都叫 Partial GC,也就是部分收集。還有一種收集:

  • Full GC(整堆收集):收集整個 java 堆方法區的垃圾收集。

3.4 具體的垃圾回收演算法

之前看網上有的說法講最早、基本的垃圾回收演算法是

引用計數(Reference Counting):有一個引用就加一個技術,少一個引用就減一個計數,垃圾回收的時候就收集計數為 0 的。

但是現在我明白了,這玩意確實劃分的有點亂,引用計數正如 3.2 講到的,應該算到 如何判斷垃圾是否需要回收的演算法裡,不應該算在垃圾回收演算法裡。

所以仍然按照深入理解java虛擬機器書裡講的,分為三個演算法。

3.4.1 標記-清除演算法(Mark-Sweep)

掃描GC Roots集合:

  • 第一階段,從引用根節點,開始標記所有被引用的物件;
  • 第二階段,遍歷整個堆,把未標記的物件清除。
  • 也可以反過來,標記未被引用的物件,然後清除未被標記的。
JVM記憶體區域以及各區域的記憶體溢位異常,記憶體分代策略,垃圾收集演算法,各種垃圾收集器

缺點

  • 效率很不穩定,如果堆包含大量物件,而大部分都要被收集,那麼這個操作過程執行效率一直降低;
  • 記憶體空間碎片化,如上圖可以看的很明顯,垃圾收集執行完後空間碎片過多,可能會導致以後程式執行的時候需要分配大物件的時候找不到連續記憶體從而又提前觸發垃圾收集。

3.4.2 標記-複製演算法(簡稱複製演算法)

把記憶體劃分為兩個相等的區域,每次只用一個區域,一個記憶體用完就開始執行演算法:

  • 把這個區域仍然存活的物件複製到另一個區域(這一步顯然還是要先標記的);
  • 然後把這個區域一次清理(省掉了上一種方法的第二次遍歷)。
JVM記憶體區域以及各區域的記憶體溢位異常,記憶體分代策略,垃圾收集演算法,各種垃圾收集器

優點

簡單、高效,而且解決了產生空間碎片的問題。

缺點

需要 2 倍記憶體,總是有一半空的,可用的也只有一半,太浪費了。

3.4.3 標記-整理(Mark-Compact)

結合標記清除演算法的第一步,第二步並不採用直接清理,而是讓所有物件都向記憶體空間的固定一端挪動,最後清理掉邊界之外的記憶體。

JVM記憶體區域以及各區域的記憶體溢位異常,記憶體分代策略,垃圾收集演算法,各種垃圾收集器

優點

顯然,進行垃圾收集後不會產生碎片。

缺點

“整理”的過程,或者說移動,如果是在老年代,每次都沉積著大量物件,移動的過程顯然會是一個很負重的操作,必須全程暫停使用者應用程式這種停頓還被設計者描述為 Stop the world。

權衡:

  • 如果移動,那麼缺點已經說過了;
  • 如果不移動,那麼要通過更復雜的策略解決記憶體碎片問題,而記憶體的訪問本身又是使用者程式最頻繁的操作,額外的負擔會影響應用程式的吞吐量。

也就是說,如果移動,記憶體回收會更復雜,如果不移動,記憶體分配會更復雜。從整個程式的吞吐量來看,移動會更划算。

注意:因為有標記的過程,通常都是需要停頓使用者執行緒來進行的,只是總體來說,最後一種有整理的過程,前兩種的停頓時間就會短一些。

四、JVM堆記憶體分代策略

需要再次強調的是:

從回收記憶體的角度看,由於現代垃圾收集器大部分都是基於上一節所說的,分代收集理論設計的,區域劃分僅僅是一部分垃圾收集器的共同特性或者說設計風格而已,而非某個Java虛擬機器具體實現的固有記憶體佈局,更不是《Java虛擬機器規範》裡對Java堆的進一步細緻劃分。

尤其到 G1 收集器的出現後,已經打破了固有的策略,往後,垃圾收集器技術的更新也會帶來更多的策略,而不是分代。

因此我們從分水嶺的前後來分別介紹。

記憶體分代策略:也就是根據物件存活的週期不同,將堆記憶體劃分為幾塊,一般分為新生代、老年代、永久代。

4.1 為什麼要分代?

很好理解,因為各種物件示例需要回收的頻率是不一樣的,分割槽操作能縮小操作的範圍,結合上一節的垃圾回收策略,更好理解。

  • 如果沒有區域劃分,頻繁進行垃圾收集的時候,遍歷範圍都是所有的物件,會嚴重影響的 GC 效率。

  • 有了記憶體分代,根據不同區域,採用不同的垃圾收集演算法。

4.2 記憶體劃分具體策略

新生代、老年代、永久代(如上一節所介紹的,永久代後來已經被取締)。

JVM記憶體區域以及各區域的記憶體溢位異常,記憶體分代策略,垃圾收集演算法,各種垃圾收集器

4.2.1 新生代(Young)

新生代又分為了三塊區域,他們的空間比例預設為 8:1:1。

  • Eden(伊甸園,人類建立的地方),就是所有物件產生的地方;
  • From,屬於第一塊 Survivor 區域;
  • To,屬於第二塊 Survivor 區域。

這麼個比例劃分是因為新生代的垃圾回收演算法是標記-複製演算法,設定這個比例是為了充分利用記憶體空間。

新生物件在 Eden 區分配,除了大物件,大物件直接進入老年代

大物件就是指需要大量連續記憶體的物件,就是很大的陣列,或者很長的字串。比大物件更糟糕的就是遇到一個朝生夕滅的大物件。

結合一般在這個區域採用標記-複製演算法,看一看新生代的垃圾收集過程:

  • 如果 Eden 區不夠了,就會開始一次 Minor GC,將 Eden 裡存活的複製到 From(Eden空了);
  • 下次 Eden 區滿了,再執行一次 Minor GC,將存活的物件複製到 To 中 (Eden空了),同時,將 From 中消亡的物件清理掉,將存活的物件也複製到 To 區,然後清空 From 區(此時 From空);

在 From 和 To 兩個區域的這種切換,顯然就是標記複製的演算法,他們兩個的空間也確實是 1 : 1。此後從 Eden 區滿了後再往他們兩個區域移動的時候就是交替進行。

注意事項

  • 當兩個存活區切換了幾次(HotSpot虛擬機器預設15次)之後,仍然存活的物件,將被複制到老年代。實現方式,就是在不斷的 Minor GC ,這個複製的過程會給物件計算年齡,年齡計數器是儲存在物件頭裡的(關於虛擬機器的物件頭資訊)。
  • 除了年齡判斷,hotspot 虛擬機器還有動態物件年齡判定的策略,如果 survivor 空間相同年齡所有物件大小總和 >= Survivor 空間的一半,這部分物件都直接進入老年代。

所以可以總結出有 3 類物件都會進入老年代:1.大物件直接進;2.在Minor GC 存活15歲後進;3.相同年齡物件成為眾數,一起進。

4.2.2 老年代(Old)

這裡的物件GC 頻率低。

4.2.3 永久代(Permanent)

正如前面所說,jdk8以前,很多人願意把方法區稱為永久代,本質上是因為當時的hotspot虛擬機器選擇把垃圾收集的設計擴充套件到了方法區,或者說使用永久代實現方法區,使得垃圾收集器能夠管理這部分記憶體,其他虛擬機器不存在這個概念。

到jdk8就完全放棄了,因為實現方法區的內容已經改為用本地記憶體的元空間。

這裡其實我有一個疑問,邏輯上本來方法區是屬於堆的一塊特殊區域,現在改用本地直接記憶體來實現,那麼在記憶體區域的劃分上,是應該定義為直接記憶體的一塊特殊區域?

反正說 jvm 的記憶體區域的時候迷迷糊糊的。

五、垃圾回收器

這裡指的都是“經典”垃圾回收器,是因為目前的新技術實現的高效能低延遲收集器還處於實驗狀態。

所以記錄一下時間:現在是2020.09.04,參考的書是基於 jdk11 的。

5.1 Serial 收集器(複製演算法)

新生代單執行緒收集器,標記和清理都是單執行緒,需要其它工作執行緒暫停,優點是簡單高效。

這也是虛擬機器在Client模式下執行的預設值,可以通過 -XX:+UseSerialGC 來強制指定。

5.2 Serial Old 收集器(標記-整理演算法)

老年代單執行緒收集器,Serial收集器的老年代版本,需要其它工作執行緒暫停,簡單高效。

5.3 ParNew 收集器(複製演算法)

新生代收集器,實質上是 Serial 收集器的多執行緒版本,各種策略都和 Serial 收集器一樣。除了支援多執行緒並行,沒有別的優點,但是在 jdk7 之前,都會用他,原因和效能無關,原因是:只有他能和 CMS 配合工作。(之後有 G1 了,他就沒這麼高地位了)

5.4 Parallel Scavenge 收集器(複製演算法)

新生代收集器,並行,表面上看起來的特性和 ParNew 一樣。

但是他的特點是,關注點不在縮短執行緒停頓時間,而關注如何達到一個可控制的吞吐量,什麼是吞吐量?

JVM記憶體區域以及各區域的記憶體溢位異常,記憶體分代策略,垃圾收集演算法,各種垃圾收集器

Parallel Scavenge+Serial Old 收集器組合回收垃圾(這也是在Server模式下的預設值)可用 -XX:+UseParallelGC 來強制指定,用 -XX:ParallelGCThreads=4 來指定執行緒數。

5.5 Parallel Old 收集器(標記-整理演算法)

Parallel Scavenge 收集器的老年代版本,並行收集器。

Parallel Scavenge 和 Parallel Old 搭配,產生了一種“吞吐量”優先的收集器方案。

5.6 CMS(Concurrent Mark Sweep)收集器(標記-清除演算法)

老年代收集器。從名字就可以看出來,是併發+標記清除。一些官方公開文件裡害稱之為Concurrent Low Pause Collector,併發低停頓收集器。

他的收集過程比較複雜,分為四步:

  1. 初始標記;(需要停頓使用者執行緒,標記GC roots能直接關聯到的物件,快)
  2. 併發標記;(從上一步關聯到的物件遍歷整個圖,但是是併發執行的,不用停頓使用者執行緒,慢)
  3. 重新標記;(修正上一個階段可能因為使用者繼續操作又產生變動的部分,需要停頓使用者執行緒,快)
  4. 併發清除。(併發執行,因為不需要整理移動存活物件)

最大的優點就是名字型現出來的:併發收集、低停頓。

缺點

  • 對處理器資源非常敏感,原因就是,雖然你是併發的,但是你本身相當於其他的執行緒,這是境地總吞吐量的(空間換時間嘛);
  • 無法處理浮動垃圾。浮動垃圾就是說,他的四個步驟裡,併發的兩個步驟,使用者執行緒都是在同時產生垃圾的,只能等到下一次才能處理。所以垃圾收集還需要有額外預留的空間,否則還會產生問題;
  • 因為是標記清除演算法,所以有空間碎片以及後續會產生的問題。

5.7 G1/Garbage First 收集器

這是一個里程碑式的成果。實驗期完成後,正式商用,到jdk8後,官方稱之為全功能垃圾收集器(Fullly-Featured Garbage Collector)。

jdk 9 後,G1 也替代了Parallel Scavenge 和 Parallel Old 搭配的組合,稱為服務端模式下的預設收集器,CMS 直接淪落到了不推薦使用。

之前垃圾收集的目標都是基於分代的記憶體,要麼在新生代工作、要麼老年代、要麼整個 java 堆。G1 則跳出了這個牢籠,可以面向堆記憶體的任何部分來組成回收集(Collection Set,簡稱 CSet),衡量標準不再是哪個分代,而是哪塊垃圾最多我去哪快,這就是 G1 收集器的 Mixed GC 模式。

G1 把堆記憶體分為了不同的 Region ,這些 Region 大小相等,各自獨立。這個劃分不像以前遵循的那種固定比例,這樣,每個 Region 都可能扮演以前的新生代的 Eden 空間、Survivor空間或者老年代空間,然後垃圾收集器採用不同的策略去收集。

缺點:比CMS有更高的記憶體佔用,更高的額外執行負載。

相關文章