JVM內存區域以及各區域的內存溢出異常,內存分代策略,垃圾收集算法,各種垃圾收集器


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


一、一個命令


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

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

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


二、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 沒有任何引用鏈相連時,則證明此對象是不可能再被使用的。

可達性分析算法是當前主流商用程序語言的內存管理子系統采用的算法。

  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集合:

  • 第一階段,從引用根節點,開始標記所有被引用的對象;
  • 第二階段,遍歷整個堆,把未標記的對象清除。
  • 也可以反過來,標記未被引用的對象,然后清除未被標記的。

缺點

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

3.4.2 標記-復制算法(簡稱復制算法)

把內存划分為兩個相等的區域,每次只用一個區域,一個內存用完就開始執行算法:

  • 把這個區域仍然存活的對象復制到另一個區域(這一步顯然還是要先標記的);
  • 然后把這個區域一次清理(省掉了上一種方法的第二次遍歷)。

優點

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

缺點

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

3.4.3 標記-整理(Mark-Compact)

結合標記清除算法的第一步,第二步並不采用直接清理,而是讓所有對象都向內存空間的固定一端挪動,最后清理掉邊界之外的內存。

優點

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

缺點

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

權衡:

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

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

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


四、JVM堆內存分代策略


需要再次強調的是:

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

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

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

內存分代策略:也就是根據對象存活的周期不同,將堆內存划分為幾塊,一般分為新生代、老年代、永久代。

4.1 為什么要分代?

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

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

  • 有了內存分代,根據不同區域,采用不同的垃圾收集算法。

4.2 內存划分具體策略

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

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 一樣。

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

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有更高的內存占用,更高的額外執行負載。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM