你必須瞭解的java內存管理機——垃圾標記

正文

1、怎麼找到存活對象?

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

1.1、引用計數法

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

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

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

4e1a33c3328247d181435fd137aa9e53

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

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

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

70cf8d3cdade4256baf37288b49fb025

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

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

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

c7830050ea1941bf9e5cd8d6842957cd

優點:實現簡單,效率高。

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

現狀:主流的JVM都沒有選用引用計數法來管理內存。

1.2、可達性分析

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

9bd1224202c1488e98864137fd553b4b

優點:可以很好的解決對象相互循環引用的問題。

缺點:實現比較複雜;需要分析大量數據,消耗大量時間;

現狀:主流的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;
    }
}

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

dadd2ae4194e44298a537933f2bb403f

3、枚舉根節點

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

b2431b2b78db440093b717fe8a03abb8

如果是進行根節點枚舉,我們先要全棧掃描,找到變量表中存放爲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的信號通知!


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