1. 程式人生 > >jvm-垃圾回收簡單分析

jvm-垃圾回收簡單分析

首先看看傳統的垃圾回收演算法

引用計數法和可達性分析法
       引用計數法:給每一個物件新增一個引用計數器,表示這個物件的引用個數,一旦引用個數為0的時候,說明該物件已經沒用了,就可以回收了。缺點是需要額外的空間,額外的更新操作,且不能處理迴圈引用。一旦有物件互相引用你  即 A引用B,B引用A,如果使用引用計數法那這兩個物件就永遠不會回收造成記憶體洩漏。

       可達性分析法:將一系列GCRoots作為根集合,然後從這個集合出發,尋找所有可以被這個集合引用的物件,再把物件加入到這個集合裡。最終沒有在集合裡面的物件就是要回收的物件。一般來講,GCRoots包括以下幾種

  • 已經載入的類的靜態變數
  • 區域性變數
  • JNI handles
  • 已啟動且未停止的執行緒

       可達性分析法可以解決引用計數法不能解決的迴圈引用問題。但預想很美好,在實際處理中還是有不少問題的。譬如在多執行緒環環境裡,在GC已經標記完成的情況下,執行緒將引用設定成null而造成的誤報,將引用設定成未被訪問的物件而造成的漏報。誤報對於jvm來講,最多損失一部分效能,對於其他處理沒什麼影響。而漏報則很苦逼了,因為回收了還被正在使用中的物件,那麼運氣好就執行緒崩潰,但很大的可能是直接導致jvm崩潰。

        為了解決這個問題,在傳統的jvm的垃圾回收演算法就引入了一種簡單粗暴的方式 叫Stop-the-world。就是停止非垃圾回收執行緒的工作,直到垃圾回收完成。jvm是通過safepoint機制來實現Stop-the-world的功能的。即jvm收到Stop-the-world請求,會等待所有執行緒都到達了safepoint,才會允許Stop-the-world繼續執行下去。

        safepoint的目的並不是要讓非垃圾回收執行緒停止工作,而是讓費垃圾回收執行緒處於一個穩定的執行狀態,在這個執行狀態裡面,jvm的堆疊不會發生變化。使垃圾回收器能更安全的執行可達性分析法去GC。列舉一下執行緒的幾種狀態  java程式通過JNI執行原生代碼;解釋執行位元組碼,執行即時編譯器生成的機器碼,以及執行緒阻塞。

        其中執行JNI原生代碼需要通過JNI的API,如果這段程式碼不訪問java物件,呼叫java方法,那jvm的堆疊並不會發生任何變化,所以這段原生代碼就可作為同一個safepoint。即只需要在API的入口處進行safepoint檢測,是否有其他執行緒停留在safepoint裡。

        阻塞的執行緒處於jvm執行緒排程器裡,所以也屬於safepoint。其他幾種則是執行時狀態,需要虛擬機器保證在可以預計的時間裡進入safepoint,不然垃圾回收執行緒就要長時間的處於等待狀態,從而變相的提高了Stop-the-world時間。

垃圾回收的方式
        使用垃圾護手演算法標記完所有存活的物件後,就可以進行垃圾回收工作了。主流的基礎回收演算法有三種

  • 清除 :把死亡物件佔據的記憶體標記為空閒記憶體,並記錄在一個列表裡面,當需要新建物件的時候,就從列表裡尋找空閒記憶體,劃分給新建的物件。 這種方法簡單粗暴  ,但有兩個廳嚴重的缺陷,
  1. 記憶體碎片會很多。因為jvm的堆中的物件必須是連續的,因此會出現總的空閒記憶體足夠,在新分配的記憶體比空閒的連續記憶體大,導致無法分配的情況
  2. 分配效率低下。要給新物件分配記憶體,jvm就要遍歷空閒列表。
  • 壓縮:把存活的物件全都放在記憶體的起始位置,從而留下一片連續的記憶體空間。這種方式是可以解決碎片化的問題,但代價則是及其低下的效能
  • 複製:把記憶體分為兩份,使用from和to指標來分別維護。並只用form指標來分配記憶體。當發生垃圾回收時,就把存活的物件複製到to指標指向的區域,並交換from和to指標的記憶體。有點是也可以解決記憶體碎片的問題,缺點是記憶體使用率極其低下。

       jvm將虛擬機器劃分為了兩代,一個叫新生代,另一個叫老年代。新生代用來儲存新建的物件,而當物件的年齡夠大了,就會將它移動到老年代裡。

       對於老年代而言,jvm猜測是大部分需要回收的都在新生代裡回收了,而存活下來的物件有很大的概率可以繼續存活下去。當然,當觸發老年代回收時,意味著猜測錯誤或者堆記憶體耗盡,這時候就要進行full gc了。

       新生代被劃分為Eden區和兩個Survivor。預設情況下jvm採用的是動態分配的策略來調整Eden和Survivor區的比例。通常來講,在你使用new指令生成物件時,就會在Eden區劃分一塊記憶體來儲存物件。當然,堆對於執行緒來講是共享的,因此劃分記憶體是需要同步的。但如果真使用同步那就太影響效率了,根本跟不上現代處理器的速度。所以,jvm的解決辦法是預先生氣一批記憶體。當預先申請的記憶體用完了,那就再申請就完事了。這個呢就叫做Thread Local Allocation Buffer。對應引數是-XX:UseTLAB 預設是開啟狀態。執行緒申請記憶體後,會維護多個指標。其中重要的指標有兩個。一個指向TLAB空餘記憶體的起始位置,另一個指向TLAB的末尾。

       當Eced區的空間耗盡了,就會觸發一次Minor GC,在這次GC存活下來的物件,就會被送到Survivor。新生代的Survivor有兩個,可以用from和to來代表。其中  to指向的區域是空的。當發生Minor GC,Eden去和From區中存活下來的物件就會被複制到to區,然後在交換from和to的指標,以保證下次GC時,to區還是空的。

        jvm會記錄Survivor區中的物件存活了幾次GC,因為jvm中用來代表物件年齡的只有4位,所以一個物件被存活了15次GC後,該物件就會被一道老年代。設定次數的jvm引數是-XX:-MaxTenuringThreshold  。另外,如果單個Survivor的記憶體被佔用了超過50%  那麼年齡較大的物件也會被複制到老年代。

        Minor GC的好處一個是不用進行全表掃描,但有個問題就是老年代的物件可能會引用新生代的物件。如果按現在的邏輯就是說在標記存活物件的時候需要對老年代的進行掃描,這樣一來不就也是進行了一次全表掃描?為了解決這個問題,HotSpot給的方案叫做Card Table 。Card Table 將整個堆劃分成一個個大小為512位元組的的card,且還會維護一張表,用來標識每張card。,這個標識位如果指向的Card Table存有指向新生代的引用,那麼在Minor GC時,就需要把這張Card Table 裡的物件加入GC Roots裡面。當完成了所有的Card Table掃描時,就會把那些標識位全部清零。由於Minor GC後,存活物件會有複製,複製就需要更新指向這個物件的引用,因此在更新引用的時候,就可以設定引用所在Card Table的標識位,這樣就能確保Card Table必定包含指向新生代的引用。