1. 前言 Java和C++之間顯著的一個區別就是對記憶體的管理。和C++把記憶體管理的權利賦予給開發人員的方式不同,Java擁有一套自動的記憶體回收系統(Garbage Collection,GC)簡稱GC,可以無需開發人員干預而對不再使用的記憶體進行回收管理。 垃圾回收技術(以下簡稱GC)是一套自動的內 ...
1. 前言
Java和C++之間顯著的一個區別就是對記憶體的管理。和C++把記憶體管理的權利賦予給開發人員的方式不同,Java擁有一套自動的記憶體回收系統(Garbage Collection,GC)簡稱GC,可以無需開發人員干預而對不再使用的記憶體進行回收管理。
垃圾回收技術(以下簡稱GC)是一套自動的記憶體管理機制。當電腦系統中的記憶體不再使用的時候,把這些空閑的記憶體空間釋放出來重新投入使用,這種記憶體資源管理的機制就稱為垃圾回收。
其實GC並不是Java的專利,GC的的發展歷史遠比Java來得久遠的多。早在Lisp語言中,就有GC的功能,包括其他很多語言,如:Python(其實Python的歷史也比Java早)也具有垃圾回收功能。
使用GC的好處,可以把這種容易犯錯的行為讓給電腦系統自己去管理,可以防止人為的錯誤。同時也把開發人員從記憶體管理的泥沼中解放出來。
雖然使用GC雖然有很多方便之處,但是如果不瞭解GC機制是如何運作的,那麼當遇到問題的時候,我們將會很被動。所以有必要學習下Java虛擬機中的GC機制,這樣我們才可以更好的利用這項技術。當遇到問題,比如記憶體泄露或記憶體溢出的時候,或者垃圾回收操作影響系統性能的時候,我們可以快速的定位問題,解決問題。
接下來,我們來看下JVM中的GC機制是怎麼樣的。
2. 哪些記憶體可以回收
首先,我們如果要進行垃圾回收,那麼我們必須先要識別出哪些是垃圾(被占用的無用記憶體資源)。
Java虛擬機將記憶體劃分為多個區域,分別做不同的用途。簡單的將,JVM對記憶體劃分為這幾個記憶體區域:程式計數器、虛擬機棧、本地方法棧、Java堆和方法區。其中程式計數器、虛擬機棧和本地方法棧是隨著線程的生命周期出生和死亡的,所以這三塊區域的記憶體在程式執行過程中是會有序的自動產生和回收的,我們可以不用關心它們的回收問題。剩下的Java堆和方法區,它們是JVM中所有線程共用的區域。由於程式執行路徑的不確定性,這部分的記憶體分配和回收是動態進行的,GC主要關註這部分的記憶體的回收。
對像實例是否是存活的,有兩種演算法可以用於確定哪些實例是死亡的(它們占用的記憶體就是垃圾),那麼些實例是存活的。第一種是引用計數演算法:
2.1 引用計數演算法
引用計數演算法會對每個對象添加一個引用計數器,每當一個對象在別的地方被引用的時候,它的引用計數器就會加1;當引用失效的時候,它的引用計數器就會減1。如果一個對象的引用計數變成了0,那麼表示這個對象沒有被任何其他對象引用,那麼就可以認為這個對象是一個死亡的對象(它占用的記憶體就是垃圾),這個對象就可以被GC安全地回收而不會導致系統出現問題。
我們可以發現,這種計數演算法挺簡單的。在C++中的智能指針,也是使用這種方式來跟蹤對象引用的,來達到記憶體自動管理的。引用計數演算法實現簡單,而且判斷高效,在大部分情況下是一個很好的垃圾標記演算法。在Python中,就是採用這種方式來進行記憶體管理的。但是,這個演算法存在一個明顯的缺陷:如果兩個對象之間有迴圈引用,那麼這兩個對象的引用計數將永遠不會變成0,即使這兩個對象沒有被任何其他對象引用。
public class ReferenceCountTest { public Object ref = null; public static void main(String ...args) { ReferenceCountTest objA = new ReferenceCountTest(); ReferenceCountTest objB = new ReferenceCountTest(); // 迴圈引用 objA <--> objB objA.ref = objB; objB.ref = objA; // 去除外部對這兩個對象引用 objA = null; objB = null; System.gc(); } }
上面的代碼就演示了兩個對象之間出現迴圈引用的情況。這個時候objA和objB的引用計數都是1,由於兩個對象之間是迴圈引用的,所以它們的引用計數將一直是1,而即使這兩個對象已經不再被系統所使用到。
由於引用計數這種演算法存在這種缺陷,所以就有了一種稱為“可達性分析演算法”的演算法來標記垃圾對象。
2.2 可達性分析演算法
通過可達性分析演算法來判斷對象存活,可以剋服上面提到的迴圈引用的問題。在很多編程語言中都採用這種演算法來判斷對象是否存活。
這種演算法的基本思路是,確定出一系列的稱為“GC Roots”的對象,以這些對象作為起始點,向下搜索所有可達的對象。搜索過程中所走過的路徑稱為“引用鏈”。當一個對象沒有被任何到“GC Roots”對象的“引用鏈”連接的時候,那麼這個對象就是不可達的,這個對象就被認為是垃圾對象。
從上面的圖中可以看出,object1~4這4個對象,對於GC Roots這個對象來說都是可達的。而object5~7這三個對象,由於沒有連接GC Roots的引用鏈,所以這三個對象時不可達的,被判定為垃圾對象,可以被GC回收。
在Java中,可以作為GC Roots的對象有以下幾種:
- 虛擬機棧中的本地變數表中引用的對象,也就是正在執行函數體中的局部變數引用的對象。
- 方法區中類靜態屬性引用的對象
- 方法區中常量引用的對象
- 本地方法棧中(Native方法中)引用的對象
2.3 怎麼判一個對象"死刑"
當通過可達性分析演算法判定為不可達的對象,我們也不能斷定這個對象就是需要被回收的。當我們需要真正回收一個對象的時候,這個對象必須經歷至少兩次標記過程:
當通過可達性分析演算法處理以後,這個對象沒有和GC Roots相連的引用鏈,那麼這個對象就會被第一次標記,並判斷對象的finalize()方法(在Java的Object對象中,有一個finalize()方法,我們創建的對象可以選擇是否重寫這個方法的實現)是否需要執行,如果對象的類沒有覆蓋這個finalize()方法或者finalize()已經被執行過了,那麼就不需要再執行一次該方法了。
如果這個對象的finalize()方法需要被執行,那麼這個對象會被放到一個稱為F-Queue的隊列中,這個隊列會被由Java虛擬機自動創建的一個低優先順序Finalizer線程去消費,去執行(虛擬機只是觸發這個方法,但是不會等待方法調用返回。這麼做是為了保證:如果方法執行過程中出現阻塞,性能問題或者發生了死迴圈,Finalizer線程仍舊可以不受影響地消費隊列,不影響垃圾回收的過程)隊列中的對象的finalize()方法。
稍後,GC會對F-Queue隊列中的對象進行第二次標記,如果在這次標記發生的時候,隊列中的對象確實沒有存活(沒有和GC Roots之間有引用鏈),那麼這個對象就確定會被系統回收了。當然,如果在隊列中的對象,在進行第二次標記的時候,突然和GC Roots之間創建了引用鏈,那麼這個對象就"救活"了自己,那麼在第二次標記的時候,這個存活的對象就被移除出待回收的集合了。所以,通過這種兩次標記的機制,我們可以通過在finalize()方法中想辦法讓對象重新和GC Roots對象建立鏈接,那麼這個對象就可以被救活了。
下麵的代碼,通過在finalize()方法中將this指針賦值給類的靜態屬性來"拯救"自己:
public class FinalizerTest { private static Object HOOK_REF; public static void main(String ...args) throws Exception { HOOK_REF = new FinalizerTest(); // 將null賦值給HOOK_REF,使得原先創建的對象變成可回收的對象 HOOK_REF = null; System.gc(); Thread.sleep(1000); if (HOOK_REF != null) { System.out.println("first gc, object is alive"); } else { System.out.println("first gc, object is dead"); } // 如果對象存活了,再次執行一次上面的代碼 HOOK_REF = null; System.gc(); if (HOOK_REF != null) { System.out.println("second gc, object is alive"); } else { System.out.println("second gc, object is dead"); } } @Override protected void finalize() throws Throwable { super.finalize(); // 在這裡將this賦值給靜態變數,使對象可以重新和GC Roots對象創建引用鏈 HOOK_REF = this; System.out.println("execute in finalize()"); } }
#output:
execute in finalize()
first gc, object is alive
second gc, object is dead
可以看到,第一次執行System.gc()的時候,通過在方法finalize()中將this指針指向HOOK_REF來重建引用鏈接,使得本應該被回收的對象重新複活了。而對比同樣的第二段代碼,沒有成功拯救的原因是:finalize()方法只會被執行一次,所以當第二次將HOOK_REF賦值為null,釋放對對象的引用的時候,由於finalize()方法已經被執行過一次了,所以沒法再通過finalize()方法中的代碼來拯救對象了,導致對象被回收。
3. 怎麼回收記憶體
上面我們已經知道了怎麼識別出可以回收的垃圾對象。現在,我們需要考慮如何對這些垃圾進行有效的回收。垃圾收集的演算法大致可以分為三類:
- 標記-清除演算法
- 標記-複製演算法
- 標記-整理演算法
這三種演算法,適用於不同的回收需求和場景。下麵,我們來一一介紹下每個回收演算法的思想。
3.1 標記-清除演算法
"標記-清除"演算法是最基礎的垃圾收集演算法。標記-清除演算法在執行的時候,分為兩個階段:分別是"標記"階段和"清除"階段。
在標記階段,它會根據上面提到的可達性分析演算法標記出哪些對象是可以被回收的,然後在清除階段將這些垃圾對象清理掉。
演算法思路很簡單,但是這個演算法存在一些缺陷:首先標記和清除這兩個過程的效率不高,其次是,直接將標記的對象清除以後,會導致產生很多不連續的記憶體碎片,而太多不連續的碎片會導致後續分配大塊記憶體的時候,沒有連續的空間可以分配,這會導致不得不再次觸發垃圾回收操作,影響性能。
3.2 複製演算法
複製演算法,顧名思義,和複製操作有關。該演算法將記憶體區域劃分為大小相等的兩塊記憶體區域,每次只是用其中的一塊區域,另一塊區域閑置備用。當進行垃圾回收的時候,會將當前是用的那塊記憶體上的存活的對象直接複製到另外一塊閑置的空閑記憶體上,然後將之前使用的那塊記憶體上的對象全部清理乾凈。
這種處理方式的好處是,可以有效的處理在標記-清除演算法中碰到的記憶體碎片的問題,實現簡單,效率高。但是也有一個問題,由於每次只使用其中的一半記憶體,所以在運行時會浪費掉一半的記憶體空間用於複製,記憶體空間的使用率不高。
3.3 標記-整理演算法
標記-整理演算法,思路就是先進行垃圾記憶體的標記,這個和標記-清除演算法中的標記階段一樣。當將標記出來的垃圾對象清除以後,為了避免出現標記-清除演算法中碰到的記憶體碎片問題,標記-整理演算法會對記憶體區域進行整理。將當前的所有存活的對象移動到記憶體的一端,將一端的空閑記憶體整理出來,這樣就可以得到一塊連續的空閑記憶體空間了。
這樣做,可以很方便地申請新的記憶體,只要移動記憶體指針就可以划出需要的記憶體區域以存放新的對象,可以在不浪費記憶體的情況下高效的分配記憶體,避免了在複製演算法中浪費一部分記憶體的問題。
4. 分代收集
在現代虛擬機實現中,會將整塊記憶體劃分為多個區域。用"年齡"的概念來描述記憶體中的對象的存活時間,並將不同年齡段的對象分類存放在不同的記憶體區域。這樣,就有了我們平時聽說的"年輕代"、"老年代"等術語。
顧名思義,"年輕代"中的對象一般都是剛出生的對象,而"老年代"中的對象,一般都是在程式運行階段長時間存活的對象。將記憶體中的對象分代管理的好處是,可以按照不同年齡代的對象的特點,使用合適的垃圾收集演算法。
對於"年輕代"中的對象,由於其中的大部分對象的存活時間較短,很多對象都撐不過下一次垃圾收集,所以在年輕代中,一般都使用"複製演算法"來實現垃圾收集器。
在上圖中,我們可以看到"Young Generation"標記的這塊區域就是"年輕代"。在年輕代中,還細分了三塊區域,分別是:"eden"、"S0"和"S1",其中"eden"是新對象出生的地方,而"S0"和"S1"就是我們在複製演算法中說到了那兩塊相等的記憶體區域,稱為存活區(Survivor Space)。
這裡用於複製的區域只是占用了整個年輕代的一部分,由於在新生代中的對象大部分的存活時間都很短,所以如果按照複製演算法中的以1:1的方式來平分年輕代的話,會浪費很多記憶體空間。所以將年輕代劃分為上圖中所示的,一塊較大的eden區和兩塊同等大小的survivor區,每次只使用eden區和其中的一塊survivor區,當進行記憶體回收的時候,會將當前存活的對象一次性複製到另一塊空閑的survivor區上,然後將之前使用的eden區和survivor區清理乾凈,現在,年輕代可以使用的記憶體就變成eden區和之前存放存活對象的那個survivor區了,S0和S1這兩塊區域是輪替使用的。
HotSpot虛擬機預設Eden區和其中一塊Survivor區的占比是8:1,通過JVM參數"-XX:SurvivorRatio"控制這個比值。SurvivorRatio的值是一個整數,表示Eden區域是一塊Survivor區域的大小的幾倍,所以,如果SurvivorRatio的值是8,那麼Eden區和其中Survivor區的占比就是8:1,那麼總的年輕代的大小就是(Eden + S0 + S1) = (8 + 1 + 1) = 10,所以年輕代每次可以使用的記憶體空間就是(Eden + S0) = (8 + 1) = 9,占了整個年輕代的 9 / 10 = 90%,而每次只浪費了10%的記憶體空間用於複製。
並不是留出越少的空間用於複製操作越好,如果在進行垃圾收集的時候,出現大部分對象都存活的情況,那麼空閑的那塊很小的Survivor區域將不能存放這些存活的對象。當Survivor空間不夠用的時候,如果滿足條件,可以通過分配擔保機制,向老年代申請記憶體以存放這些存活的對象。
對於老年代的對象,由於在這塊區域中的對象和年輕代的對象相比較而言存活時間都很長,在這塊區域中,一般通過"標記-清理演算法"和"標記-整理演算法"來實現垃圾收集機制。上圖中的Tenured區域就是老年代所在的區域。而最後那塊Permanent區域,稱之為永久代,在這塊區域中,主要是存放類對象的信息、常量等信息,這個區域也稱為方法區。在Java 8中,移除了永久區,使用元空間(metaspace)代替了。
5. 總結
在這篇文章中,我們首先介紹了採用最簡單的引用計數法來跟蹤垃圾對象和通過可達性分析演算法來跟蹤垃圾對象。然後,介紹了垃圾回收中用到的三種回收演算法:標記-清除、複製、標記-整理,以及它們各自的優缺點。最後,我們結合上面介紹的三種回收演算法,介紹了現代JVM中採用的分代回收機制,以及不同分代採用的回收演算法。