本篇內容包括:JAVA 垃圾回收機制概述、有哪些內存需要回收、如何回收(標記-清除、標記-整理(標記-清除-壓縮)、復制(標記-復制-清除)、分代收集等算法) 以及 何時進行垃圾回收等內容!
一、概述
Java 與 C++ 之間有一堵由內存動態分配和垃圾收集技術所圍成的"高墻",墻外面的人想進去,墻里面的人卻想出來
說起垃圾收集(Garbage Collection,下文簡稱GC),有不少人把這項技術當作Java語言的伴生產物。事實上,垃圾收集的歷史遠遠比Java久遠,在1960年誕生于麻省理工學院的Lisp是第一門開始使用內存動態分配和垃圾收集技術的語言。
當 Lisp 還在胚胎時期是,其作者 John McCarthy 就思考過垃圾收集器需要完成的三件事情:
- 有哪些內存需要回收?
- 什么時候回收?
- 如何回收?
二、有哪些內存需要回收?——對象已死?
在堆里面存放著 Java 世界中幾乎所有的對象實例,垃圾收集器在對堆進行回收前,第一件事情就是要確定這些對象之中哪些還“存活”著,哪些已經“死去”(即不可能再被任何途徑 使用的對象)。
1、引用計數算法
引用計數是垃圾收集器中的早期策略。在這種方法中,堆中每個對象實例都有一個引用計數。當一個對象被創建時,就將該對象實例分配給一個變量,該變量計數設置為 1。當任何其它變量被賦值為這個對象的引用時,計數加 1(a = b,則b引用的對象實例的計數器+1),但當一個對象實例的某個引用超過了生命周期或者被設置為一個新值時,對象實例的引用計數器減 1。任何引用計數器為 0 的對象實例可以被當作垃圾收集。當一個對象實例被垃圾收集時,它引用的任何對象實例的引用計數器減 1。
優缺點:
-
優點:引用計數收集器可以很快的執行,交織在程序運行中。對程序需要不被長時間打斷的實時環境比較有利。
-
缺點:無法檢測出循環引用。如父對象有一個對子對象的引用,子對象反過來引用父對象。這樣,他們的引用計數永遠不可能為0。
2、可達性分析算法
可達性分析算法是從離散數學中的圖論引入的,程序把所有的引用關系看作一張圖,從一個節點 GC ROOT 開始,尋找對應的引用節點,找到這個節點以后,繼續尋找這個節點的引用節點,當所有的引用節點尋找完畢之后,剩余的節點則被認為是沒有被引用到的節點,即無用的節點,無用的節點將會被判定為是可回收的對象。

在Java語言中,可作為 GC Roots 的對象包括下面幾種:
- 棧幀中的局部變量表中的 reference 引用所引用的對象;
- 方法區中 static 靜態引用的對象;
- 方法區中 final 常量引用的對象;
- 本地方法棧中 JNI(Native方法)引用的對象;
- Java 虛擬機內部的引用, 如基本數據類型對應的 Class 對象, 一些常駐的異常對象(比如 NullPointExcepiton、OutOfMemoryError) 等, 還有系統類加載器;
- 所有被同步鎖(synchronized關鍵字) 持有的對象;
- 反映 Java 虛擬機內部情況的 JMXBean、 JVMTI 中注冊的回調、 本地代碼緩存等。
這個算法的基本思路就是通過一系列的稱為 GC Roots 的對象作為起始點,從這些節點開始向下搜索,搜索所走過的路徑稱為引用鏈(Reference Chain),當一個對象到GC Roots 沒有任何引用鏈相連(用圖論的話來說,就是從GC Roots 到這個對象不可達)時,則證明此對象是不可用的。
三、如何回收?
在 JVM 中主要的垃圾收集算法有:標記-清除、標記-整理(標記-清除-壓縮)、復制(標記-復制-清除)、分代收集等算法。這幾種收集算法互相配合,針對不同的內存區域采取對應的收集算法實現(這里具體是由相應的垃圾收集器實現)。
垃圾回收涉及到大量的程序細節,而且各個平臺的虛擬機操作內存的方式也不一樣,但是他們進行垃圾回收的算法是通用的,所以這里我們也只介紹幾種通用算法。
1、標記-清除算法
算法實現:分為標記-清除兩個階段,首先根據上面的根搜索算法標記出所有需要回收的對象,在標記完成后,然后在統一回收掉所有被標記的對象。
缺點:
- 效率低:標記和清除這兩個過程的效率都不高。
- 容易產生內存碎片:因為內存的申請通常不是連續的,那么清除一些對象后,那么就會產生大量不連續的內存碎片,而碎片太多時,當有個大對象需要分配內存時,便會造成沒有足夠的連續內存分配而提前觸發垃圾回收,甚至直接拋出 OutOfMemoryExecption。

2、復制算法
為了解決標記-清除算法的兩個缺點,復制算法誕生了。
算法實現:將可用內存按容量劃分為大小相等的兩塊區域,每次只使用其中一塊,當這一塊的內存用完了,就將還活著的對象復制到另一塊區域上,然后再把已使用過的內存空間一次性清理掉。
優缺點:
-
優點:每次都是只對其中一塊內存進行回收,不用考慮內存碎片的問題,而且分配內存時,只需要移動堆頂指針,按順序進行分配即可,簡單高效。
-
缺點:將內存分為兩塊,但是每次只能使用一塊,也就是說,機器的一半內存是閑置的,這資源浪費有點嚴重。并且如果對象存活率較高,每次都需要復制大量的對象,效率也會變得很低。

3、標記-整理算法
上面我們說過復制算法會浪費一半的內存,并且對象存活率較高時,會有過多的復制操作,效率低下。如果對象存活率很高,基本上不會進行垃圾回收時,標記-整理算法誕生了。
算法實現:首先標記出所有存活的對象,然后讓所有存活對象向一端進行移動,最后直接清理到端邊界以外的內存。
局限性:只有對象存活率很高的情況下,使用該算法才會效率較高。

4、分代收集算法
當前商業虛擬機都是采用此算法,但是其實這不是什么新的算法,而是上面幾種算法的合集。
算法實現:根據對象的存活周期不同將內存分為幾塊,然后不同的區域采用不同的回收算法。
對于 HotSpot 虛擬機,它將堆空間分為老年代和新生代兩塊區域
-
對于存活周期較短,每次都有大批對象死亡,只有少量存活的區域,采用復制算法,因為只需要付出少量存活對象的復制成本即可完成收集;
-
對于存活周期較長,沒有額外空間進行分配擔保的區域,采用標記-整理算法,或者標記-清除算法。
堆有新生代和老年代兩塊區域組成,而新生代區域又分為三個部分,分別是 Eden、From Surivor、To Survivor,比例是8:1:1。
新生代采用復制算法,每次使用一塊 Eden 區和一塊 Survivor 區,當進行垃圾回收時,將 Eden 和一塊 Survivor 區域的所有存活對象復制到另一塊 Survivor 區域,然后清理到剛存放對象的區域,依次循環。
老年代采用標記-清除或者標記-整理算法,根據使用的垃圾回收器來進行判斷。
至于為什么要這樣,這是由于內存分配的機制導致的,新生代存的基本上都是朝生夕死的對象,而老年代存放的都是存活率很高的對象。
四、何時進行垃圾回收
理清了什么是垃圾,怎么回收垃圾,最后一點就是Java虛擬機何時進行垃圾回收呢?
程序員可以調用 System.gc()
方法,手動回收,但是調用此方法表示希望進行一次垃圾回收。但是它不能保證垃圾回收一定會進行,而且具體什么時候進行是取決于具體的虛擬機的,不同的虛擬機有不同的對策。
其次虛擬機會自行根據當前內存大小,判斷何時進行垃圾回收,比如前面所說的,新生代滿了,新產生的對象無法分配內存時,便會觸發垃圾回收機制。
這里需要說明的是宣告一個對象死亡,至少要經歷兩次標記,前面我們說過,如果對象與 GC Roots 不可達,那么此對象會被第一次標記并進行一次篩選,篩選的條件是此對象是否有必要執行 finalize() 方法,當對象沒有覆蓋 finalize() 方法,或者該方法已經執行了一次,那么虛擬機都將視為沒有必要執行 finalize() 方法。
如果這個對象有必要執行 finalize() 方法,那么該對象將會被放置在一個有虛擬機自動建立、低優先級,名為 F-Queue 隊列中,GC 會對 F-Queue進行第二次標記,如果對象在 finalize() 方法中成功拯救了自己(比如重新與GC Roots建立連接),那么第二次標記時,就會將該對象移除即將回收的集合,否則就會被回收。