為什么需要垃圾收集
在回答這個問題之前,可以先比較目前最流行的兩款面向對象的語言 JAVA 和 C++。JAVA 是帶垃圾收集功能的,而 C++ 是沒有垃圾收集功能的。其本質原因就是在于 JAVA 是一門使用內存動態分配和垃圾收集技術的語言,因此對象的創建和回收都不需要程序員手動操作,只需要交給虛擬機即可,因此由于語言本身的特點所以需要專門的垃圾收集器來回收“已死”的對象。而 C++ 的內存是需要程序員手動分配和釋放的,因此不需要進行專門的垃圾收集操作。由于 C++ 需要手動分配和釋放內存因此若是釋放不及時很容易出現內存泄露的問題,JAVA 是交給專門的收集器因此極少出現內存泄露問題。由于有了垃圾收集器的參與 JAVA 在效率上就會比 C++ 差,但是就避免了區分配和釋放內存的麻煩,有得就必有失。
垃圾收集需要關注的三個問題
哪些內存需要回收
籠統的來講 JAVA 里面的內存分為堆內存和棧內存,由于棧是伴隨著線程而存在的,線程執行結束棧內存就應該被回收,因此相對簡單不需要太關心。堆內存才是需要考慮的重點,主要是堆內存非線程私有,它是被共用的,因此它的回收會比較復雜,才是垃圾收集器需要關注的重點區域,這就回答了哪些內存需要回收的問題了。
什么時候回收
現在知道了需要關注的回收區域是堆內存的回收,而且也知道堆內存上分配的都是引用類型的數據,即對象和數組。也就是需要關心什么時候回收堆中的對象,于是問題就轉化成了確定堆中哪些對象是無用的“已死”對象。于是引出了兩種確定“已死”對象的方法。
引用計數法
引用計數法說白了就是把堆中對象被其他對象引用總次數記錄下來,每次被別的對象引用時計數器的數量就加 1 引用失效后數量就減 1,當對象的引用計數為 0 時就回收該對象。從算法上來看是一個非常簡單的算法,但是其有一個問題就是循環引用,當兩個已失效的對象互相引用彼此時,通過這個方法是無法把計數減到 0 的。JAVA 虛擬機也不是采用這種方式確定對象“已死”的。
可達性分析法
這種方法有一個叫 GCRoots 的概念,從名字可知就是垃圾收集的根節點集合。以這一系列的根節點為開始去遍歷其引用鏈,在引用鏈中可達的對象就當作“存活”的對象,不可達的則為“已死”對象。從而可達性分析法就避免了計數法的問題,能正確找到“存活”的對象。這里需要關注的是哪些對象可以作為 GCRoots:
1.虛擬機棧(棧幀中的本地變量表)中引用的對象;
2.方法區中的類靜態屬性引用的對象;
3.方法區中常量引用的對象;
4.本地方法棧中JNI(即一般說的Native方法)中引用的對象;
5.JAVA 虛擬機內部的引用;
6.所有被同步鎖持有的對象;
7.反映 Java 虛擬機內部情況的 JMZBean、JVMTI 中注冊的回調、本地代碼緩存等;
8.除了這些外還有跨代引用的記憶集等;
如何回收
對于“已死”對象的回收問題,這就涉及到具體的垃圾收集算法了。在討論垃圾收集算法前,有一個重要的特征需要說明。根據對象“朝生夕死”的特點,在 G1 前的垃圾收集器都有分代的思想,也就是會把整個堆內存分為新生代和老年代,而不同的分代會應用不同的的垃圾收集算法進而有不同的垃圾收集器。本次系列文章只討論 CMS、G1、shenandoah、ZGC,這幾種關注低延遲的垃圾收集器。由于分代的引入,于是出現了跨代引用問題,不同收集器處理跨代引用方法略有不同,都會使用記憶集這種數據結構去解決跨代引用問題。同時也出現了幾種專有名詞:
MinorGC、YoungGC:指的是新生代的垃圾回收;
MajorGC、OldGC:指的是老年代的 GC,目前只有 CMS 有老年代 GC 的說法。
MixedGC:會收集整個新生代以及部分老年代,目前只有 G1 會有這種 GC。
FullGC:會收集整個 Java 堆和方法區;
有時候也會把 FullGC 和 MajorGC 混用,某些場景需要注意區分。
標記-清除算法
它是最基礎的垃圾收集算法,其他的收集算法都是在它的基礎上改進而來的。
根據名字可知該算法有兩個過程,即標記和清除。根據上文的可達性分析法即可標記出存活的對象,然后清除調未被標識的對象即可。該算法的缺點:
1、執行效率不穩定當對象太多時,標記和清除兩個過程效率會降低;
2、會導致很多內存碎片,從而會影響大對象的分配;
標記-復制算法
如算法名所示它也包含兩個過程,即標記和復制。該算法在分配對象內存時,會留出一塊區域,此區域不分配新對象,也就是會有一塊空閑的內存區域,用以復制對象。其標記過程和標記-整理算法一樣,而清除時,它是將標記后存活的對象復制到空閑的的那塊內存區域中,然后將另一塊作為垃圾整個回收掉。由于新生代對象“朝生夕死”的特點,Java 虛擬機在新生代使用的就是復制算法,將新生代內存分成三塊,Eden、From Survivor 、To Survivor,其默認比例為 8:1:1。新創建的對象在 Eden 區分配,Survivor 區用來保存從 Eden 區存活下來的對象,當對象的可達性分析標記完成后,將 Eden 區中存活的對象復制到 To Suirvivor 區,From Survivor 區的對象根據對象的分代年齡,大于設置的分代年齡的對象復制到老年代,小于的也復制到 To Survivor 區,然后清空 Eden 和 From Survivor 區,此時的 To Survivor 區變成 From Survivor 區,被清空的 From Survivor 區變成 To Survivor 區。如此便完成了標記-復制算法在新生代的流程。由于新生代對象“朝生夕死”的特點,所以每次存活下來的對象比較少,因此復制的開銷也比較小,并且也完全避免了空間碎片的產生,也有利于垃圾的整塊回收;但是也有兩個缺點:
1、由于需要專門留一塊做為空閑的區域,故浪費內存空間,但是由于對象朝生夕死的特點,浪費的空間有限;
2、由于預留的的空間比較小,就有可能存在存活的對象內存超過預留的內存的可能性,故需要做空間的擔保,當預留空間不夠用時就直接在老年代分配新對象。
標記-整理算法
這個算法的標記過程和標記-清除算法一樣,區別就是標記完成后不是直接清除“已死”對象,而是將存活的對象進行整理,即移動到一起保證中間不存在碎片。這個算法適用于老年代,但是老年代的存活的對象比較多,每次移動對象效率不高,更關鍵的是需要暫停用戶線程 stop the world 因此老年代也不會直接使用該算法,一般都是先進行幾輪標記-清除算法收集后,再進行一次壓縮的工作,從而減少空間碎片的產生。他的缺點就是:
1、老年代存活對象多,整理需要移動對象,需要開銷會很大;
2、由于需要移動對象就需要 stop the world 因此會影響用戶線程的執行;