JVM之垃圾回收-垃圾收集演算法
如何判斷物件是否存活
在Java中,如何回收物件,第一步,肯定是需要知道物件是否還有引用,是否還存活的。這樣,JVM才能進行下一步的操作。判斷物件是否還存活有著如下兩種演算法:引用計數演算法與可達性分析演算法。
引用計數演算法
給每一個物件都新增一個引用計數器,每當有一個地方引用該物件,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是不可能再被使用的。
引用計數演算法的優點是實現簡單,判定效率也高。缺點是它很難解決物件之間相互迴圈引用的問題。所以目前主流的虛擬機器中都沒有使用該演算法來管理記憶體,Java虛擬機器當然也並沒有使用該演算法判斷物件是否應該回收。
可達性分析(GC Roots Tracing)演算法
這個演算法的基本思想就是通過一系列的稱為“GC Roots” 的物件作為起點,從這些節點開始向下搜尋,節點所走過的路徑稱為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連的話,則證明此物件是不可用的,也就是可以回收的物件。 (可作為GC Roots的物件包括:虛擬機器棧中(棧幀中的本地變量表)引用的物件、方法區中類靜態屬性引用的物件、方法區中常量引用的物件、本地方法棧中JNI(native方法)引用的物件。)
效率
採用引用計數演算法的系統只需在每個例項物件建立之初,通過計數器來記錄所有的引用次數即可。而可達性演算法,則需要再次GC時,遍歷整個GC根節點來判斷是否回收。(所以引用計數回收的效率比可達性演算法效率高)
物件之間相互迴圈引用的問題
看一個簡單的例子:
public class GcDemo {
public static void main(String[] args) {
//分為6個步驟
GcObject obj1 = new GcObject(); //Step 1
GcObject obj2 = new GcObject(); //Step 2
obj1.instance = obj2; //Step 3
obj2.instance = obj1; //Step 4
obj1 = null; //Step 5
obj2 = null; //Step 6
}
}
class GcObject{
public Object instance = null;
}
如果採用引用計數演算法,上述程式碼中obj1和obj2指向的物件已經不可能再被訪問,彼此互相引用對方導致引用計數都不為0,最終無法被GC回收,而可達性演算法能解決這個問題。
接下來進行分析為什麼
使用引用計數演算法
如果採用的是引用計數演算法:
再回到前面程式碼GcDemo的main方法共分為6個步驟:
- Step1:GcObject例項1的引用計數加1,物件例項1的引用計數=1;
- Step2:GcObject例項2的引用計數加1,物件例項2的引用計數=1;
- Step3:GcObject例項2的引用計數再加1,物件例項2的引用計數=2;
- Step4:GcObject例項1的引用計數再加1,物件例項1的引用計數=2; 執行到Step 4,則GcObject例項1和例項2的引用計數都等於2。 如圖,在虛擬機器棧中有obj1和obj2物件的引用
接下來繼續 5. Step5:棧幀中obj1不再指向Java堆,GcObject例項1的引用計數減1,結果為1; 6. Step6:棧幀中obj2不再指向Java堆,GcObject例項2的引用計數減1,結果為1。 到此,發現GcObject例項1和例項2的計數引用都不為0,那麼如果採用的引用計數演算法的話,那麼這兩個例項所佔的記憶體將得不到釋放,這便產生了記憶體洩露。
使用可達性演算法
看下面的這個圖:
物件例項1、2、4、6都具有GC Roots可達性,也就是存活物件,不能被GC回收的物件。而對於物件例項3、5直接雖然連通,但並沒有任何一個GC Roots與之相連,這便是GC Roots不可達的物件,這就是GC需要回收的垃圾物件。
回到前面的那個程式碼,當執行完Step 5和Step 6時,obj1和obj2作為GC Roots都指向null,物件例項1與物件例項2都脫離了GC Roots,沒有GC Roots鏈指向,因此,從可達性演算法來看,都是GC Roots不可達的物件。
所以,對於物件之間迴圈引用的情況,使用引用計數演算法,GC是無法回收掉物件的,而使用可達性演算法可以正確回收。(這也是為什麼C++容易發生記憶體洩露,而Java不容易發生的原因)
Java引用的四種狀態
JDK1.2以後,Java對於引用進行了擴充,將引用繼續細分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)四種(引用強度逐漸減弱)
強引用(Strong Reference)
強引用一般指的就是new出來的物件(反射出來使用的物件也屬於強引用),這是使用最普遍的引用。
只要某個物件有強引用與之關聯,JVM必定不會回收這個物件,即使在記憶體不足的情況下,JVM寧願丟擲OutOfMemory錯誤也不會回收這種物件。
如果想中斷強引用和某個物件之間的關聯,可以顯示地將引用賦值為null,這樣一來的話,JVM在合適的時間就會回收該物件。
軟引用(Soft Reference)
軟引用是用來描述一些有用但並不是必需的物件,在Java中用java.lang.ref.SoftReference類來表示。對於軟引用關聯著的物件,只有在記憶體不足的時候JVM才會回收該物件。因此,這一點可以很好地用來解決OOM的問題,並且這個特性很適合用來實現快取:比如網頁快取、圖片快取等。
軟引用可以和一個引用佇列(ReferenceQueue)聯合使用,如果軟引用所引用的物件被JVM回收,這個軟引用就會被加入到與之關聯的引用佇列中。
如下面的程式碼使用 SoftReference 類來建立軟引用。
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使物件只被軟引用關聯
弱引用(Weak Reference)
弱引用也是用來描述非必需物件的,當JVM進行垃圾回收時,無論記憶體是否充足,都會回收被弱引用關聯的物件(也就是說它只能存活到下一次垃圾收集發生之前)。在java中,用java.lang.ref.WeakReference類來表示。 使用 WeakReference 類來實現弱引用。
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null; // 使物件只被弱引用關聯
java.util中的WeakHashMap通常用來實現快取,該類中的Entry繼承自WeakReference。
private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V>
Tomcat 中的 ConcurrentCache 就使用了 WeakHashMap 來實現快取功能。ConcurrentCache 採取的是分代快取,經常使用的物件放入 eden 中,而不常用的物件放入 longterm。eden 使用 ConcurrentHashMap 實現,longterm 使用 WeakHashMap,保證了不常使用的物件容易被回收。 原始碼如下,挺好理解
public final class ConcurrentCache<K, V> {
private final int size;
private final Map<K, V> eden;
private final Map<K, V> longterm;
public ConcurrentCache(int size) {
this.size = size;
this.eden = new ConcurrentHashMap<>(size);
this.longterm = new WeakHashMap<>(size);
}
public V get(K k) {
V v = this.eden.get(k);
if (v == null) {
v = this.longterm.get(k);
if (v != null)
this.eden.put(k, v);
}
return v;
}
public void put(K k, V v) {
if (this.eden.size() >= size) {
this.longterm.putAll(this.eden);
this.eden.clear();
}
this.eden.put(k, v);
}
}
虛引用(Phantom Reference)
又稱為幽靈引用或者幻影引用。一個物件是否有虛引用的存在,完全不會對物件的生命週期構成影響,也無法通過虛引用取得一個物件例項。
為一個物件設定虛引用關聯的唯一目的就是能在這個物件被收集器回收時收到一個系統通知。 使用 PhantomReference 來實現虛引用。
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null; //使該物件只有虛引用
對於強引用,我們平時在編寫程式碼時經常會用到。而對於其他三種類型的引用,使用得最多的就是軟引用和弱引用,這2種既有相似之處又有區別。 它們都是用來描述非必需物件的,但是被軟引用關聯的物件只有在記憶體不足時才會被回收,而被弱引用關聯的物件在JVM進行垃圾回收時總會被回收。
針對上面的特性,軟引用適合用來進行快取,當記憶體不夠時能讓JVM回收記憶體。 弱引用可以用來在回撥函式中防止記憶體洩露。
因為回撥函式往往是匿名內部類,隱式儲存有對外部類的引用,所以如果回撥函式是在另一個執行緒裡面被回撥,而這時如果需要回收外部類,那麼就會記憶體洩露,因為匿名內部類儲存有對外部類的強引用。
什麼情況下回收物件
即使是在可行性分析演算法中不可達的物件,也並不是立即回收的。至少要經歷兩次標誌過程,才真正宣告該物件"死亡";
可達性分析演算法中不可達的物件被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行finalize方法。
當物件沒有覆蓋finalize方法,或finalize方法已經被虛擬機器呼叫過時,虛擬機器將這兩種情況視為沒有必要執行finalize方法。被判定為需要執行的物件將會被放在一個佇列中進行第二次標記,除非這個物件與引用鏈上的任何一個物件建立關聯,否則就會被真的回收。
所以,一般情況下,不建議重寫finalize方法。會影響物件回收的效能。
回收方法區
方法區(或Hotspot虛擬中的永久代)的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。
回收廢棄常量與回收Java堆中的物件非常相似。以常量池中字串的回收為例,若字串“abc”已經進入常量池中,但當前系統沒有任何String物件引用常量池中的“abc”常量,也沒有其他地方引用該字面量,若發生記憶體回收,且必要的話,該“abc”就會被系統清理出常量池。常量池中其他的類(介面)、方法、欄位的符號引用與此類似。
判定一個常量是否是“廢棄常量”比較簡單,而要判定一個類是否是“無用的類”的條件則相對苛刻許多。類需要同時滿足下面3個條件才能算是 “無用的類”:
- 該類所有的例項都已經被回收,也就是Java堆中不存在該類的任何例項。
- 載入該類的ClassLoader已經被回收。
- 該類對應的java.lang.Class物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。 虛擬機器可以對滿足上述3個條件的無用類進行回收,此處僅僅是“可以”,而並不是和物件一樣(不使用了就必然回收)
垃圾收集演算法
標記-清除演算法(Mark and Sweep)
演算法分為“標記”和“清除”階段。
網上有些文章是這樣介紹的:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件。(錯誤,顛倒了概念)
正確的概念是:首先從根開始將可能被引用的物件用遞迴的方式進行標記,然後將沒有標記到的物件作為垃圾進行回收。
圖一
圖一顯示了標記清除演算法的大致原理。圖中的(1)部分顯示了隨著程式的執行而分配出一些物件的狀態,一個物件可以對其他的物件進行引用。
圖中(2)部分中,GC開始執行,從根開始對可能被引用的物件打上“標記”。大多數情況下,這種標記是通過物件內部的標誌(Flag)來實現的。
於是,被標記的物件我們把它們塗黑。圖中(3)部分中,被標記的物件所能夠引用的物件也被打上標記。
重複這一步驟的話,就可以將從根開始可能被間接引用到的物件全部打上標記。到此為止的操作,稱為標記階段(Mark phase)。
標記階段完成時,被標記的物件就被視為“存活”物件。圖1中的(4)部分中,將全部物件按順序掃描一遍,將沒有被標記的物件進行回收。這一操作被稱為清除階段(Sweep phase)。
在掃描的同時,還需要將存活物件的標記清除掉,以便為下一次GC操作做好準備。標記清除演算法的處理時間,是和存活物件數與物件總數的總和相關的。
缺點
它是最基礎的收集演算法,但是會帶來兩個明顯的問題;
- 標記和清除的過程效率不高(由於空閒區塊是用連結串列實現,分塊可能都不連續,每次分配都需要遍歷空閒連結串列,極端情況是需要遍歷整個連結串列的)
- 空間問題(標記清除後會產生大量不連續的碎片)
- 與寫時複製技術不相容
寫時複製
寫時複製(copy-on-write)是眾多 UNIX 作業系統用到的記憶體優化的方法。比如在 Linux 系統中使用 fork() 函式複製程序時,大部分記憶體空間都不會被複制,只是複製程序,只有在記憶體中內容被改變時才會複製記憶體資料。 但是如果使用標記清除演算法,這時記憶體會被設定標誌位,就會頻繁發生不應該發生的複製。
另外,關於標記清除的變形,還有一種叫做標記壓縮(Mark and Compact)的演算法,它不是將被標記的物件清除,而是將它們不斷壓縮。
複製收集演算法(Copy and Collection)
標記清除演算法有一個缺點,就是在分配了大量物件,並且其中只有一小部分存活的情況下,所消耗的時間會大大超過必要的值,這是因為在清除階段還需要對大量死亡物件進行掃描。
複製收集(Copy and Collection)則試圖克服這一缺點。在這種演算法中,會將從根開始被引用的物件複製到另外的空間中,然後,再將複製的物件所能夠引用的物件用遞迴的方式不斷複製下去。
簡單的說:它可以將記憶體分為大小相同的兩塊,每次使用其中的一塊。當這一塊的記憶體使用完後,就將還存活的物件複製到另一塊去,然後再把使用的空間一次清理掉。這樣就使每次的記憶體回收都是對記憶體區間的一半進行回收。 圖二
圖二中的(1)部分是GC開始前的記憶體狀態,這和圖一的(1)部分是一樣的。
圖二的(2)部分中,在舊物件所在的“舊空間”以外,再準備出一塊“新空間”,並將可能從根被引用的物件複製到新空間中。 圖中(3)部分中,從已經複製的物件開始,再將可以被引用的物件像一串糖葫蘆一樣複製到新空間中。複製完成之後,“死亡”物件就被留在了舊空間中。
圖中(4)部分中,將舊空間廢棄掉,就可以將死亡物件所佔用的空間一口氣全部釋放出來,而沒有必要再次掃描每個物件。下次GC的時候,現在的新空間也就變成了將來的舊空間。
通過圖二我們可以發現,複製收集方式中,只存在相當於標記清除方式中的標記階段。由於清除階段中需要對現存的所有物件進行掃描,在存在大量物件,且其中大部分都即將死亡的情況下,全部掃描一遍的開銷實在是不小。而在複製收集方式中,就不存在這樣的開銷。
但是,和標記相比,將物件複製一份所需要的開銷則比較大,因此在“存活”物件比例較高的情況下,反而會比較不利(極端情況下,實際使用的記憶體效率只有50%)。
優點
1. 優秀的吞吐量
GC標記-清除演算法消耗的吞吐量是搜尋活動物件(標記階段)所花費的時間和搜尋整體堆(清除階段)所花費的時間之和。
另一方面,因為GC複製演算法只搜尋並複製活動物件,所以跟一般的GC標記-清除演算法相比,它能在短時間內完成GC,也就是說其吞吐量優秀。
尤其是堆越大,差距越明顯。GC標記-清除演算法在清除階段所花費的時間會不斷增加,但GC複製演算法就不會。因為它消耗的時間是與活動物件的數量成比例的。
2.可實現高速分配
GC複製演算法不使用空閒連結串列,因為分塊是一塊連續的記憶體空間。因此,調查這個分塊的大小,只要這個分塊大小不小於所申請的大小,那麼移動指標就可以進行分配了。
比起GC標記-清除演算法和引用計數演算法等使用空閒連結串列的分配,GC複製演算法明顯快得多。使用空閒連結串列是為了找到滿足要求的分塊,需要遍歷空閒連結串列,最壞的情況是我們不得不從空閒連結串列中取出最後一個分塊,這樣就用了大量時間把所有分塊都調查一遍。
3.不會發生碎片化
基於演算法性質,活動物件被集中安排在From空間的開頭。像這樣把物件重新集中,放在堆中一端的行為叫作壓縮。在GC複製演算法中,每次執行GC時都會執行壓縮。
因此GC演算法有個非常優秀的特點,就是不會發生碎片化,也就是說可以安排分塊允許範圍內大小的物件。
另一方面,在GC標記-清除演算法等GC演算法中,一旦安排了物件,原則上就不能再移動它了,所以會多多少少產生碎片化。
4.與快取相容
複製演算法具有區域性性(Lo-cality)。在複製收集過程中,會按照物件被引用的順序將物件複製到新空間中。
於是,關係較近的物件被放在距離較近的記憶體空間中的可能性會提高,這被稱為區域性性。
在區域性性高的情況下,記憶體快取會更容易有效運作,程式的執行效能也能夠得到提高。
缺點
1.堆使用率低下
GC複製演算法把堆分成二等分,通常只能利用其中一半來安排物件。也就是說只有一半堆能被使用,相比其他能使用整個堆的GC演算法而言,這是GC複製演算法的一個重大缺陷。
詳細介紹: 現在的商業虛擬機器都採用這種收集演算法來回收新生代,新生代中的物件98%都是“朝生夕死”的,所以並不需要按照1:1的比例來劃分記憶體空間,而是將記憶體分為一塊比較大的Eden Space(伊甸園)空間和兩塊較小的Survivor Space(倖存者區)空間,每次使用Eden和其中一塊Survivor。
當回收時,將Eden和Survivor中還存活著的物件一次性地複製到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor空間。HotSpot虛擬機器(Java虛擬機器的一個實現)預設Eden和Survivor的大小比例是8:1,也就是說,每次新生代中可用記憶體空間為整個新生代容量的90%(80%+10%),只有10%的空間會被浪費(也就是說,至少會浪費10%的空間)。
當然,98%的物件可回收只是一般場景下的資料,我們沒有辦法保證每次回收都只有不多於10%的物件存活,當Survivor空間不夠用時,需要依賴於老年代進行分配擔保,所以大物件直接進入老年代。
2.不相容保守式GC演算法
GC標記-清除演算法有著跟保守式GC演算法相相容的優點。因為GC標記-清除演算法不用移動物件。 另一方面,GC複製演算法必須移動物件重寫指標,所以有著跟保守式GC演算法不相容的性質。雖然有限制條件,GC複製演算法和保守式GC演算法可以進行組合。
保守式GC
簡單的說,就是不能識別指標和非指標的GC
把不能識別指標還是非指標的物件當做指標來保守處理,也就是當成活動物件保留下
3.遞迴呼叫函式
在演算法中,複製某個物件時要遞迴複製它的子物件,因此在每次進行復制的時候都要呼叫函式,由此帶來的額外負擔不容忽視。比起遞迴演算法,迭代演算法更能有效地執行。 此外,因為在每次遞迴呼叫時都會消耗棧,所以還有棧溢位的可能。
實際應用
對賬系統就非常適合使用複製收集演算法進行垃圾回收,因為物件存活率不高,且在對賬期間會產生大量的物件,也就是分配速率非常大,峰值幾千MB/S
標記-整理演算法
複製演算法對於物件存活率很低的情況是高效的,但是當物件的存活率非常高時,就變得非常低效了。在老年代中,物件的存活率很高,所以不能使用複製演算法。於是根據老年代的物件特點,提出了標記-整理(Mark-Compact)演算法。 標記-整理演算法也分為兩個階段:標記和整理。 第一個階段與標記-清除演算法一樣:標記出所有可以被回收的物件。 第二個階段不再是簡單的清除無用物件的空間,而是將後面的活著的物件依次向前移動。將所有的活著的物件都移動成記憶體空間中前段連續一個區域,之後的連續的區域都是可分配的沒有使用的記憶體空間。 如下圖所示:
優點
不會產生記憶體碎片。
缺點
在標記的基礎之上還需要進行物件的移動,成本相對較高,效率也不高。
垃圾收集演算法比較
- 效率:複製演算法 > 標記/整理演算法 > 標記/清除演算法(此處的效率只是簡單的對比時間複雜度,實際情況不一定如此)。
- 記憶體整齊度:複製演算法=標記/整理演算法>標記/清除演算法。
- 記憶體利用率:標記/整理演算法=標記/清除演算法>複製演算法。 (>表示前者要優於後者,=表示兩者效果一樣)
注1:標記-整理演算法不僅可以彌補標記-清除演算法當中,記憶體區域分散的缺點,也消除了複製演算法當中,記憶體減半的高額代價。 注2:可以看到標記/清除演算法是比較落後的演算法了,但是後兩種演算法卻是在此基礎上建立的。 注3:時間與空間不可兼得。
分代收集演算法
當前商業虛擬機器中一般採用“分代收集演算法”。分代收集演算法是根據物件的特點將記憶體空間分成不同的區域(即不同的代),對每個區域使用合適的收集演算法。 在JVM中一般分為新生代和老年代,新生代中物件的存活率比較低,使用複製演算法簡單高效;在老年代中,由於物件的存活率較高,所以一般採用標記-整理演算法。 簡單的理解:
- 存活率低:少量物件存活,適合複製演算法
- 存活率高:大量物件存活,適合用標記-清理/標記-整理演算法 注:老年代的物件中,有一小部分是因為在新生代回收時,老年代做擔保,進來的物件;絕大部分物件是因為很多次GC都沒有被回收掉而進入老年代。
小結
沒有最好的演算法,只有最適合的演算法。 每一種演算法的存在必然有其使用場景和適合的地方。
本系列中涉及到的所有相關名詞, 我會專門整理出一篇文章作為說明與解釋。 本篇主要是講解基礎知識,在下一篇將會講解垃圾收集器(也就是垃圾收集演算法的實際應用&實現)。
為什麼會有本系列文章: 原本打算是直接出對賬二期的文章,畢竟是千萬級訂單專案的實戰與經驗。但是由於近期會在部門內進行分享JVM的垃圾回收以及調優,所以該分享的準備資料我也就寫成文章形式先進行分享了。後面抽時間再寫對賬二期的文章。