天天看點

JVM垃圾回收算法JVM如何判斷對象"已死"?垃圾回收算法

Java是一門不用程式員手動管理記憶體的語言,全靠JVM自動管理記憶體,既然是自動管理,那必然有一個垃圾記憶體的回收機制或者回收算法。

在Java堆上配置設定一個記憶體給執行個體對象時,此時在虛拟機棧上引用型變量就會存放這個執行個體對象位址。

JVM垃圾回收算法JVM如何判斷對象"已死"?垃圾回收算法

 當執行Object obj = null 時,棧中的obj變量就不在指向堆中的執行個體對象了,也就是Java堆上的執行個體對象無法再次引用它,那麼它就是被GC的對象,我們稱之為對象“已死”。那虛拟機棧上的obj變量呢? JVM記憶體結構提到過,虛拟機棧是線程獨占的,也就是說随着線程初始而初始,消亡而消亡,當線程被銷毀後,虛拟機棧上的記憶體自然會被回收,也就是說虛拟機棧上的這塊記憶體空間不在虛拟機GC範圍。下圖展示了垃圾回收的記憶體範圍:

JVM垃圾回收算法JVM如何判斷對象"已死"?垃圾回收算法

JVM如何判斷對象"已死"?

        在堆裡面存放着Java 世界中幾乎所有的對象執行個體,垃圾收集器在對堆進行回收前,第一件事情就是要确定這些對象之中哪些還 存活着,哪些已經死去(“死去”即不可能再被任何途徑使用的對象)了。

引用計數算法

思想:

在對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器為零的對象就是不可能再被使用的

        看似簡單的一個算法,實際上在大部分Java虛拟機中并沒有采用這種算法,因為它會帶來一個緻命的問題——對象循環引用。對象A指向B,對象B反過來指向A,此時它們的引用計數器都不為0,但它們倆實際上已經沒有意義因為沒有任何地方指向它們,但是它們因為互相引用着對方,導緻它們的引用計數都不為零,引用計數算法也就無法回收它們。

demo:

JVM垃圾回收算法JVM如何判斷對象"已死"?垃圾回收算法

 運作結果:

JVM垃圾回收算法JVM如何判斷對象"已死"?垃圾回收算法

從運作結果中可以清楚看到記憶體回收日志中包含 “4603K->210K” ,意味着虛拟機并沒有因為這兩 個對象互相引用就放棄回收它們,這也從側面 說明了Java虛拟機并不是通過引用計數算法來判斷對象是否存活的。

可達性分析算法

        目前主流的商用程式語言(Java、C#,上溯至前面提到的古老的Lisp)的記憶體管理子系統,都是通過 可達性分析(Reachability Analysis)算法 來 判定 對象是否存活 的。 算法的基本思路:

        是通過一系列稱為 “GC Roots” 的根對象作為起始節點集,從這些節點開始,根據引用關系向下搜尋,搜尋過 程所走過的路徑稱為“引用鍊”(Reference Chain),如果某個對象到GC Roots間沒有任何引用鍊相連, 或者用圖論的話來說就是從GC Roots到這個對象不可達時,則證明此對象是不可能再被使用的。

如圖所示,對象object 5、object 6、object 7雖然互有關聯,但是它們到GC Roots是不可達的, 是以它們将會被判定為可回收的對象。

JVM垃圾回收算法JVM如何判斷對象"已死"?垃圾回收算法

 在Java技術體系裡面,固定可作為GC Roots的對象包括以下幾種:

  • 在虛拟機棧(棧幀中的本地變量表)中引用的對象,譬如各個線程被調用的方法堆棧中使用到的參數、局部變量、臨時變量等。
  • 在方法區中類靜态屬性引用的對象,譬如Java類的引用類型靜态變量。
  • 在方法區中常量引用的對象,譬如字元串常量池(String Table)裡的引用。
  • 在本地方法棧中JNI(即通常所說的Native方法)引用的對象。
  • Java虛拟機内部的引用,如基本資料類型對應的Class對象,一些常駐的異常對象(比如 NullPointExcepiton、OutOfMemoryError)等,還有系統類加載器。
  • 所有被同步鎖(synchronized關鍵字)持有的對象。
  • 反映Java虛拟機内部情況的JMXBean、JVMTI中注冊的回調、本地代碼緩存等。

除了這些固定的 GC Roots 集合以外,根據使用者所選用的垃圾收集器以及目前回收的記憶體區域不 同,還可以有其他對象 “ 臨時性 ” 地加入,共同構成完整 GC Roots 集合。

垃圾回收算法

分代收集理論

         目前商業虛拟機的垃圾收集器,大多數都遵循了“分代收集”(Generational Collection)的理論進行設計,分代收集名為理論,實質是一套符合大多數程式運作實際情況的經驗法則,它建立在兩個分代假說之上:

  • 弱分代假說(Weak Generational Hypothesis):絕大多數對象都是朝生夕滅的。
  • 強分代假說(Strong Generational Hypothesis):熬過越多次垃圾收集過程的對象就越難以消亡。

          這兩個分代假說共同奠定了多款常用的垃圾收集器的一緻的設計原則:收集器應該将Java堆劃分出不同的區域,然後将回收對象依據其年齡(年齡即對象熬過垃圾收集過程的次數)配置設定到不同的區域之中存儲。顯而易見,如果一個區域中大多數對象都是朝生夕滅,難以熬過垃圾收集過程的話,那麼把它們集中放在一起,每次回收時隻關注如何保留少量存活而不是去标記那些大量将要被回收的對象,就能以較低代價回收到大量的空間;如果剩下的都是難以消亡的對象,那把它們集中放在一塊,虛拟機便可以使用較低的頻率來回收這個區域,這就同時兼顧了垃圾收集的時間開銷和記憶體的空間有效利用。

        在Java堆劃分出不同的區域之後,垃圾收集器才可以每次隻回收其中某一個或者某些部分的區域——----因而才有了“Minor GC”“Major GC”“Full GC”這樣的回收類型的劃分;也才能夠針對不同的區域安排與裡面存儲對象存亡特征相比對的垃圾收集算法——因而發展出了“标記-複制算法”“标記-清除算法”“标記-整理算法”等針對性的垃圾收集算法。這裡筆者提前提及了一些新的名詞,它們都是本章的重要角色,稍後都會逐一登場,現在讀者隻需要知道,這一切的出現都始于分代收集理論。

        把分代收集理論具體放到現在的商用Java虛拟機裡,設計者一般至少會把Java堆劃分為新生代 (Young Generation)和老年代(Old Generation)兩個區域。顧名思義,在新生代中,每次垃圾收集時都發現有大批對象死去,而每次回收後存活的少量對象,将會逐漸晉升到老年代中存放。如果讀者有興趣閱讀HotSpot虛拟機源碼的話,會發現裡面存在着一些名為“*Generation”的實作, 如“DefNewGeneration”和“ParNewGeneration”等,這些就是HotSpot的“分代式垃圾收集器架構”。原本HotSpot鼓勵開發者盡量在這個架構内開發新的垃圾收集器,但除了最早期的兩組四款收集器之外,後來的開發者并沒有繼續遵循。導緻此事的原因有很多,最根本的是分代收集理論仍在不斷發展之中, 如何實作也有許多細節可以改進,被既定的代碼架構限制反而不便。其實我們隻要仔細思考一下,也很容易發現分代收集并非隻是簡單劃分一下記憶體區域那麼容易,它至少存在一個明顯的困難:對象不是孤立的,對象之間會存在跨代引用。

        假如要現在進行一次隻局限于新生代區域内的收集(Minor GC),但新生代中的對象是完全有可能被老年代所引用的,為了找出該區域中的存活對象,不得不在固定的GC Roots之外,再額外周遊整個老年代中所有對象來確定可達性分析結果的正确性,反過來也是一樣[3]。周遊整個老年代所有對象的方案雖然理論上可行,但無疑會為記憶體回收帶來很大的性能負擔。為了解決這個問題,就需要對分代收集理論添加第三條經驗法則:

  • 跨代引用假說(Intergenerational Reference Hypothesis):跨代引用相對于同代引用來說僅占極少數。

        這其實是可根據前兩條假說邏輯推理得出的隐含推論:存在互相引用關系的兩個對象,是應該傾向于同時生存或者同時消亡的。舉個例子,如果某個新生代對象存在跨代引用,由于老年代對象難以消亡,該引用會使得新生代對象在收集時同樣得以存活,進而在年齡增長之後晉升到老年代中,這時跨代引用也随即被消除了。                  依據這條假說,我們就不應再為了少量的跨代引用去掃描整個老年代,也不必浪費空間專門記錄每一個對象是否存在及存在哪些跨代引用,隻需在新生代上建立一個全局的資料結構(該結構被稱為“記憶集”,Remembered Set),這個結構把老年代劃分成若幹小塊,辨別出老年代的哪一塊記憶體會存在跨代引用。此後當發生Minor GC時,隻有包含了跨代引用的小塊記憶體裡的對象才會被加入到GC Roots進行掃描。雖然這種方法需要在對象改變引用關系(如将自己或者某個屬性指派)時維護記錄資料的正确性,會增加一些運作時的開銷,但比起收集時掃描整個老年代來說仍然是劃算的。 注意 剛才我們已經提到了“Minor GC”,後續文中還會出現其他針對不同分代的類似名詞, 為避免讀者産生混淆,在這裡統一定義:

  • 部分收集(Partial GC):指目标不是完整收集整個Java堆的垃圾收集,其中又分為:
  1. 新生代收集(Minor GC/Young GC):指目标隻是新生代的垃圾收集。
  2. 老年代收集(Major GC/Old GC):指目标隻是老年代的垃圾收集。目前隻有CMS收集器會有單獨收集老年代的行為。另外請注意“Major GC”這個說法現在有點混淆,在不同資料上常有不同所指, 讀者需按上下文區分到底是指老年代的收集還是整堆收集。
  3. 混合收集(Mixed GC):指目标是收集整個新生代以及部分老年代的垃圾收集。目前隻有G1收集器會有這種行為。
  • 整堆收集(Full GC):收集整個Java堆和方法區的垃圾收集。

值得注意的是,分代收集理論也有其缺陷,最新出現(或在實驗中)的幾款垃圾收集器都展現出了面向全區域收集設計的思想,或者可以支援全區域不分代的收集的工作模式。新生代(Young)、老年代(Old)是HotSpot虛拟機,也是現在業界主流的命名方式。在IBM J9虛拟機中對應稱為嬰兒區(Nursery)和長存區(Tenured),名字不同但其含義是一樣的。通常能單獨發生收集行為的隻是新生代,是以這裡“反過來”的情況隻是理論上允許,實際上除了CMS收集器,其他都不存在隻針對老年代的收集

以上摘自 深入了解Java虛拟機:JVM進階特性與最佳實踐(第3版) (華章原創精品) - 周志明

标記-清除 算法

标記清除算法是最早也是最基礎的垃圾收集算法,算法分為“ 标記 ” 和 “ 清除 ” 兩個階段:

        首先标記出所有需要回收的對象,在标記完成後,統一回收掉所有被标記的對象,也可以反過來,标記存活的對象,統一回收所有未被标記的對象。标記過程就是對象是否屬于垃圾的判定過程,這在前面講述垃圾對象标記判定算法時其實已經介紹過了。

        之是以說它是最基礎的收集算法,是因為後續的收集算法大多都是以标記-清除算法為基礎,對其缺點進行改進而得到的。 标記- 清除算法的執行過程如圖 所示:

JVM垃圾回收算法JVM如何判斷對象"已死"?垃圾回收算法

标記-清除算法的缺點:

        第一個是執行效率不穩定,如果Java堆中包含大量對象,而且其中大部分是需要被回收的,這時必須進行大量标記和清除的動作,導緻标記和清除兩個過程的執行效率都随對象數量增長而降低;         第二個是記憶體空間的碎片化問題,标記、清除之後會産生大量不連續的記憶體碎片,空間碎片太多可能會導緻當以後在程式運作過程中需要配置設定較大對象時無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

标記-複制 算法

          标記-複制算法常被簡稱為複制算法。為了解決标記-清除算法面對大量可回收對象時執行效率低和記憶體空間碎片化的問題         1969年 Fenichel 提出了一種稱為 “ 半區複制 ” ( Semispace Copying )的垃圾收集算法, 它将可用記憶體按容量劃分為大小相等的兩塊,每次隻使用其中的一塊。當這一塊的記憶體用完了,就将還存活着的對象複制到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。         如果記憶體中多數對象都是存活的,這種算法将會 産生大量的記憶體間複制的開銷 ,但對于多數對象都是可回收的情況,算法需要複制的就是占少數的存活對象,而且每次都是針對整個半區進行記憶體回收,配置設定記憶體時也就不用考慮有空間碎片的複雜情況,隻要移動堆頂指針,按順序配置設定即可。這樣實作簡單,運作高效,不過其缺陷也顯而易見,這種複制回收算法的代價是将可用記憶體縮小為了原來的一半,空間浪費未免太多了一 點。

複制算法主要 針對新生代的垃圾回收 。

Java堆中被分為了新生代和老年代,這樣的劃分是友善GC。Java堆中的新生代就使用了GC複制算法。在新生代中又分為了三個區域:Eden 空間、To Survivor空間、From Survivor空間。

JVM垃圾回收算法JVM如何判斷對象"已死"?垃圾回收算法

 新的對象執行個體被建立的時候幾乎都放在Eden空間,發生在Eden空間上的GC稱為Minor GC,當在新生代發生一次GC後,會将Eden和其中一個Survivor空間的記憶體複制到另外一個Survivor中,如果反複幾次有對象一直存活,此時記憶體對象将會被移至老年代(此連接配接的文章中提到年齡計數器)。可以看到新生代中Eden占了大部分,而兩個Survivor實際上占了很小一部分。這是因為大部分的對象被建立過後很快就會被GC(這裡也許運用了是二八原則)。

再重申下複制算法的思想:

        每次将一塊Survivor的空間保留,将另一塊Survivor與Eden一起拿來使用。進行垃圾回收時,将所有存活的對象複制被到保留的那塊Survivor的空間上,然後将Eden和之前使用的Survivor的空間清理掉。兩塊Survivor交替着與Eden一起使用。

原理圖:

初始的新生代中的Eden、s0、s1

JVM垃圾回收算法JVM如何判斷對象"已死"?垃圾回收算法

 GC前新生代中的Eden、s0、s1

JVM垃圾回收算法JVM如何判斷對象"已死"?垃圾回收算法

GC後新生代中的Eden、s0、s1

JVM垃圾回收算法JVM如何判斷對象"已死"?垃圾回收算法

 IBM的一項研究表明,新生代中98%的對象是“朝生夕死”的,是以并不需要按照1:1的比例來劃分記憶體空間,而是将記憶體劃分為一塊較大的名為Eden的空間,兩塊較小的名為Survivor的空間。HotSpot虛拟機預設Eden和Survivor的大小比例為8:1:1。

大多數情況下,隻有2%的對象存活,一塊Survivor(占據10%記憶體)完全夠用,但是超過10%的對象存活的情況還是存在的,屆時一塊Survivor的空間就不夠用了,需要依賴其他記憶體(這裡指老年代)進行配置設定擔保(Handle Promotion)。

如果保留的Survivor沒有足夠的空間存放上一次新生代收集下來的存活對象,這些對象将直接通過配置設定擔保機制進入老年代。

标記-整理 算法

标記-整理算法又名标記-壓縮 算法

對于新生代,大部分對象都不會存活,是以在新生代中使用複制算法較為高效,而對于老年代來講,大部分對象可能會繼續存活下去,如果此時還是利用複制算法,效率則會降低。标記-壓縮算法首先還是先“标記”,标記過後,将不用回收的記憶體對象壓縮到記憶體一端,此時即可直接清除邊界處的記憶體,這樣就能避免複制算法帶來的效率問題,同時也能避免記憶體碎片化的問題。老年代的垃圾回收稱為“Major GC”。

原理圖示:

JVM垃圾回收算法JVM如何判斷對象"已死"?垃圾回收算法