細說Java垃圾回收

目錄

前言

什麼是垃圾回收?

手動回收垃圾時代

虛擬機接管時代

哪些區域需要垃圾回收?

怎麼定義垃圾?

引用計數法

可達性分析算法

哪些些對象可以作爲GC Root?

虛擬機棧(棧幀中的局部變量表)中的引用對象

方法區中靜態類屬引用的對象

怎麼進行垃圾回收?

標記清除法(Mark-Sweep)

複製算法(Copying)

標記壓縮法(Mark-Compact)

分代收集(Generational Collection)

GC 過程

卡表

本文腦圖

前言

通過之前的篇章我們已經瞭解了Java運行時數據區的結構和一個類如何被加載進虛擬機的虛擬機類加載機制。那虛擬機又是如何回收資源的呢?

在Java的世界裏,我們似乎對垃圾回收沒有那麼關注,至少在我初學Java的那幾年完全不懂GC,但是依然不影響我寫一個還不錯的程序或者系統。但是也不能代表Java的GC不重要,當發生OutOfMemoryException的時候,如果你不瞭解Java虛擬機,不瞭解垃圾回收機制,那麼你只有乾瞪眼,或者說重啓服務器。我一直認爲對Java虛擬機的掌握是區分初級程序員和中級程序員的關鍵指標,可想而知它在我心目中的地位。話不多說,開始今天的講解。

什麼是垃圾回收?

說起垃圾回收(Garbage Collection,檢查GC),首先要明確什麼是垃圾。GC中的垃圾特指存在於內存中的、不會再被使用的對象;而“回收”,也相當於把垃圾“倒掉”。如果不及時清理內存中的垃圾,那麼這些垃圾對象所佔用的空間會一直保留到應用程序結束,而這部分空間又無法被其他對象使用。而真正需要內存空間時,因爲空間都被垃圾對象佔滿,從而有可能導致內存溢出。

手動回收垃圾時代

最早學習C/C++的時候,垃圾回收基本上是手工進行的。開發人員通過new關鍵字申請內存,當使用結束後用delete關鍵字進行內存釋放。

這樣的釋放方式是由開發人員顯示指定的,這種方式可以很靈活的控制釋放時間,但是一個系統中的內存申請和釋放可能及其頻繁,這就會給開發人員帶來極大的管理負擔。倘若有一處內存開發人員忘記回收,那麼就會產生內存泄露,垃圾對象永遠無法被清楚,隨着系統運行時間的不斷增長,垃圾對象所耗內存可能只需上升,直到內存溢出

虛擬機接管時代

爲了將開發者從繁重的內存管理中釋放出來,更加專注於業務,就需要一種垃圾回收機制可以自動識別並回收垃圾,不需要人工干預。有得必有失,有了這種自動回收技術後,垃圾釋放就由虛擬機控制,釋放時機可能不是那麼靈活,當然優點遠遠大於這個不足。

垃圾回收並不是Java虛擬機獨創的,早在20世紀60年代,垃圾回收就已經被Lisp語言所使用。現在,除了Java以外的C#,Python等語言都使用了垃圾回收的思想。可以說這種自動化的內存管理方式已經成爲了現代開發語言必備的標準。

哪些區域需要垃圾回收?

通過對Java運行時數據區的瞭解,我們知道Java虛擬機的內存區域分爲線程私有和線程共享兩大塊。其中線程私有的程序計數器、Java虛擬機棧和本地方法棧和線程同生共死;棧中的棧幀隨着方法的進入和退出,有條不紊地執行入棧he出棧操作。同時每一個棧幀中分配多少內存基本上是在類結構確定下來就已知了,因此這幾個區域的內存分配和回收都具備確定性,在這幾個區域內不需要過多考慮回收問題,因爲方法結束或線程結束,內存自然就隨着回收了。

線程共享的堆和方法區則不一樣,因爲虛擬機類加載機制,程序只有處於運行期間纔會知道創建了哪些對象,而沒一個接口或者多個實現類中的內存又可能不一樣。這部分的內存分配和回收都是動態的,也是垃圾所主要關注的區域。

怎麼定義垃圾?

要進行垃圾回收首先要進行垃圾收集,而那些已經完成了它的使命並且不再被任何對象引用的內存,那麼我們就可以認爲這塊內存已經“死去”,等待垃圾收集器進行收集。

引用計數法

一種古老的垃圾收集方法就是引用計數法(Reference Counting),實現也非常簡單,每個對象內部都有一個引用計數器來表示該對象被引用的次數。如果有對象引用它,則引用計數器就加1,當引用失效時,引用計數器就減1。只要對象的引用計數器的值爲0,就表示對象不可能再被使用。

看似很美好,但是引用計數法無法解決循環依賴問題,例如:

public class 引用計數法 {
    public static void testGC(){
        ReferenceCountingGC a = new ReferenceCountingGC("objA");
        ReferenceCountingGC b = new ReferenceCountingGC("objB");
        a.instance = b;
        b.instance = a;
        a = null;
        b = null;
    }
}
class ReferenceCountingGC{
    public Object instance;
    public ReferenceCountingGC(String name){}
}

這段程序非常簡單,但是如果使用引用計數法的話,我們來分析它是不是還可以滿足我們的要求。

從圖中我們可以看到,最後這2個對象已經不可能再被訪問了,但是由於他們還是被互相引用者,導致引用計數器永遠都不會爲0,因爲GC收集器永遠不可能回收他們,最終就會導致內存泄露。所以Java虛擬機並沒有選擇此算法作爲垃圾回收算法。

可達性分析算法

當前主流的商用程序語言的內存管理子系統,都是通過可達性分析(Reachability Analysis)算法來判定對象是否存活的。這個算法的基本思路就是通過一系列稱爲“GCRoots”的根對象作爲起始節點集,從這些節點開始,根據引用關係向下搜索,搜索過程所走過的路徑稱爲“引用鏈”(Reference Chain),如果某個對象到GC Roots間沒有任何引用鏈相連,或者用圖論的話來說就是從GC Roots到這個對象不可達時,則證明此對象是不可能再被使用的。

上圖中,對象object5、object6、object7雖然互相關聯,但是他們始終沒有一個GC Root和他們關聯,所以會被判斷爲可回收對象。

哪些些對象可以作爲GC Root?

從上圖可以看出Tracing GC的基本思路有就是,以當前一定存活的對象作爲Root,遍歷他們所有關聯的對象,沒有遍歷到的對象既爲非存活對象,這部分對象就可以被GC掉。

可以作爲GC Root的節點包括但不限於以下幾點:

而。常量引用的對象在當前可能存活,因此也可能是GC root

虛擬機棧(棧幀中的局部變量表)中的引用對象

虛擬機棧和本地方法棧都是線程私有的內存區域,不屬於垃圾回收的範圍,只要線程沒終止,就一定能確保他們中引用的對象是存活的。

public class StackLocalParameter {
    public static void testGC(){
        StackLocalParameter s = new StackLocalParameter();
        s = null;
    }
}

上圖中s爲局部變量表中的對象,既爲GC Root,當s置空時,那麼對象也就斷了個GC Root的引用,因此將被回收。

方法區中靜態類屬引用的對象

方法區中靜態類屬性引用的對象顯然也是存活的,那麼被引用的對象一定是存活的,因此可以作爲GC Root。

public class MethodAreaStaticProperties {
    public static MethodAreaStaticProperties m;
    public MethodAreaStaticProperties(String objectName){

    }
    public static void testGC(){
        MethodAreaStaticProperties s = new MethodAreaStaticProperties("properties");
        s.m = new MethodAreaStaticProperties("parameter");
        s = null;
    }
}

s爲局部變量,所以它是GC Root,經過GC後s所指向的properties對象無法與任意一個GC Root建立關係所以被回收。而m作爲類的靜態屬性,也是屬於GC Root,parameter對象依然與GC Root建立連接,所以此時parameter對象無法被回收。

3:方法區中常量引用的對象

public class MethodAreaStaticProperties {

    public static final MethodAreaStaticProperties m = new MethodAreaStaticProperties("final");
    public MethodAreaStaticProperties(String name){}
    public static void testGC(){
        MethodAreaStaticProperties s = new MethodAreaStaticProperties("staticProperties");
        s = null;
    }
}

m 即爲方法區中的常量引用,也爲GC Root,s 置爲 null 後,final 對象也不會因沒有與 GC Root 建立聯繫而被回收。

4:本地方法棧中JNI(既一般所得Native方法)引用的對象

5:所有被同步鎖(synchronized關鍵字)持有的對象。

怎麼進行垃圾回收?

此時的垃圾收集器已經知道那些垃圾可以被回收,接下來要做的就是如何高效的進行垃圾回收。由於《Java虛擬機規範》並沒有對如何實現垃圾收集器做出明確的規定,因此各個廠商的虛擬機可以採用不同的方式來實現垃圾收集器,比較常見的有如下幾種:

標記清除法(Mark-Sweep)

標記清除算法是現代垃圾回收算法思想的基礎。標記清楚算法將垃圾回收分成兩個階段:標記階段和清除階段。在標記階的段首先通過GC Root標記所有從根節點開始的可達對象,未被標記的對象就是未被引用的垃圾對象。然後,在清除階段清除所有未被標記的對象。

標記清除算法的好處就是實現簡單,但是我們觀察上圖後就會發現清除過後它會產生大量不連續的內存碎片。當需要分配大對象的時候因爲無法找到連續內存又不得不再觸發一次GC。

複製算法(Copying)

複製算法核心思想就是:將原有的內存空間分成兩塊,每次只使用一塊,在垃圾回收時,將正在使用的內存中的存活對象複製到未使用的內存塊中,之後,清除正在使用的內存塊中的所有對象,交換兩個內存的角色,完成垃圾回收。

上圖A、B兩塊相同的內存空間,當進行垃圾回收的時候將A中存活的對象連續複製到B中。複製結束後再清空A,並將B空間設置爲當前使用的空間。

如果系統中的垃圾很多,複製算法需要複製的存活對象就會相對較少。因此真正需要垃圾回收的時候,複製算法的效率很高。同時也解決的標記清除中,大量內存碎片的問題。但是又帶來了新的問題,我們可使用的內存只有只有一半,內存利用率太低只有50%,單單這一點也很難讓人接受。

對半分的話,需要犧牲一半的內存空間。當前的商業虛擬機的垃圾收集器,大多數都遵循“分代收集”的理論進行設計,分代收集名爲理論,實質是一套符合大多數程序運行實際情況的經驗法則,它建立在兩個分代假說之上:

  1. 絕大多數對象都是朝生夕滅的。
  2. 熬過越多次垃圾收集過程的對象就越難以消亡。

內存對半分對於java來說就不是那麼適合的,所以採用的是將內存分成1塊較大的Eden和2塊較小的survivor空間,每次使用一塊Eden和其中一塊survivor,當回收的時候,直接將Eden和survivor中存活的對象另一塊Survivor上,最後清理掉Eden和survivor的內存空間。

大對象或者多次回收依然存活的對象會直接進入老年代。eden:from:to的比例是8:1:1。那麼就是說,我們每次只有10%的內存浪費,可以滿足我們絕大部分的場景,但是當survivor不夠時,我們需要依賴其他內存(老年代)進行分配擔保。

標記壓縮法(Mark-Compact)

複製算法的高性能建立在存活對象少、垃圾對象多的前提下。這種情況在新生代經常發生,但是老年代(經歷多次垃圾回收依然存活的對象)更常見的是大部分對象都是存活對象。複製算法在這時候回收成本就很大。因此基於老年代的垃圾回收特性需要使用其他算法。

標記壓縮是基於老年代特性的一種垃圾回收算法,和標記清除算法一樣,首先從根節點開始,對所有可達對象做一次標記。但之後並不只是簡單地清除未標記的對象,而是將所有存活對象壓縮到內存一段。之後清理邊界以外的空間。這種方式既避免了碎片產生,又不需要兩塊相同的內存空間,對於老年代的回收性價比非常高。

標記壓縮算法,其實相當於標記整理執行完成之後,再對內存碎片進行一次整理。因此也有人稱它爲標記清除壓縮(MarkSweepCompact)算法。

分代收集(Generational Collection)

至此我們已經瞭解了標記整理,複製,標記壓縮等垃圾回收算法。在這些算法中,並沒有一種算法可以完全替代其他算法,都具有各自獨特的優勢和特點。因此根據要回收對象的特點,選擇合適的垃圾回收算法纔是明智的決定。

在Java堆劃分出不同的區域之後,垃圾收集器纔可以每次只回收其中某一個或者某些部分的區域——因而纔有了Minor GC、Major GC、Full GC”這樣的回收類型的劃分;也才能夠針對不同的區域安排與裏面存儲對象存亡特徵相匹配的垃圾收集算法。在新生代中每次垃圾胡思後都有大量對象死去,少量存活,那就適合複製算法。而老年代中因爲對象存活率高、沒有額外空間對他進行分配擔保,就必須使用標記整理來進行回收。

GC 過程

1:在初始階段,新創建的對象都會被分到Eden區,這時候Survivor區域是空的

2:當Eden區滿了的時候,這時候就會觸發Minor GC進行新生代的垃圾回收,存活下來的對象將會被存入Survivor的to區域此時對象的年齡爲1,之後清空Eden空間。

3:當下一次Minor GC來臨時候依然會重複這個過程,只不過這次Survivor區域的from和to會交換身份。同時上次Minor GC倖存下來的對象會被複制到新的to區域此時年齡再次加1。

4:經過多次Minor GC後,當存活對象的年齡達到一個閾值之後(可通過參數配置,默認是8),就會從年輕代Promotion到老年代(大對象或者老年代對象對象會直接進入老年代,如果to空間已滿,則對象也會直接進入老年代)。

隨着Minor GC一次又一次的進行,不斷的有新對象被promote到老年代。最終Major GC被觸發回收使用標記壓縮來回收老年代對象。

卡表

對於新生代和老年代來說,通常新生代回收頻率很高,但是每次回收的耗時很短;而老年代回收頻率比較低,但是會消耗更多的時間。爲了支持新生代回收的高刷新率,虛擬機使用了一種叫卡表(Card Table)的數據結構。卡表爲一個比特位集合,每一個比特位用來標識老年代某一區域中的所有對象是否持有新生代對象的引用。

這樣Minor GC的時候,就不用花大量時間掃描所有老年代對象對象,來確定每一個對象的引用關係而是先掃描卡表,當卡表標記爲1時,才需要掃描給定區域的老年代對象,而卡表位0所在的老年代區域一定沒有任何對象指向新生代。使用這樣的方式可以加速新生代的回收速度。

本文腦圖

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章