你必須了解的java內存管理機制(三)-垃圾標記


本文在個人技術博客不同步發布,詳情可用力戳
亦可掃描屏幕右側二維碼關注個人公眾號,公眾號內有個人聯系方式,等你來撩...

相關鏈接(注:文章講解JVM以Hotspot虛擬機為例,jdk版本為1.8)
1、 你必須了解的java內存管理機制-運行時數據區
2、 你必須了解的java內存管理機制-內存分配
3、 你必須了解的java內存管理機制-垃圾標記

前言

  前面花了兩篇文章對JVM的內存管理機制做了較多的介紹,通過第一篇文章先了解了JVM的運行時數據區,然后在第二篇文章中通過一個創建對象的實例介紹了JVM的內存分配的相關內容!那么,萬眾矚目的JVM垃圾回收是時候登場了!JVM垃圾回收這塊的內容相對較多、較復雜。但是,想要做好JVM的性能調優,這塊的內容又必須了解和掌握!

正文

1、怎么找到存活對象?

  通過上篇文章我們知道,JVM創建對象時會通過某種方式從內存中划分一塊區域進行分配。那么當我們服務器源源不斷的接收請求的時候,就會頻繁的需要進行內存分配的操作,但是我們服務器的內存確是非常有限的呢!所以對不再使用的內存進行回收再利用就成了JVM肩負的重任了! 那么,擺在JVM面前的問題來了,怎么判斷哪些內存不再使用了?怎么合理、高效的進行回收操作?既然要回收,那第一步就是要找到需要回收的對象!

1.1、引用計數法

  實現思路:給對象添加一個引用計數器,每當有一個地方引用它,計數器加1。當引用失效,計數器值減1。任何時刻計數器值為0,則認為對象是不再被使用的。舉個小栗子,我們有一個People的類,People類有id和bestFriend的屬性。我們用People類來造兩個小人:

      People p1 = new People();
  	People p2 = new People();

  通過上篇文章的知識我們知道,當方法執行的時候,方法的局部變量表和堆的關系應該是如下圖的(注意堆中對象頭中紅色括號內的數字,就是引用計數器,這里只是舉栗,實際實現可能會有差異):

  

  造出來的p1和p2兩個人,我想讓他們互為最好的朋友,於是代碼如下:

	People p1 = new People();
	People p2 = new People();
	p1.setBestFriend(p2);
	p2.setBestFriend(p1);

  對應的引用關系圖應該如下(注意引用計數器值的變化):

  

  然后我們再做一些處理,去除變量和堆中對象的引用關系。

		People p1 = new People();
		People p2 = new People();
		
		p1.setBestFriend(p2);
		p2.setBestFriend(p1);
		
		p1 = null;
		p2 = null;

  這時候引用關系圖就變成如下了,由於p1和p2對象還相互引用着,所以引用計數器的值還為1。

  

  優點:實現簡單,效率高。
  缺點:很難解決對象之間的相互循環引用。且開銷較大,頻繁的引用變化會帶來大量的額外運算。在談實現思路的時候有這樣一句話“任何時刻計數器值為0,則認為對象是不再被使用的”。但是通過上面的例子我們可以看到,雖然對象已經不再使用了,但計數器的值仍然是1,所以這兩個對象不會被標記為垃圾。
  現狀:主流的JVM都沒有選用引用計數法來管理內存。

1.2、可達性分析

  實現思路:通過GC Roots的對象作為起始點,從這些節點向下搜索,搜索走過的路徑成為引用鏈,當一個對象到GC Root沒有任何引用鏈相連時,則證明對象是不可用的。如下圖,紅色的幾個對象由於沒有跟GC Root沒有任何引用鏈相連,所以會進行標記。

  

  優點:可以很好的解決對象相互循環引用的問題。
  缺點:實現比較復雜;需要分析大量數據,消耗大量時間;
  現狀:主流的JVM(如HotSpot)都選用可達性分析來管理內存。

2、標記死亡對象

  通過可達性分析可以對需要回收的對象進行標記,是否標記的對象一定會被回收呢?並不是呢!要真正宣告一個對象的死亡,至少要經歷兩次的標記過程!

2.1、第一次標記

  在可達性分析后發現到GC Roots沒有任何引用鏈相連時,被第一次標記。並且判斷此對象是否必要執行finalize()方法!如果對象沒有覆蓋finalize()方法或者finalize()已經被JVM調用過,則這個對象就會認為是垃圾,可以回收。對於覆蓋了finalize()方法,且finalize()方法沒有被JVM調用過時,對象會被放入一個成為F-Queue的隊列中,等待着被觸發調用對象的finalize()方法。

2.2、第二次標記

  執行完第一次的標記后,GC將對F-Queue隊列中的對象進行第二次小規模標記。也就是執行對象的finalize()方法!如果對象在其finalize()方法中重新與引用鏈上任何一個對象建立關聯,第二次標記時會將其移出"即將回收"的集合。如果對象沒有,也可以認為對象已死,可以回收了。

  finalize()方法是被第一次標記對象的逃脫死亡的最后一次機會。在jvm中,一個對象的finalize()方法只會被系統調用一次,經過finalize()方法逃脫死亡的對象,第二次不會再調用。由於該方法是在對象進行回收的時候調用,所以可以在該方法中實現資源關閉的操作。但是,由於該方法執行的時間是不確定的,甚至,在java程序不正常退出的情況下該方法都不一定會執行!所以在正常情況下,盡量避免使用!如果需要"釋放資源",可以定義顯式的終止方法,並在"try-catch-finally"的finally{}塊中保證及時調用,如File相關類的close()方法。下面我們看一個在finalize中逃脫死亡的栗子吧:

public class GCDemo {
    public static GCDemo gcDemo = null;

    public static void main(String[] args) throws InterruptedException {

      gcDemo = new GCDemo();
        System.out.println("------------對象剛創建------------");
        if (gcDemo != null) {
            System.out.println("我還活得好好的!");
        } else {
            System.out.println("我死了!");
        }

        gcDemo = null;
        System.gc();
        System.out.println("------------對象第一次被回收后------------");
        Thread.sleep(500);// 由於finalize方法的調用時間不確定(F-Queue線程調用),所以休眠一會兒確保方法完成調用
        if (gcDemo != null) {
            System.out.println("我還活得好好的!");
        } else {
            System.out.println("我死了!");
        }

        gcDemo = null;
        System.gc();
        System.out.println("------------對象第二次被回收后------------");
        Thread.sleep(500);
        if (gcDemo != null) {
            System.out.println("我還活得好好的!");
        } else {
            System.out.println("我死了!");
        }

        // 后面無論多少次GC都不會再執行對象的finalize方法
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("execute method finalize()");
        gcDemo = this;
    }
}

  執行結果如下,具體就不多說啦,不明白的就自己動手去試試吧!

  

3、枚舉根節點

  通過上面可達性分析我們了解了有哪些GC Root,了解了通過這些GC Root去搜尋並標記對象是生存還是死亡的思路。但是具體的實現就是那張圖顯示的那么簡單嗎?當然不是,因為我們的堆是分代收集的,那GC Root連接的對象可能在新生代,也可能在老年代,新生代的對象可能會引用老年代的對象,老年代的對象也可能引用新生代。如果直接通過GC Root去搜尋,則每次都會遍歷整個堆,那分代收集就沒法實現了呢!並且,枚舉整個根節點的時候是需要線程停頓的(保證一致性,不能出現正在枚舉 GC Roots,而程序還在跑的情況,這會導致 GC Roots 不斷變化,產生數據不一致導致統計不准確的情況),而枚舉根節點又比較耗時,這在大並發高訪問量情況下,分分鍾就會導致系統癱瘓!啥意思呢,下面一張圖感受一下:

  

  如果是進行根節點枚舉,我們先要全棧掃描,找到變量表中存放為reference類型的變量,然后找到堆中對應的對象,最后遍歷對象的數據(如屬性等),找到對象數據中存放為指向其他reference的對象……這樣的開銷無疑是非常大的!

  為解決上述問題,HotSpot 采用了一種 “准確式GC” 的技術,該技術主要功能就是讓虛擬機可以准確的知道內存中某個位置的數據類型是什么,比如某個內存位置到底是一個整型的變量,還是對某個對象的reference,這樣在進行 GC Roots枚舉時,只需要枚舉reference類型的即可。那怎么讓虛擬機准確的知道哪些位置存在的是reference類型數據呢?OopMap+RememberedSet!

  OopMap記錄了棧上本地變量到堆上對象的引用關系,在GC發生時,線程會運行到最近的一個安全點停下來,然后更新自己的OopMap,記下棧上哪些位置代表着引用。枚舉根節點時,遞歸遍歷每個棧幀的OopMap,通過棧中記錄的被引用對象的內存地址,即可找到這些對象( GC Roots )。這樣,OopMap就避免了全棧掃描,加快枚舉根節點的速度。

  OopMap解決了枚舉根節點耗時的問題,但是分代收集的問題依然存在!這時候就需要另一利器了- RememberedSet。對於位於不同年代對象之間的引用關系,會在引用關系發生時,在新生代邊上專門開辟一塊空間記錄下來,這就是RememberedSet!所以“新生代的 GC Roots ” + “ RememberedSet存儲的內容”,才是新生代收集時真正的GC Roots(G1 收集器也使用了 RememberedSet 這種技術)。

3.1、安全點

  HotSpot在OopMap的幫助下可以快速且准確的完成GC Roots枚舉,但是在運行過程中,非常多的指令都會導致引用關系變化,如果為這些指令都生成對應的OopMap,需要的空間成本太高。所以只在特定的位置記錄OopMap引用關系,這些位置稱為安全點(Safepoint)。如何在GC發生時讓所有線程(不包括JNI線程)運行到其所在最近的安全點上再停頓下來?這里有兩種方案:

  1、搶先式中斷:不需要線程的執行代碼去主動配合,當發生GC時,先強制中斷所有線程,然后如果發現某些線程未處於安全點,那么將其喚醒,直至其到達安全點再次將其中斷。這樣一直等待所有線程都在安全點后開始GC。

  2、主動式中斷:不強制中斷線程,只是簡單地設置一個中斷標記,各個線程在執行時主動輪詢這個標記,一旦發現標記被改變(出現中斷標記)時,就將自己中斷掛起。目前所有商用虛擬機全部采用主動式中斷。

  安全點既不能太少,以至於 GC 過程等待程序到達安全點的時間過長,也不能太多,以至於 GC 過程帶來的成本過高。安全點的選定基本上是以程序“是否具有讓程序長時間執行的特征”為標准進行選定的,例如方法調用、循環跳轉、異常跳轉等,所以具有這些功能的指令才會產生安全點(在主動式中斷中,輪詢標志的地方和安全點是重合的,所以線程在遇到這些指令時都會去輪詢中斷標志!)。

3.2、安全區域

  使用安全點似乎已經完美解決如何進入GC的問題了,但是GC發生的時候,某個線程正在睡覺(sleep),無法響應JVM的中斷請求,這時候線程一旦醒來就會繼續執行了,這會導致引用關系發生變化呢!所以需要安全區域的思路來解決這個問題。線程執行進入安全區域,首先標識自己已經進入安全區域。線程被喚醒離開安全區域時,其需要檢查系統是否已經完成根節點枚舉(或整個GC)。如果已經完成,就繼續執行,否則必須等待,直到收到可以安全離開Safe Region的信號通知!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM