淺談JVM垃圾回收


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停頓時間來制定回收計划。


免責聲明!

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



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