1. 程式人生 > >詳解JVM記憶體管理與垃圾回收機制2 - 何為垃圾

詳解JVM記憶體管理與垃圾回收機制2 - 何為垃圾

隨著程式語言的發展,GC的功能不斷增強,效能也不斷提高,作為語言背後的無名英雄,GC離我們的工作似乎越來越遠。作為Java程式設計師,對這一點也許會有更深的體會,我們不需要了解太多與GC相關的知識,就能很好的完成工作。那還有必要深入瞭解GC嗎?學習GC的意義在哪兒?

不管效能提高到何種程度,GC都需要花費一定的時間,對於實時性要求較高的場景,就必須儘量壓低GC導致的最大暫停時間 (GC會導致應用執行緒處於暫停狀態),舉兩個例子:

  • 實時對戰遊戲:如果因為GC導致玩家頻繁卡頓,任誰都會想摔手機吧。
  • 金融交易:在某些對價格非常敏感的交易場景下(比如,外匯交易中價格的變動非常頻繁),如果因為GC導致沒有按照交易者指定的價格進行交易,相信我,這些交易者非生吃了你。

但也有許多場景,GC的最大暫停時間沒那麼重要,比如,離線分析、視訊網站等等。因此,知道這個GC演算法有這樣的特徵,所以它適合這個場景,對程式設計師來說非常有價值,這就是我們學習GC最重要的意義。接下來,我們將一步步走進GC的世界。

從誕生之初,人們就在思考GC需要完成的3件事情:何為垃圾?何時回收?如何回收?垃圾收集器在對記憶體進行回收前,第一件事就是要確定這些物件之中哪些還”活著“,哪些已經”死去“,而這些”死去“的物件,也就是我們所說的垃圾。

引用計數法

判斷物件是否存活,其中一種方法是給物件新增一個引用計數器,每當有一個地方引用它,計數器的值就加1,當引用失效時,計數器的值減1,任一時刻,如果物件的計數器值為0,那麼這個物件就不會再被使用,這種方法被稱為引用計數法。在整個回收過程中,引用計數器的值會以極快的速度更新,因而計數值的更新任務變得繁重,而且需要給計數器預留足夠大的記憶體空間,以確保它不會溢位。因此,引用計數法的演算法很簡單,但在實際運用中要考慮非常多的因素,所以它的實現往往比較複雜,更為重要的是它不能解決物件之間的迴圈引用問題。

舉個栗子,下面的程式碼片段展示了為什麼引用計數法無法解決迴圈引用的問題。

public class GcDemo {
    public static void main(String[] args) {
        // 在棧中分配記憶體空間給obj1,然後在堆中建立GcObject物件A
        // 將obj1指向A例項,這時A的引用計數值 = 1
        GcObject obj1 = new GcObject();
        // 同理,GcObject例項B的引用計數值 = 1
        GcObject obj2 = new GcObject();
        // GcObject例項2被引用,所以B引用計數值 = 2
        obj1.instance = obj2;
        // 同理A的引用計數值 = 2
        obj2.instance = obj1;
        // 棧中的obj1不再指向堆中A,這時A的計數值減1,變成1
        obj1 = null;
        // 棧中的obj2不再指向堆中B,這時B的計數值減1,變成1
        obj2 = null;
    }
}

class GcObject {
    public Object instance = null;
}

仔細閱讀程式碼中的註釋,並結合下面的記憶體結構示意圖,應該可以很好的理解其中的原因:如果JVM垃圾收集器採用引用計數法,當obj1和obj2不再指向堆中的例項A、B時,雖然A、B已經不可能再被訪問,但彼此間相互引用導致計數器的值不為0,最終導致無法回收A和B。

可達性分析

引用計數法有一個致命的問題,即無法釋放有迴圈引用的垃圾,因此,主流的Java虛擬機器都沒有選用引用計數法來管理記憶體,而是通過可達性分析 (Reachability Analysis)來判定物件是否存活。
可達性分析的基本思路是找到一系列被稱為”GC Roots“的物件引用 (Reference) 作為起始節點,通過引用關係向下搜尋,能被遍歷到的 (可到達的) 物件就被判定為存活,其餘物件 (也就是沒有被遍歷到的) 自然被判定為死亡。這裡需要著重理解的是:可達性分析本質是找出活的物件來把其餘空間判定為“無用”,而不是找出所有死掉的物件並回收它們佔用的空間,簡略的示意圖如下所示。

從圖中可以看出,經過可達性分析後,有不少物件沒有在GC Roots的引用鏈條上,其中還包含一些相互引用的物件,這些物件在不久以後都會被垃圾收集器回收,因此,可達性分析演算法可以有效解決引用計數法存在的致命問題。

但是,首次被標記的物件並一定會被回收,它還有自救的機會。一個物件真正的死亡至少需要經歷兩次標記過程:

標記所有不可達物件,並進行篩選,篩選的標準是該物件覆蓋了finalize()方法且finalize()方法沒有被虛擬機器呼叫過,選出的物件將被放置在一個“即將被回收”的佇列中。稍後虛擬機器會建立一個低優先順序的Finalizer執行緒去遍歷佇列中的所有物件並執行finalize()方法
對佇列中的物件進行第二次標記,如果物件在finalize()方法中重新與引用鏈上的任何一個物件建立關聯,那麼這個物件將被移除佇列,而還留在佇列中的物件,就會被回收了。

要正確的實現可達性分析演算法,就必須完整地枚舉出所有的GC Roots,否則就有可能會漏掉本應存活的物件,如果垃圾收集器錯誤的回收了這些被漏掉的活物件,將會造成嚴重的bug。GC Roots作為垃圾回收的起點,必須是一些列活的引用 (Reference) 集合,那這個集合中究竟包含哪些引用?為什麼這些引用可以作為GC Roots?要回答好這兩個問題,需要對Java物件在記憶體中佈局有一些初步的瞭解,所以,在下節會對相關知識進行補充。

參考資料

周志明 著; 深入理解Java虛擬機器(第2版); 機械工業出版社,2013
知乎上關於GC ROOTS的問題

作者:CHEN川
連結:https://www.jianshu.com/p/d9840ebdea25


掃碼關注有驚喜

(轉載本站文章請註明作者和出處 方誌朋的部落格