JVM——四種垃圾收集演算法詳解
之前幾篇部落格介紹了記憶體模型以及判斷物件是否存活的兩種演算法,當一個物件死亡的時候,就要被當做垃圾回收。那麼今天我們就來了解一下垃圾收集演算法,看看都是怎麼將這些死亡的物件給回收了去。
目前主要的垃圾收集演算法有四種,分別是標記-清除演算法、複製演算法、標記整理演算法以及分代收集演算法。下面我們就來看看這四種演算法都是啥。
1.標記-清除演算法
標記-清除演算法是最基礎的收集演算法。從它的名字我們可以看出來,這個演算法分為“標記”和“清除”兩個階段。
首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件。 |
之所以說它是最基礎的演算法,那是因為後面要介紹的幾種演算法都是基於這種演算法的思路並對其不足進行改進而得到的。
那它有什麼不足呢?它的主要不足有兩個:
標記-清除演算法的執行過程如下圖所示:
- 一是效率問題,標記和清除兩個過程的效率都不高;
- 二是空間問題,標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配大物件的時候,無法找到足夠的連續記憶體而不得不提前觸發下一次垃圾收集動作(這可划不來,畢竟垃圾收集的開銷很大)。
從上圖我們可以發現,通過標記-清除進行垃圾收集之後,會產生很多的零散的空間,這不利於很多後續操作。
而為了解決效率問題,“複製演算法”騰空而出。
2.複製演算法
複製演算法是為了解決標記-清除演算法的效率問題而出現的。
複製演算法將可用記憶體按照容量劃分為大小相等的兩塊,每一次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另一塊記憶體上去,然後再把已經使用過的記憶體空間一次清理掉。 |
複製演算法使得每次都是對整個半區進行記憶體回收,這樣的話在記憶體分配時也就不必想標記-清除演算法那樣,考慮記憶體碎片等複雜情況啦。只要移動堆頂指標,按照順序分配記憶體就能完事。
如圖:
Survivor Space 是兩塊等大的記憶體空間。
當使用記憶體A的時候,B不使用。當需要進行GC的時候,將記憶體A中仍然存活的物件複製到B中,然後再將A中的所有使用過的記憶體都回收。下一次則使用記憶體B,當要GC的時候,再將B中仍然存活的物件複製到A中,再將B中使用過的記憶體回收,不斷輪換。
這種演算法的實現簡單,執行高效,但也有代價,它將記憶體縮小到原來的一半,這著實有些高了。
下面是複製演算法的執行過程。
也許看圖不太明白,那麼再看兩張圖吧~
當複製演算法的GC執行緒處理之後,兩個區域會變成什麼樣子呢?如下所示。
可以看到,1和4號物件被清除了,而2、3、5、6號物件則是規則的排列在剛才的空閒區間,也就是現在的活動區間之內。此時左半部分已經變成了空閒區間,不難想象,在下一次GC之後,左邊將會再次變成活動區間。
很明顯,複製演算法彌補了標記/清除演算法中,記憶體佈局混亂的缺點。不過與此同時,它的缺點也是相當明顯的。
上文說到,複製演算法將記憶體分為兩份,則每次可使用的記憶體只有原來的一半,這樣不太好。所以現在大部分的商用虛擬機器都採用這種收集演算法來回收新生代。
由於新生代中的物件都是朝生夕死的(IBM公司的專門研究表明新生代中的物件98%都是“朝生夕死”的),所以並不需要按照1:1的比例來劃分記憶體空間,而是江記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。當回收的時候則將Eden和Survivor中還存活著的物件一次性地複製到另外一個Survivor空間上,最後再清理掉Eden和剛才用過的Survivor空間。
我們常用的HotSpot虛擬機器預設Eden和Survivor的大小比例是8:1,也就是說,每次新生代中可用記憶體空間為整個新生代容量的90%(即80%+10%) ,只有10%的記憶體會被浪費。
當然啦,這只是針對大多數情況下的安排,我們沒有辦法保證每次回收都只有不多於10%的物件存活,當Survivor空間不夠用的時候,需要依賴老年代進行分配擔保。
什麼叫分配擔保呢?它就好比我們去銀行借小錢錢,如果我們信譽很好,比如本帥博主這樣的,那麼在98%的情況下都能夠按時償還,所以銀行可能會預設我們下一次也能夠按時償還貸款。但是這樣的話銀行要承受的風險還是很大,萬一本帥博主哪一天攜著鉅款跑路呢?所以還需要一個擔保人,當本帥博主不能夠按時償還貸款的時候,可以從他的賬戶里扣錢,那麼這樣的話銀行就幾乎沒有風險啦。
記憶體的分配擔保也是這個樣子,如果另外一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活物件,那麼這些物件將直接通過分配擔保機制直接進入老年代。這個時候,就不關新生代的事了,而是需要老年代煩惱了。
但上文我們說到,複製演算法是為了提升標記-清除演算法效率才有的演算法,在大部分情況下,複製演算法適用於新生代的垃圾收集,但是當物件存活率比較高的時候它就需要進行很多的複製操作,這樣一來效率也低了。
更關鍵的是,如果我們不想浪費50%的空間,就需要有額外的空間進行擔保,以應對使用的記憶體中所有物件都100%存活的極端情況。
由此看來,複製演算法不適合老年代。怎麼辦呢?還是用標記-整理演算法吧。
3.標記-整理演算法
對於老年代來說,它們的存活時間長,而且佔據的記憶體常常較大,這樣的該怎麼辦呢?
複製演算法顯然不適用於老年代的物件,難道用最基礎的標記-清除演算法咩?
我們知道,標記-清除演算法會在垃圾回收之後留下很多碎片空間,而咱們老年代的物件往往佔據的記憶體較大,如果用標記-清除演算法的話恐怕會經常找不到足夠大的連續記憶體空間,因此要提前觸發下一次垃圾收集,這樣的消耗顯然太大。那怎麼辦呢?
根據老年代的特點,有人提出了“標記-整理”演算法。
顯然,“標記-整理”演算法也是從“標記-清除”演算法的基礎上改進的,它們的標記過程一樣,只是“標記-整理”演算法的後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後再清理掉端邊界以外的記憶體,即“先移動後清除”。
“標記-清除”演算法的示意圖如下:
具體流程時什麼樣的呢?
GC前記憶體中物件的狀態與佈局,如下圖所示。
標記階段過後物件的狀態,如下圖。
我們來看當整理階段處理完以後,記憶體的佈局是如何的,如下圖。
我們可以看到,標記的存活物件將會被整理,按照記憶體地址依次排列(即物件在移動的時候,其記憶體地址會重新分配),而未被標記的記憶體會被清理掉。如此一來,當我們需要給新物件分配記憶體時,JVM只需要持有一個記憶體的起始地址即可,這比維護一個空閒列表顯然少了許多開銷。
不難看出,標記-整理演算法不僅可以彌補標記/清除演算法當中,記憶體區域分散的缺點,也消除了複製演算法當中,記憶體減半的高額代價。不過任何演算法都會有其缺點,標記-整理演算法唯一的缺點就是效率也不高,不僅要標記所有存活物件,還要整理所有存活物件的引用地址。從效率上來說,標記-整理演算法要低於複製演算法。
4.三種演算法的異同點總結
共同點:
- 三個演算法都基於根搜尋演算法去判斷一個物件是否應該被回收,而支撐根搜尋演算法可以正常工作的理論依據,就是語法中變數作用域的相關內容。因此,要想防止記憶體洩露,最根本的辦法就是掌握好變數作用域,而不應該使用前面記憶體管理雜談一章中所提到的C/C++式記憶體管理方式。
- 在GC執行緒開啟時,或者說GC過程開始時,它們都要暫停應用程式(stop the world)。
不同點:
- 效率不同:複製演算法>標記-整理演算法>標記-清除演算法(此處的效率只是簡單的對比時間複雜度,實際情況不一定如此)。
- 記憶體整齊度不同:複製演算法=標記-整理演算法>標記-清除演算法。
- 記憶體利用率不同:標記/整理演算法=標記-清除演算法>複製演算法。
看完這三種演算法的異同點,我們會發現,它們似乎針對於不同的記憶體區域有著各自的強大力量。為了讓垃圾收集得更好,分代收集演算法就出來了。
5.分代收集演算法
當前商業虛擬機器的垃圾收集都採用的是“分代收集”演算法。
這種演算法其實並沒有什麼新的思想,它只不過是根據物件存活皺起的不同而將記憶體劃分為了多個塊。
一般是將Java堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。
在新生代中,由於每次垃圾收集都發現有大量的物件死去,物件存活率很低,因此採用複製演算法,這樣只需要付出少量存活物件的複製成本就可以完成收集;而在老年代中,由於物件存活率很高,而且沒有額外的空間對它們進行分配擔保,因此必須使用“標記-清理”或者“標記-整理”演算法來進行回收。(一般是用標記-整理演算法。)
好啦,以上就是關於四種垃圾收集演算法的相關知識總結啦,如果大家有什麼不明白的地方或者發現文中有描述不好的地方,歡迎大家留言評論,我們一起學習呀。
Biu~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~pia!