Java支持內存動態分配、垃圾自動回收,而 C++ 不支持。我想這可能也是 爲何 Java 脫胎於 C++ 的一個緣由吧。java
GC 的歷史比 Java 更久遠,好比 1960 年誕生的於 MIT 的 Lisp
就是第一門真正使用內存動態分配和垃圾回收的語言。程序員
咱們從這三個問題出發,來更深一層地看看 JVM GC 爲咱們作了哪些工做。算法
咱們都知道,JVM 棧和堆所使用的計算機內存都是由 JVM 統一管理的,只不過棧中元素的內存分配和回收是由 JVM 全權管理,而堆中對象建立時的內存分配則由咱們 Java 程序員來控制,內存回收則由 JVM GC 負責。eclipse
JVM Stack 棧中元素的生命週期隨着方法棧的結束或者線程棧的結束而結束,並且每個棧中元素須要分配多少的內存空間在 Java 代碼編譯成 class 字節碼文件的時候就已經肯定了,所以 JVM 對於棧的內存管理相對於堆來講,是要簡單一些的。在這裏關於 JVM 棧的知識點就不作細緻挖掘,之後再分享。本文只針對回收堆內存中的對象。工具
通俗點講,JVM 堆內存中的全部對象都是 JVM GC 回收的目標對象,但只有已經肯定死掉的對象纔會被 GC 回收,否則 JVM 中就亂套了,想一想看:一個對象正在愉快地搬磚,忽然被一隻看不見的手給殺掉了,連屍體都沒留下,它的親人們就會很着急啊,對象失蹤了,究竟是死是活,活要見人死要見屍啊。性能
Java 世界是一個法制社會,作任何事情要有理有據,那麼就引出了一個問題:優化
只有被打上了「死亡」標記的對象,纔會被 GC 回收掉。那麼咱們能夠將 「哪些對象會被 GC 回收?」 的問題稍微轉換一下角度:「如何判斷一個對象已經死亡,並給它打上'死亡'標記?」.net
先來介紹兩種給對象「判死刑」的算法:線程
原理:在建立對象時,給每一個對象都添加一個「引用計數器」,每當有一個地方引用它時,計數器的值就加 1;反之,當一個指向該對象的引用失效時,計數器值就減 1。在任什麼時候刻,計數器值爲 0 時,就表示這個對象已經不可用了,或者說已經死掉了。指針
引用計數算法的原理實現起來很簡單,並且對死亡對象的斷定效率也很高,在大部分狀況下,這都是一個很不錯的斷定算法。有一些很著名的應用案例:微軟公司的COM(Component Object Model)技術、Python 語言都使用了引用計數算法。
可是!在主流的 JVM 中卻沒有選擇 引用計數算法 來管理內存,其中最主要的緣由:它很難解決對象之間相互循環引用的問題。
舉個簡單的栗子:
public class Test { public Object obj = null; public static void main(String []args){ // 建立並初始化兩個 Test 對象 Test a = new Test(); Test b = new Test(); // 讓兩個對象 相互循環引用 a.obj = b; b.obj = a; // 接下來是關鍵點:讓兩個對象的引用失效 a = null; b = null; // 假設執行了 GC System.GC(); } }
問題來了,執行了 System.GC();
以後,a 和 b 兩個對象會被回收嗎?
咱們能夠經過配置 eclipse.ini 開啓 Eclipse 工具打印 GC 日誌的功能,而後對比執行 GC 以前的堆空間大小與執行 GC 以後的堆空間大小,就能獲得答案:沒有被回收。所以能夠肯定 JVM 沒有使用引用計數算法做爲對象是否存活的斷定算法。
在主流的商用程序語言(Java、C#,還有前面提到的古老的Lisp)主要都是經過可達性分析(Reachability Analysis)來斷定對象是否存活的。
算法基本思路:經過一系列稱爲 「GC Roots」 的對象做爲起始點,從這些節點開始向下搜索,搜索所走過的路徑稱爲 引用鏈(Reference Chain),當一個對象到 GC Roots 沒有任何引用鏈相連(用數學圖論的話來講,就是從 GC Roots到這個對象不可達)時,則證實此對象是不可用的。
GC Roots 的圖例:
以上圖例中,object 五、object 六、object 7 三個對象雖然有關聯,可是它們到 GC Roots 是不可達的,因此它們將會被斷定爲可回收的對象。
在 Java 中,能夠做爲 GC Roots 的對象包括下面幾種:
對可達性分析算法簡單總結一下:只要 Java 堆中的對象與最後一個 GC Roots 斷開了鏈接,這個對象就成爲了 GC 的回收目標。
由於 Java 堆中對象的建立是由咱們 Java 程序員控制的,所以:
建立時機不肯定,到底須要多少的內存空間也不肯定,那麼 JVM 也就不知道該在什麼時候爲建立對象準備足夠的資源空間,爲了不 當須要爲建立對象分配內存空間時,卻已經沒有可用的內存空間
這種尷尬的狀況發生,JVM GC 就須要適時地在暗地裏操控 JVM 堆內存,回收被那些已經死掉的對象佔用的內存空間。
那麼這個「適時」究竟是何時呢?你們都知道 GC 執行的時間不肯定性,但這不表明 GC 就是在隨性而爲,下面咱們來對這個 GC 時機 進行講解:
Java 中,通過可達性分析算法斷定後,成爲 GC 回收目標的對象,並非被判了死刑要當即執行,而是死緩。
要真正宣告一個對象死亡,至少要經歷兩次標記過程:
若是對象在通過可達性分析以後發現沒有與 GC Roots 相鏈接的引用鏈,那它將會被 GC 打上第一次標記而且執行一次篩選斷定,篩選的條件是該對象是否有必要執行 finalize() 方法。
當對象沒有覆寫 finalize() 方法,或者 finalize() 方法剛剛被 JVM 調用過了,那麼 JVM 就會認爲沒有必要執行 finalize() 方法,也就失去了重生的機會。
若是這個對象被斷定爲有必要執行 finalize() 方法,那麼這個對象就會被放置在一個叫 F-Queue 的隊列中,並在稍後由一個低優先級的 Finalizer 線程去執行 finalize() 方法,只要在 finalize() 方法的執行過程當中,將對象與引用鏈上的任何一個對象創建關聯,那麼在 GC 第二次標記時就會將該對象移除「回收名單」,若第二次標記時仍然沒有可達鏈接,就將這個對象完全回收。
一個對象的 finalize() 方法只會被系統自動調用一次,也就是說,這個復活技能只能使用一次。
關於 finalize() 方法,不建議使用,不肯定性太大,沒法保證各個對象的調用順序,finalize() 能作的,try-finally 能作得更好、並且更及時。
咱們換個通俗點的說法總結一下:第一次打標記就是給這個對象發法院的一審判決通知,這個對象要麼上訴,上訴了還有勝訴的但願,要麼什麼都不作等待執刑,第二次打標記就是對那些沒有勝訴的對象斬立決。
前面提到的兩種算法,不管是經過引用計數算法判斷對象的引用數量,仍是經過可達性分析算法判斷對象的引用鏈是否可達,判斷對象是否存活的關鍵,都與「引用」有關。在這裏對「引用」這個概念作一下擴展。
在 JDK 1.2 之前,Java 中的引用的定義很傳統:
若是 reference 類型的數據中存儲的數值表明的是另一塊內存的起始地址,就稱這塊內存表明着一個引用。
這也是咱們做爲 Java 初學者時常認爲的引用概念,在這種定義下,一個對象只有被引用或者沒有被引用兩種狀態,在 JDK 1.2 以後,Java 對引用的概念進行了擴充,將引用分爲:強引用、軟引用、弱引用、虛引用,這 4 種引用的強度依次遞減。
- 強引用(Strong Reference):指在程序代碼中廣泛存在的,相似 「
Object obj = new Object();
」 這種建立的對象引用。只要強引用還存在,JVM GC 就不會回收掉被引用的對象。當內存空間不足時,JVM 寧願拋出 OutOfMemoryError 錯誤使程序異常終止,也不會靠隨意回收具備強引用的對象來解決內存不足的問題。- 軟引用(Soft Reference):用來描述一些還有用但並非必需的對象。對於軟引用關聯着的對象,在系統將要發生內存溢出異常以前,將會把這些對象列入回收範圍之中,但不會當即回收,此時這些對象仍然能夠被程序使用,只有當內存空間確實不足時,纔會對回收範圍內的對象進行回收處理。
- 弱引用(Weak Reference):也是用來描述非必需對象的,但強度比軟引用要更弱,生命週期更短暫,在 GC 線程掃描它所管轄的內存區域的過程當中,一旦發現了只具備弱引用的對象,無論當前內存空間是否足夠,都會回收它。
- 虛引用(Phantom Reference):虛引用並不會決定對象的生命週期,若是一個對象僅持有虛引用,那麼它就和沒有任何引用同樣,在任什麼時候候均可能被 GC 回收,固然咱們也不能經過虛引用獲得一個對象實例。爲一個對象加上虛引用的惟一目的就是能在這個對象被 GC 回收時,能夠收到一個系統通知。
由於篇幅問題,本文僅對實現垃圾回收的算法進行分析,不作過多實現細節上的描述。
垃圾回收目前經常使用的有 3 種算法思路:
標記 - 清除算法是最基礎的回收算法。該算法分爲 「標記」 和 「清除」 兩個階段:
之因此說它是最基礎的算法,由於後續的回收算法都是基於這種思路,而且對其不足進行改進而來。
標記 - 清除算法有兩個明顯的不足:
咱們來看看使用「標記 - 清除」算法執行先後的內存變化:
爲了解決「標記 - 清除算法」的效率問題,誕生了「複製算法」。
複製算法的思路:將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。每次只使用其中的一塊,當這一塊的內存用完了,就將還存活的對象複製到另外一塊上,而後再把已使用過的內存空間一次性清理掉。
這樣使得每次 GC 都是在對整個堆內存的一半區域進行操做,進行內存分配時也就不用考慮內存碎片等複雜問題,每次分配只須要移動堆頂的指針,按順序分配內存便可,實現簡單,運行高效。
缺點也很明顯:將可用內存縮小爲原來的一半,代價太大了。
可是目前主流的虛擬機都是採用複製算法來進行垃圾回收的。爲何你們還要用缺點這麼明顯的算法呢?這牽扯到另外一個問題:JVM 堆內存分代。
在這裏咱們簡單描述下堆內存分代
的概念:
JVM 堆內存並非一鍋亂燉的大雜燴,而是將堆內存進行了分代(新生代、老年代),分代的目的也就是爲了優化 GC 的性能,就比如硬盤要分區,要建文件夾管理文件同樣,方便尋找和管理資源。
HotSpot 版本的 JVM 將新生代內存區域分爲了三個部分: 1 塊較大的 Eden 區和 2 塊較小的 Survivor 區(分別名叫 from 和 to)。HotSpot JVM 中默認 Eden 和 Survivor 空間大小的比例是 8:1,也就是說,每次新生代中可用內存空間爲整個新生代容量的 90%( 80% +10% ),要說浪費,也只有其中的 10% 被浪費了。
新生代中實際可用的內存區域只有: Eden 和 其中的一塊 Survivor(第一次是 from,from 滿後轉移到 to)。
通常狀況下,新建立的對象都會被分配到 Eden 區(先放到 Eden 區是由於有些對象比較大,但不必定是常駐對象),Eden 中的對象通過第一次 Minor GC 後,若是仍然存活,將會被移到 Survivor 區。對象在 Survivor 區中每熬過一次 Minor GC,年齡就會增長 1 歲,當它的年齡增長到必定程度時,就會被移動到老年代中。
另外作一個關於 新生代 和 老年代 的擴展:
新生代 和 老年代:
- 新生代:剛建立、存活時間較短的對象,通常都存放在新生代堆區
- 老年代:在新生代中存活超過了必定年齡的對象,就會被轉移到老年代堆區
新生代 GC 和 老年代 GC:
- 新生代 GC(Minor GC):指發生在新生代的垃圾回收動做。由於 java 對象大多都具有「朝生夕滅」的特性,因此 Mirnor GC 很是頻繁,回收速度也比較快。
- 老年代 GC(Major GC / Full GC):指發生在老年代的垃圾回收動做。Major GC 的速度通常會比 Minor GC 慢 10 倍以上。
使用複製算法,在對象存活率比較高時,要複製的內容就多了,相應的操做效率就會下降。另外,若是內存空間總體的使用率要求超過一半,好比內存中 100% 的對象都是存活狀態的極端狀況,用複製算法就不可靠了,特別是在 老年代
中,不能使用複製算法,這就催生而出另外一種符合 老年代
特色的算法:標記 - 整理算法。
該算法的思路:標記的過程與 「標記 - 清除」算法是一致的,區別在於後續步驟不是直接對可回收對象進行清理,而是讓全部存活的對象都向內存空間的一端移動,而後直接清理掉內存另外一端邊界之外的內存,用圖來講話:
分代收集算法是目前商業虛擬機的垃圾回收主要採用的算法。
其實分代收集算法並無什麼特別的新思想,只是根據對象存活週期的不一樣,將內存劃分爲新生代和老年代,而後根據不一樣的年代內存區域,採用符合各自特色的回收算法。好比:在新生代中,由於每次 GC 都會發現大量的死對象,只有少許存活,選用複製算法回收效率更高;而在老年代中的對象存活率高、也沒有額外的空間爲其冗餘,就必須採用 「標記 - 清除」 或 「標記 - 整理」算法進行回收。
至此,關於 Java 虛擬機垃圾回收的知識點分享就到這裏,謝謝。
參考資料:《深刻理解Java虛擬機:JVM高級特性與最佳實踐》 - 周志明