版權聲明:本文為博主原創文章,轉載請注明出處,歡迎交流學習!
在前面的文章里介紹了可達性分析算法,它為我們解決了判定哪些對象可以回收的問題,接下來就該我們的垃圾收集算法出場了。不同的垃圾收集算法有各自不同的優缺點,在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)。理解了這些知識,為我們研究垃圾收集器的運行原理打下了基礎。以上是我個人學習的一點總結,歡迎交流學習。