JVM垃圾回收(下)

接着上一篇,介紹完了 JVM 中識別須要回收的垃圾對象以後,這一篇咱們來講說 JVM 是如何進行垃圾回收。 git

首先要在這裏介紹一下80/20 法則:github

約僅有20%的變因操縱着80%的局面。也就是說:全部變量中,最重要的僅有20%,雖然剩餘的80%佔了多數,控制的範圍卻遠低於「關鍵的少數」。算法

Java 對象的生命週期也知足也這樣的定律,即大部分的 Java 對象只存活一小段時間,而存活下來的小部分 Java 對象則會存活很長一段時間。多線程

所以,這也就造就了 JVM 中分代回收的思想。簡單來講,就是將堆空間劃分爲兩代,分別叫作新生代老年代。新生代用來存儲新建的對象。當對象存活時間夠長時,則將其移動到老年代。併發

這樣也就可讓 JVM 給不一樣代使用不一樣的回收算法。性能

對於新生代,咱們猜想大部分的 Java 對象只存活一小段時間,那麼即可以頻繁地採用耗時較短的垃圾回收算法,讓大部分的垃圾都可以在新生代被回收掉。優化

對於老年代,咱們猜想大部分的垃圾已經在新生代中被回收了,而在老年代中的對象有大機率會繼續存活。當真正觸發針對老年代的回收時,則表明這個假設出錯了,或者堆的空間已經耗盡了。此時,JVM 每每須要作一次全堆掃描,耗時也將不計成本。(固然,現代的垃圾回收器都在併發收集的道路上發展,來避免這種全堆掃描的狀況。)線程

那麼,咱們先來看看 JVM 中堆到底是如何劃分的。指針

堆劃分

按照上文所述,JVM 將堆劃分爲新生代和老年代,其中,新生代又被劃分爲 Eden 區,以及兩個大小相同的 Survivor 區。code

一般來講,當咱們調用 new 指令時,它會在 Eden 區中劃出一塊做爲存儲對象的內存。因爲堆空間是線程共享的,所以直接在這裏邊劃空間是須要進行同步的。不然,將有可能出現兩個對象共用一段內存的事故。

JVM 的解決方法是爲每一個線程預先申請一段連續的堆空間,而且只容許每一個線程在本身申請過的堆空間中建立對象,若是申請的堆空間被用完了,那麼再繼續申請便可,這也就是 TLAB(Thread Local Allocation Buffer,對應虛擬機參數 -XX:+UseTLAB,默認開啓)。

此時,若是線程操做涉及到加鎖,則該線程須要維護兩個指針(實際上可能更多,但重要也就兩個),一個指向 TLAB 中空餘內存的起始位置,一個則指向 TLAB 末尾。

接下來的 new 指令,即可以直接經過指針加法(bump the pointer)來實現,即把指向空餘內存位置的指針加上所請求的字節數。

若是加法後空餘內存指針的值仍小於或等於指向末尾的指針,則表明分配成功。不然,TLAB 已經沒有足夠的空間來知足本次新建操做。這個時候,便須要當前線程從新申請新的 TLAB。

那有沒有可能出現申請不到的狀況呢?有的,這個時候就會觸發Minor GC了。

Minor GC

所謂 Minor GC,就是指:

當 Eden 區的空間耗盡時,JVM 會進行一次 Minor GC,來收集新生代的垃圾。存活下來的對象,則會被送到 Survivor 區。

上文提到,新生代共有兩個 Survivor 區,咱們分別用 from 和 to 來指代。其中 to 指向的 Survivior 區是空的。

當發生 Minor GC 時,Eden 區和 from 指向的 Survivor 區中的存活對象會被複制到 to 指向的 Survivor 區中,而後交換 from 和 to 指針,以保證下一次 Minor GC 時,to 指向的 Survivor 區仍是空的。

JVM 會記錄 Survivor 區中每一個對象一共被來回複製了幾回。若是一個對象被複制的次數爲 15(對應虛擬機參數 -XX:+MaxTenuringThreshold),那麼該對象將被晉升(promote)至老年代。

另外,若是單個 Survivor 區已經被佔用了 50%(對應虛擬機參數 -XX:TargetSurvivorRatio),那麼較高複製次數的對象也會被晉升至老年代。

總而言之,當發生 Minor GC 時,咱們應用了標記 - 複製算法,將 Survivor 區中的老存活對象晉升到老年代,而後將剩下的存活對象和 Eden 區的存活對象複製到另外一個 Survivor 區中。理想狀況下,Eden 區中的對象基本都死亡了,那麼須要複製的數據將很是少,所以採用這種標記 - 複製算法的效果極好。

Minor GC 的另一個好處是不用對整個堆進行垃圾回收。可是,它卻有一個問題,那就是老年代中的對象可能引用新生代的對象。也就是說,在標記存活對象的時候,咱們須要掃描老年代中的對象。若是該對象擁有對新生代對象的引用,那麼這個引用也會被做爲 GC Roots。這樣一來,豈不是又作了一次全堆掃描呢?

爲了不掃描全堆,JVM 引入了名爲卡表的技術,大體地標出可能存在老年代到新生代引用的內存區域。有興趣的朋友能夠去詳細瞭解一下,這裏限於篇幅,就不具體介紹了。

Full GC

那何時會發生Full GC呢?針對不一樣的垃圾收集器,Full GC 的觸發條件可能不都同樣。按 HotSpot VM 的 serial GC 的實現來看,觸發條件是:

當準備要觸發一次 Minor GC 時,若是發現統計數聽說以前 Minor GC 的平均晉升大小比目前老年代剩餘的空間大,則不會觸發 Minor GC 而是轉爲觸發 Full GC。

由於 HotSpot VM 的 GC 裏,除了垃圾回收器 CMS 能單獨收集老年代以外,其餘的 GC 都會同時收集整個堆,因此不須要事先準備一次單獨的 Minor GC。

垃圾回收

基礎的回收方式有三種:清除壓縮複製,接下來讓咱們來一一瞭解一下。

清除

所謂清除,就是把死亡對象所佔據的內存標記爲空閒內存,並記錄在一個空閒列表之中。當須要新建對象時,內存管理模塊便會從該空閒列表中尋找空閒內存,並劃分給新建的對象。

其原理十分簡單,可是有兩個缺點:

  1. 會形成內存碎片。因爲 JVM 的堆中對象必須是連續分佈的,所以可能出現總空閒內存足夠,可是沒法分配的極端狀況。
  2. 分配效率較低。若是是一塊連續的內存空間,那麼咱們能夠經過指針加法(pointer bumping)來作分配。而對於空閒列表,JVM 則須要逐個訪問空閒列表中的項,來查找可以放入新建對象的空閒內存。

壓縮

所謂壓縮,就是把存活的對象彙集到內存區域的起始位置,從而留下一段連續的內存空間。

這種作法可以解決內存碎片化的問題,但代價是壓縮算法的性能開銷,所以分配效率問題依舊沒有解決。

複製

所謂複製,就是把內存區域平均分爲兩塊,分別用兩個指針 from 和 to 來維護,而且只是用 from 指針指向的內存區域來分配內存。當發生垃圾回收時,便把存活的對象複製到 to 指針所指向的內存區域中,而且交換 from 指針和 to 指針的內容。

這種回收方式一樣可以解決內存碎片化的問題,可是它的缺點也極其明顯,即堆空間的使用效率極其低下。

具體垃圾收集器

針對新生代的垃圾回收器共有三個:Serial ,Parallel Scavenge 和 Parallel New。這三個採用的都是標記 - 複製算法。

其中,Serial 是一個單線程的,Parallel New 能夠當作是 Serial 的多線程版本,Parallel Scavenge 和 Parallel New 相似,但更加註重吞吐率。此外,Parallel Scavenge 不能與 CMS 一塊兒使用。

針對老年代的垃圾回收器也有三個:Serial Old ,Parallel Old 和 CMS。

Serial Old 和 Parallel Old 都是標記 - 壓縮算法。一樣,前者是單線程的,然後者能夠當作前者的多線程版本。

CMS 採用的是標記 - 清除算法,而且是併發的。除了少數幾個操做須要 STW(Stop the world) 以外,它能夠在應用程序運行過程當中進行垃圾回收。在併發收集失敗的狀況下,JVM 會使用其餘兩個壓縮型垃圾回收器進行一次垃圾回收。因爲 G1 的出現,CMS 在 Java 9 中已被廢棄。

G1(Garbage First)是一個橫跨新生代和老年代的垃圾回收器。實際上,它已經打亂了前面所說的堆結構,直接將堆分紅極其多個區域。每一個區域均可以充當 Eden 區、Survivor 區或者老年代中的一個。它採用的是標記 - 壓縮算法,並且和 CMS 同樣都可以在應用程序運行過程當中併發地進行垃圾回收。

G1 可以針對每一個細分的區域來進行垃圾回收。在選擇進行垃圾回收的區域時,它會優先回收死亡對象較多的區域。這也是 G1 名字的由來。

總結

這篇文章主要講述的是 JVM 中具體的垃圾回收方法,從對象的生存規律,引出回收方法,結合多線程的特色,逐步優化,最終產生了咱們如今所能知道各類垃圾收集器。

有興趣的話能夠訪問個人博客或者關注個人公衆號、頭條號,說不定會有意外的驚喜。

death00.github.io/

相關文章
相關標籤/搜索