Java虛擬機器:GC演算法深度解析
在前面的文章裡介紹了可達性分析演算法,它為我們解決了判定哪些物件可以回收的問題,接下來就該我們的垃圾收集演算法出場了。不同的垃圾收集演算法有各自不同的優缺點,在JVM實現中,往往不是採用單一的一種演算法進行回收,而是採用幾種不同的演算法組合使用,來達到最好的收集效果。接下來詳細介紹幾種垃圾收集演算法的思想及發展過程。
最基礎的收集演算法 —— 標記/清除演算法
之所以說標記/清除演算法是幾種GC演算法中最基礎的演算法,是因為後續的收集演算法都是基於這種思路並對其不足進行改進而得到的。標記/清除演算法的基本思想就跟它的名字一樣,分為“標記”和“清除”兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件。
標記階段:標記的過程其實就是前面介紹的可達性分析演算法的過程,遍歷所有的GC Roots物件,對從GC Roots物件可達的物件都打上一個標識,一般是在物件的header中,將其記錄為可達物件;
清除階段:清除的過程是對堆記憶體進行遍歷,如果發現某個物件沒有被標記為可達物件(通過讀取物件header資訊),則將其回收。
上圖是標記/清除演算法的示意圖,在標記階段,從物件GC Root 1可以訪問到B物件,從B物件又可以訪問到E物件,因此從GC Root 1到B、E都是可達的,同理,物件F、G、J、K都是可達物件;到了清除階段,所有不可達物件都會被回收。
在垃圾收集器進行GC時,必須停止所有Java執行執行緒(也稱"Stop The World"),原因是在標記階段進行可達性分析時,不可以出現分析過程中物件引用關係還在不斷變化的情況,否則的話可達性分析結果的準確性就無法得到保證。在等待標記清除結束後,應用執行緒才會恢復執行。
前面剛提過,後續的收集演算法是在標記/清除演算法的基礎上進行改進而來的,那也就是說標記/清除演算法有它的不足。其實瞭解了它的原理,其缺點也就不難看出了。
1、效率問題。標記和清除兩個階段的效率都不高,因為這兩個階段都需要遍歷記憶體中的物件,很多時候記憶體中的物件例項數量是非常龐大的,這無疑很耗費時間,而且GC時需要停止應用程式,這會導致非常差的使用者體驗。
2、空間問題。標記清除之後會產生大量不連續的記憶體碎片(從上圖可以看出),記憶體空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾回收動作。
既然標記/清除演算法有這麼多的缺點,那它還有存在的意義嗎?別急,一個演算法有缺陷,人們肯定會想辦法去完善它,接下來的兩個演算法就是在標記/清除演算法的基礎上完善而來的。
複製演算法
為了解決效率問題,複製演算法出現了。複製演算法的原理是:將可用記憶體按容量劃分為大小相等的兩塊,每次使用其中的一塊。當這一塊的記憶體用完了,就將還存活的物件複製到另一塊記憶體上,然後把這一塊記憶體所有的物件一次性清理掉。用圖說明如下:
回收前:
回收後:
複製演算法每次都是對整個半區進行記憶體回收,這樣就減少了標記物件遍歷的時間,在清除使用區域物件時,不用進行遍歷,直接清空整個區域記憶體,而且在將存活物件複製到保留區域時也是按地址順序儲存的,這樣就解決了記憶體碎片的問題,在分配物件記憶體時不用考慮記憶體碎片等複雜問題,只需要按順序分配記憶體即可。
複製演算法簡單高效,優化了標記/清除演算法的效率低、記憶體碎片多的問題。但是它的缺點也很明顯:
1、將記憶體縮小為原來的一半,浪費了一半的記憶體空間,代價太高;
2、如果物件的存活率很高,極端一點的情況假設物件存活率為100%,那麼我們需要將所有存活的物件複製一遍,耗費的時間代價也是不可忽視的。
基於以上覆制演算法的缺點,由於新生代中的物件幾乎都是“朝生夕死”的(達到98%),現在的商業虛擬機器都採用複製演算法來回收新生代。由於新生代的物件存活率低,所以並不需要按照1:1的比例來劃分記憶體空間,而是將記憶體分為一塊較大的Eden空間和兩塊較小的From Survivor空間、To Survivor空間,三者的比例為8:1:1。每次使用Eden和From Survivor區域,To Survivor作為保留空間。GC開始時,物件只會存在於Eden區和From Survivor區,To Survivor區是空的。GC進行時,Eden區中所有存活的物件都會被複制到To Survivor區,而在From Survivor區中,仍存活的物件會根據它們的年齡值決定去向,年齡值達到年齡閥值(預設為15,新生代中的物件每熬過一輪垃圾回收,年齡值就加1)的物件會被移到老年代中,沒有達到閥值的物件會被複制到To Survivor區。接著清空Eden區和From Survivor區,新生代中存活的物件都在To Survivor區。接著, From Survivor區和To Survivor區會交換它們的角色,也就是新的To Survivor區就是上次GC清空的From Survivor區,新的From Survivor區就是上次GC的To Survivor區,總之,不管怎樣都會保證To Survivor區在一輪GC後是空的。GC時當To Survivor區沒有足夠的空間存放上一次新生代收集下來的存活物件時,需要依賴老年代進行分配擔保,將這些物件存放在老年代中。
標記/整理演算法
複製演算法在物件存活率較高時要進行較多的複製操作,效率會變得很低,更關鍵的是,如果不想浪費50%的記憶體空間,就需要有額外的記憶體空間進行分配擔保,以應對記憶體中物件100%存活的極端情況,因此,在老年代中由於物件的存活率非常高,複製演算法就不合適了。根據老年代的特點,高人們提出了另一種演算法:標記/整理演算法。從名字上看,這種演算法與標記/清除演算法很像,事實上,標記/整理演算法的標記過程任然與標記/清除演算法一樣,但後續步驟不是直接對可回收物件進行回收,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊線以外的記憶體。
回收前:
回收後:
可以看到,回收後可回收物件被清理掉了,存活的物件按規則排列存放在記憶體中。這樣一來,當我們給新物件分配記憶體時,jvm只需要持有記憶體的起始地址即可。標記/整理演算法不僅彌補了標記/清除演算法存在記憶體碎片的問題,也消除了複製演算法記憶體減半的高額代價,可謂一舉兩得。但任何演算法都有缺點,就像人無完人,標記/整理演算法的缺點就是效率也不高,不僅要標記存活物件,還要整理所有存活物件的引用地址,在效率上不如複製演算法。
弄清了以上三種演算法的原理,下面我們來從幾個方面對這幾種演算法做一個簡單排行。
效率:複製演算法 > 標記/整理演算法 > 標記/清除演算法(標記/清除演算法有記憶體碎片問題,給大物件分配記憶體時可能會觸發新一輪垃圾回收)
記憶體整齊率:複製演算法 = 標記/整理演算法 > 標記/清除演算法
記憶體利用率:標記/整理演算法 = 標記/清除演算法 > 複製演算法
從上面簡單的評估可以看出,標記/清除演算法已經比較落後了,但是吃水不忘挖井人,它是後面幾種演算法的前輩、是基礎,在某些場景下它也有用武之地。
終極演算法 —— 分代收集演算法
當前商業虛擬機器都採用分代收集演算法,說它是終極演算法,是因為它結合了前幾種演算法的優點,將演算法組合使用進行垃圾回收,與其說它是一種新的演算法,不如說它是對前幾種演算法的實際應用。分代收集演算法的思想是按物件的存活週期不同將記憶體劃分為幾塊,一般是把Java堆分為新生代和老年代(還有一個永久代,是HotSpot特有的實現,其他的虛擬機器實現沒有這一概念,永久代的收集效果很差,一般很少對永久代進行垃圾回收),這樣就可以根據各個年代的特點採用最合適的收集演算法。
新生代:朝生夕滅,存活時間很短。
老年代:經過多次Minor GC而存活下來,存活週期長。
在新生代中每次垃圾回收都發現有大量的物件死去,只有少量存活,因此採用複製演算法回收新生代,只需要付出少量物件的複製成本就可以完成收集;而老年代中物件的存活率高,不適合採用複製演算法,而且如果老年代採用複製演算法,它是沒有額外的空間進行分配擔保的,因此必須使用標記/清理演算法或者標記/整理演算法來進行回收。
總結一下就是,分代收集演算法的原理是採用複製演算法來收集新生代,採用標記/清理演算法或者標記/整理演算法收集老年代。
以上內容介紹了幾種收集演算法的原理、優缺點以及使用場景,它們的共同點是:當GC執行緒啟動時(即進行垃圾收集),應用程式都要暫停(Stop The World)。理解了這些知識,為我們研究垃圾收集器的執行原理打下了基礎。以上是我個人學習的一點總結,歡迎交流學習。