淺談JVM垃圾回收

起個名字都這麼男發表於2021-01-15

JVM記憶體區域

要想搞懂啊垃圾回收機制,首先就要知道垃圾回收主要回收的是哪些資料,這些資料主要在哪一塊區域。

Java8和Java8之前的相同點有很多。

都有虛擬機器棧,本地方法棧,程式計數器,這三個是執行緒隔離的也稱是執行緒獨有的;

本地記憶體和堆是執行緒共享的。

Java8和之前JVM記憶體區域不同的是,Java8中增加了元空間,取消了永久代,Java8之前永久代是在堆中的,而之後方法區搬到了元空間中,元空間存在於本地記憶體中。

下面詳細說一下各個記憶體區域的特點。

  • 虛擬機器棧:描述的是方法執行時的記憶體模型,是執行緒私有的,生命週期與執行緒同步,每個方法被執行的時候都會建立自己的棧幀,主要儲存的是區域性變數表,運算元棧,動態連結和方法的返回地址等資訊。方法執行完成後就清空了棧幀的資訊,入棧出棧實際都很明確,並且這塊區域不需要進行GC。
  • 本地方法棧:與虛擬機器棧功能非常類似。主要區別是虛擬機器棧是為虛擬機器執行java方法,而本地方法棧是為虛擬機器執行本地方法,因此這塊區域也不需要進行GC。
  • 程式計數器:用來記錄每個執行緒執行到了哪一條指令。執行緒隔離的。比如每個位元組碼之前都有一個數字,我們可以認為他就是程式計數器儲存的內容。這些數字的作用就是記錄執行緒執行時的狀態,方便執行緒下一次被喚醒的時候能從上次執行的位置繼續執行,需要注意的是程式計數器是唯一一個在Java虛擬機器中沒有規定任何OOM情況的區域,因此這塊區域也不需要進行GC。
  • 堆:物件例項和陣列都是在堆上分配的,GC主要對這兩類資料進行回收。
  • 本地記憶體:執行緒共享區域。本地記憶體也叫堆外記憶體,包含元空間和直接記憶體。從Java8開始,有了元空間的概念,我們來看一下為什麼要取消永久代,永久代實際上指的是HotSpot虛擬機器上的永久代,他用永久代實現了JVM規範定義方法區的功能,永久代主要存放類的資訊,常量,靜態變數,即時編譯器編譯後的程式碼等,永久代的大小是有限的,可以通過XX:MaxPermSize引數指定上限,所以如果動態生成類資訊或者大量執行String.intern方法(直接將字串放入永久代)就會造成永久代記憶體溢位引起OOM。因此在Java8中就將方法區的實現移動到本地記憶體中的元空間中,這樣方法區就不受JVM的控制了,也就不進行GC,因此有一定的效能提升,同樣這樣方法區也方便在元空間中進行統一管理。

如何識別垃圾

引用計數法

引用計數法就是每個物件引用你一次,你的物件頭上就+1,如果沒有物件引用你(引用次數為0),那你涼涼,等著被回收吧。

聽著引用計數確實可以解決我們無法識別哪些物件該被回收的問題,但是他還有個主要問題沒被解決,那就是迴圈引用。什麼是迴圈引用呢?

例如

A a = new Instance("a");
B b = new Instance("b");
a.instance=b;
b.instance=a;
a=null;
b=null;

雖然到最後a和b兩個物件都被置為null,但是因為他們之前都互相引用過,所以引用的次數都是1,因此無法被回收。所以現代虛擬機器都不使用這種方法來判斷物件是否該回收了。

可達性分析演算法

現代虛擬機器主要是採用這種演算法進行判斷獨享是不是該被回收。它的原理是從一個叫做GC Root物件為起點出發,引出他們指向的下一個節點,再從下一個節點出發,繼續引出下一個,以此類推。這樣就通過GCRoot節點串成了一條引用鏈,如果相關物件不是這個引用鏈上的節點,則會被判定為垃圾,然後會被回收。

可達性分析演算法可以解決上述迴圈引用的問題,因為兩個物件a,b都沒有在GC Root所在的引用鏈上。

物件最後一次垂死掙扎的機會,finalize方法。

當發生GC時,finalize方法給物件一個催死掙扎的機會,當物件可回收的時候,首先會判斷這個物件是不是執行了finalize方法,如果未執行,則會先執行finalize,我們可以在finalize方法內部將本物件和GC Root關聯起來,這樣執行完方法後,GC會再次判斷物件是否可被回收,如果可達則不會進行回收。

finalize方法只會執行一次,如果第一次執行方法這個物件變成了可達確實不會回收但是再次對這個物件進行回收的時候,則會忽略finalize方法。

哪些物件可以作為GC Root呢?

  • 虛擬機器棧中引用的物件(本地變數表中的物件)
  • 方法區中靜態屬性引用的物件。
  • 方法去中常量引用的物件。
  • 本地方法Native中引用的物件。

再談引用

JDK1.2後,Java對引用的概念進行了補充,將引用分為強引用,軟引用,弱引用,虛引用。強度依次遞減。

  • 強引用:強引用就是new出來的引用,只要強引用存在,垃圾收集器就不會回收掉物件。
  • 軟引用:用來描述一些有用但是未必須的引用,在進行發生記憶體溢位之前會對軟引用進行回收,如果記憶體空間充足不會回收軟引用指向的物件,提供了SoftReferemce來實現軟引用。
  • 弱引用也是用來描述非必須物件。但是他的強度比軟引用還要弱,弱引用關聯的物件只能存活到下一次GC之前,無論記憶體是否充足都會回收弱引用關聯的物件。弱引用用WeakReference類來實現。
  • 虛引用:也叫幽靈引用或者幻影引用,是最弱的一種引用關係,一個物件是否有虛引用的存在完全不影響物件的生存時間,虛引用存在的目的就是能在這個物件被回收時收到一個系統通知。PhantomReference類來實現虛引用。

垃圾回收演算法

上面講了如何通過可達性分析演算法來是被哪些資料是垃圾,那具體該通過什麼方式回收垃圾呢?

垃圾回收演算法主要由以下幾種方式

  • 標記清除法
  • 複製演算法
  • 標記整理法

標記清除法

先用可達性分析演算法標記處可回收的物件。

對可回收物件進行回收。

image-20210115122547191

操作簡單不需要移動資料,但是缺點也很明顯,就是存在記憶體碎片。如果想要再申請的記憶體空間大小大於碎片的大小就會申請失敗,那要是將回收過的記憶體區域和原先沒有資料的區域都合併到一塊就可以了。

複製演算法

將堆等分成兩塊記憶體區域,我們暫且把他記作區域A和區域B,A負責分配物件,區域B不分配,A區域中的物件標記為可回收時,將A中所有不可回收的物件都趕到B中,對A進行統一清除,B中存活的物件緊鄰排列。

這種演算法的缺陷也很明顯,我明明堆中還有很多空餘的空間但是不能分配,只能使用一半的空間,另外每次回收都要移動物件,這是很浪費資源並且效率低下。

標記整理法

標記整理法與標記清除法不同的是他多了一步整理記憶體碎片的操作。將所有存活物件都往一端移動,緊鄰排列,再清除另一端的所有區域,這樣就解決了記憶體碎片的問題。

但是還有缺點:每次清除可回收物件都要進行物件的移動,效率很低下。

image-20210115123647316

分代收集演算法

分代收集演算法整合了上面所講的所有演算法,綜合以上演算法優點,最大程度避免他們的缺點,因此使現代虛擬機器採用的首選演算法,於其說他是演算法,倒不是說它是一種收集策略。

經過有關專家研究表明,大部分物件(98%)都是朝生夕死,經過一次年輕代的GC就會被回收,所以分代收集演算法是根據物件存活週期的不同將堆分成新生代和老年代,在Java8之前還有永久代,新生代和老年代的比例是1:2,新生代又分為Eden區,from Survivor區,to Survivor區,簡稱S0區和S1區,Eden:S0:S1=8:1:1,我們將新生代發生的GC叫做Young GC或Minor GC,將老年代發生的GC叫做 Old GC也叫Full GC。

工作原理

新生代的分配和回收

新生代物件一般在Eden區分配,當Eden滿的時候,會發生一次Minor GC,這次Minor GC很少有物件存活,因為大部分物件都是朝生夕死的,少部分存活的物件會被移動到S0區,同時這些物件的年齡+1,最後將Eden區中的所有物件都清除,釋放空間。(複製演算法

當發生下一次Minor GC時,會把Eden區中存活的物件和S0中存活的物件都移動到S1,這些物件的年齡+1,同時清空Eden和S0空間。

若再次發生MinorGC重複上面的步驟,只不過這次是將Eden和S1中存活的物件移動到S1,每次Young GC都是S0和S1來回之間移動。因為S0和S1區域比較小,所以降低複製演算法頻繁拷貝帶來的開銷。

物件是如何進入到老年代的

大物件直接進入老年代

大物件一般指的是很長的字串或者陣列,當出現大物件時,會導致提前觸發GC,虛擬機器提供了一個-XX:PertenureSizeThreshold引數如果物件大小大於這個引數設定的閾值,就認為是大物件,直接分配到老年代,這樣做的目的是避免Eden和S1,S0區域之間發生大物件的拷貝。

長期存活的物件進入老年代

虛擬機器給每個物件都定義了一個年齡計數器,每次經過Minor GC後還存活下來的物件,他們的年齡+1,當計數器的值加到一定程度(預設是15),就會晉升到老年代,物件晉升老年代的閾值可以通過引數-XX:MaxTenuringThershold設定。

動態物件年齡判定

這種情況也會晉升到老年代,如果Survivor區中相同年齡的物件大小之和大於Survivor區空間大小的一半,這時候年齡大於等於該年齡的物件也會直接進入老年代,無需和MaxTrnuringThershold引數進行比較。

空間分配擔保

在發生Minor GC之前,虛擬機器會檢查老年代最大可用的連續空間是否大於新生代所有物件的總空間,如果大於,那麼就可以確保Minor GC是安全的,如果不大於,虛擬機器會檢視HandlePromotionFailure設定值是否允許擔保失敗,如果允許的話,那麼會繼續檢查老年代物件的平均大小,如果大於則進行GC,否者可能進行一次Full GC。儘管空間分配擔保繞的圈子很大,但是平時還是會開啟擔保的,因為可以減少Full GC的頻率。

Stop The World

如果老年代滿了,會觸發Full GC,Full GC會同時回收新生代和老年代,也就是對整個堆進行GC,他會導致Stop The World,造成很大的效能開銷。Stop The World就是指在這個GC期間,除了垃圾回收執行緒在工作,其他執行緒會被掛起。

一般Full GC會導致工作執行緒停頓時間過長,如果再次期間,服務端收到了客戶端很多的請求,則會被拒絕服務,所以才要儘量減少Full GC的次數。

因此虛擬機器設計成新生代分為Eden,S0,S1,並且設定物件年齡閾值,預設新生代和老年代的比例是1:2都是為了避免物件過早的進入老年代,儘可能晚的觸發Full GC。

老年代採用標記整理法進行垃圾回收。

因為GC都會影響效能,所以我們要在一個合適的時間點發起GC,這個時間點被稱為安全點(Safe Point),這個時間點的選定既不能太少讓GC時間太長,也不能過於頻繁以至於過分的增大執行時的負荷,安全點一般是以下特定的位置:

  • 迴圈的末尾
  • 方法返回前
  • 呼叫方法的call之後
  • 丟擲異常的位置。

垃圾收集器的種類

收集演算法其實是理論層面的,垃圾收集器才是這些理論具體的實現。

image-20210115134007568

新生代收集器

Serial收集器

Serial收集器收集的是新生代,單執行緒的垃圾收集器,單執行緒意味著他只會使用一個CPU或者一個收集執行緒來進行垃圾回收,他在進行垃圾回收的時候,其他使用者執行緒會暫停,在GC期間這個應用不可用。但是在使用者端模式下,他是簡單有效的,對於限定單個CPU的環境來說,Serial單執行緒模式無需與其他執行緒進行互動,較少了開銷,專心做GC能將單執行緒的優勢發揮到極致,在桌面應用場景下,一般不會給虛擬機器分配很大的記憶體,因此STW(Stop The World)的時間會在100ms以內,這點停頓是可以接受的,所以對於Client模式下的虛擬機器,Serial收集器是新生代的預設收集器。

ParNew收集器

ParNew收集器是Serial收集器的多執行緒版本,除了使用多執行緒,其他收集演算法以及物件分配,回收策略都和Serial一樣。ParNew主要工作在服務端,服務端如果接受的請求多了,響應時間就很重要,多執行緒可以讓垃圾與回收更快,也就是減少了STW時間,提升響應速度,所以許多執行在服務端的虛擬機器採用的新生代垃圾收集器是ParNew ,還有一點,他只能和CMS收集器配合工作,CMS是一個完全併發的收集器,第一次實現了垃圾收集執行緒和使用者執行緒同時工作,採用的是傳統的GC收集器程式碼框架,與Serial,ParNew共用一套程式碼框架,所以可以和這兩個收集器配合工作。

在多CPU情況下,ParNew收集器垃圾收集更快,可以有效減少STW時間,提升服務端響應速度。

Parallel Scavenge收集器

Parallel Scavenge收集器也是一個使用複製演算法,多執行緒,工作在新生代的垃圾收集器。看起來他的功能和ParNew收集器一樣。但是還有一些不同。

關注點不同:CMS等垃圾收集器關注的是儘可能縮短垃圾收集時使用者執行緒停頓的時間,而Parallel Scavenge目標是達到一個可控制的吞吐量。
$$
吞吐量=使用者程式碼執行時間/(使用者程式碼執行時間+垃圾收集時間)
$$
CMS等垃圾收集器更適合用於與使用者互動的應用,提升使用者體驗。而Parallel Scavenge收集器關注的是吞吐量,所以更適合用於後臺運算等不需要太多使用者互動的任務。

Parallel Scavenge收集器提供了兩個引數來精確控制吞吐量,分別是控制最大垃圾手機時間的-XX:MaxGCPauseMillis以及設定吞吐量大小的-XX:GCtimeRatio預設是99%。

除了這兩個引數外,還有第三個引數-XX:UseAdaptiveSizePolicy開啟這個引數後,就不要手工指定新生代大小比例等細節,只需要設定好堆的大小,以及最大垃圾收集時間和吞吐量,虛擬機器就會根據當前系統執行情況動態調整這些引數儘可能的達到設定的最大垃圾收集時間和吞吐量,自適應策略是ParallelScavenger和ParNew的重要區別。

老年代收集器

Serial Old

Serial收集器是工作在新生代的單執行緒收集器。Serial Old是工作在老年代的單執行緒收集器。這個收集器的主要意義是給Client模式下的虛擬機器使用,如果在Server模式下,他還有兩大用途,一種是和JDK1.5以及之前的版本的Parallel Scavenge收集器配合使用,另一種是作為CMS的備用方案。

Parallel Old

Parallel Old收集器是相對於Parallel Scavenge收集器的老年代版本,使用多執行緒和標記整理法。

CMS

CMS收集器是以實現最短STW時間為目標的收集器,如果應豔紅很重視服務的相應速度,希望給使用者最好的體驗,則CMS收集器是不錯的選擇。

CMS雖然工作在老年代但是回收演算法使用的是標記清除法。

1、初始標記

2、併發標記

3、重新標記

4、併發清除

在這四個步驟中,初始標記和重新標記兩個階段會發生STW,造成使用者執行緒掛起,不過初始標記僅僅標記GC Root能夠關聯的物件,速度很快,重新標記是進行GC Root跟蹤引用鏈的過程,是為了修正併發標記期間因為使用者執行緒繼續執行而導致標記產生變動的哪一部分物件的標記記錄,這一階段停頓時間一般比初始標記更長,但比並發標記短。

整個過程執行時間最長的是併發標記和標記整理,不過這兩個階段使用者執行緒都可以工作,所以不影響應用的正常使用,所以總體上看,可以認為CMS是記憶體回收執行緒和使用者執行緒一起併發執行的。

但是他有三個缺點:

  • CMS收集器對CPU資源非常敏感。比如本來有10個使用者執行緒處理請求,現在要分出三個執行緒做垃圾回收工作,吞吐量下降了30%,CMS預設啟動的回收執行緒數=(CPU數量+3)/4,如果CPU是2個,那麼吞吐量直接降低50%。顯然是不可接受的。
  • CMS無法處理浮動垃圾,什麼是浮動垃圾?因為併發清理階段,使用者執行緒還在工作,所以還會出現新的可回收物件,這部分垃圾只能在下一次GC時再清理,所以這部分垃圾就是浮動垃圾。因為垃圾收集階段使用者執行緒還在執行所以需要預留足夠多的空間確保使用者執行緒正常執行,這就意味著CMS收集器要提前進行Full GC,JDK1.5預設當老年代使用68%空間就後被啟用,這個比例可以通過-XX:CMSInitiatingOccupancyFraction來設定,但是如果設定太高容易導致CMS執行期間預留的記憶體不夠,導致Concurrent Model Failure,這時會啟用Serial Old收集器進行老年代的收集工作,但是Serial old 是單執行緒的,這就導致STW時間更長了。
  • CMS因為採用的是標記清除法,所以會存在大量的記憶體碎片,如果無法找到足夠的記憶體空間進行分配,就會觸發FUllGC進行垃圾回收,影響應用的效能,我們可以開啟-XX:+UseCMSCompactAtFullCollection,這個引數是當CMS頂不住要進行Full GC時開啟記憶體碎片的合併整理過程,記憶體整理會導致STW,停頓時間會變長,還可以用另一個引數-XX:CMSFullGCsBeforeCompation用來設定執行多少次不壓縮的Full GC過後再進行一次壓縮。

G1(Garbage First)

G1收集器歐式面向服務端的垃圾收集器,被稱為駕馭一切的垃圾回收器。

特點如下:

  • 向CMS收集器一樣,能與應用程式執行緒併發執行。
  • 整理空閒空間更快。
  • 需要GC停頓時間更好預測。
  • 不會像CMS那樣犧牲大量的吞吐效能。
  • 不需要打的java 堆。

與CMS相比,它有以下方面表現得更為出色。

  1. 執行期間不會產生記憶體碎片。整體採用標記整理法,區域性採用複製演算法,兩種演算法都不會產生內部碎片。
  2. STW建立在可預測的停頓時間模型,使用者可以指定期望停頓時間,G1將會停頓時間控制在使用者設定的停頓時間以內。

他為什麼能建立可預測模型呢?

主要原因是他和傳統的記憶體分配儲存方式不一樣。傳統記憶體分配是連續的,新生代,老年代。但是G1的儲存地址不是連續的,每一代都是用N個不連續的大小相同的Region,每個Region佔有一塊連續的虛擬記憶體地址。和傳統相比還多了一個H區,代表Humongous,標會儲存的是大物件。當物件大小大於Region的一般,就直接分給老年代,防止GC時反覆拷貝大物件。

這樣做G1就可以根據Region的價值大小(回收所獲得的空間大小以及回收經驗值)進行排序,維護成一個優先順序列表,根據允許的時間,回收截止最大的Region,也就避免了整個老年代的回收,減少了STW造成的停頓時間。

G1收集器工作步驟

  1. 初始標記
  2. 併發標記
  3. 最終標記
  4. 篩選回收

篩選階段會根據各個Region的回收價值和成本進行排序,根據使用者期望的GC停頓時間來制定回收計劃。

相關文章