Java對象內存佈局概述

以HotSpot虛擬機爲例,對象在內存中可以分爲三塊區域:對象頭實例數據對齊填充。其中,對象頭包含Mark Word和類型指針,關於對象頭的內容,在gitchat中對其實現和原理都已經結合openjdk源碼進行了詳細的說明,其也不是本博文的主題,這裏就不細說了;實例數據部分則是對象真正存儲的有效信息,包含代碼中所定義的字段內容;對齊填充則不是必須存在的,只是起佔位符的作用,比如Hot Spot虛擬機要求對象大小必須是8字節的整數倍,而對象頭剛好是8字節的倍數,所以當對象的實例數據沒有對齊時,就需要通過對齊填充來補全。

注:關於類型指針,虛擬機可以通過這個它來確認該對象的元數據信息,比如它屬於哪個類的實例。但是我們要注意,並不是所有的虛擬機都必須以這種方式來確定對象的元數據信息。對象的訪問定位一般有句柄直接指針兩種,如果使用句柄的話,那麼對象的元數據信息可以直接包含在句柄中(當然也包括對象實例數據的地址信息),也就沒必要將這些元數據和實例數據存儲在一起了。至於實例數據和對齊填充,這裏暫不做討論。

一個對象字段既包括自身定義的,也包括從父類繼承下來的,這些字段會按照順序存儲下來。而具體的存儲順序會受到虛擬機分配策略參數和字段在代碼中定義順序的影響。默認爲longs/doubles、ints、shorts/chars、bytes/booleans、references,可以看到,相同寬度的字段總是被分配到一起。在滿足整個前提瞧見的情況下,父類中的字段會出現在子類之前。如果開啓了CompactFields,那麼子類中較窄的字段可能會插入到父類字段的空隙之中。

簡單來說就是,大字段在前,小字段在後,references最後,同大小看聲明順序,然後在考慮父類和CompactFields的情況。其中對於reference類型字段的位置,JVM有個參數FieldsAllocationStyle控制,其取不同的值會有不同的策略:

  •  0:references, longs/doubles, ints, shorts/chars, bytes, 對齊填充

  •  1:longs/doubles, ints, shorts/chars, bytes, references, 對齊填充

  •  2:使父類reference字段和子類reference字段挨在一起

對一個對象,我們需要對其在內存中的存儲有個大致的想象:這塊內存空間是如何分配的。我們以64位系統舉例,假設現在有以下類Test,其包含兩個字段,一個int類型的value1和一個long類型的value2:

public class Test {
    private long value2;
    private int value1;
}

首先我們考慮對象頭,64位系統中,Mark Word佔8個字節;開啓了壓縮指針,類型指針佔4個字節。那麼整個對象頭就佔了12個字節。

接下來是實例數據,value1是int類型,佔4個字節,vaue2爲long類型,佔8個字節。這個情況下,整個對象佔12+4+8=24,正好是8的整數倍,所以不需要對齊填充。那麼按照我們前面介紹的分配順序,對象頭後面應該要緊跟value2纔對。但是這樣的話可能會有個問題:對象頭佔12個字節,64位系統中一次能讀取8個字節的內容,那麼讀取了對象頭的8字節之後,下一次讀取的內容其實是包含對象頭剩餘的4字節和value2其中的4個字節的,value2剩餘的4個字節要在下一次才能讀取。而我們的value1正好佔4個字節,所以將其放到對象頭之後的話就正正好。所以最終的結果就是:對象頭->value1->value2。接下來我們通過代碼驗證:

public class Test {
    private long value2;
    private int value1;

    public static void main(String[] args) throws Exception {
        Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
        unsafeField.setAccessible(true);
        Unsafe theUnsafe = (Unsafe) unsafeField.get(null);
        long offset1 = theUnsafe.objectFieldOffset(Test.class.getDeclaredField("value1"));
        long offset2 = theUnsafe.objectFieldOffset(Test.class.getDeclaredField("value2"));
        System.out.println("size:" + RamUsageEstimator.shallowSizeOf(new Test()));
        System.out.println("offset of value1:" + offset1);
        System.out.println("offset of value2:" + offset2);
    }
}

注:由於博主的項目中有使用Lucene,所以直接使用Lucene提供的RamUsageEstimator獲取對象大小,讀者朋友可以嘗試使用Instrumentation獲取。

上述代碼的輸出結果爲:

size:24
offset of value1:12
offset of value2:16

我們可以看到,總大小是24個字節。其中對象頭佔12字節,value1佔4字節,value2佔8字節,沒有對齊填充。並且value1的offset爲12,說明其是緊跟對象頭之後的;value2的offset爲16(12+4),其緊跟value1之後。這塊內存看起來像是下面這樣:

如果我們關閉指針壓縮,添加JVM啓動參數-XX:-UseCompressedOops,再看看輸出結果:

size:32
offset of value1:24
offset of value2:16

我們看到,總大小變成了32字節,value1和value2的offset也隨之發生了變化。由於關閉了指針壓縮,類型指針佔8字節,這樣對象頭就佔16字節,總大小成了28字節,但是不是8的正數倍,所以需要4個字節的對齊填充,最終大小成了32個字節。並且value2排在了value1的前面。

注:FieldsAllocationStyle和CompactFields參數對諸如Long.class、Integer.class、Class.class等是無效的,因爲這些類字段的offset被硬編碼指定了。JVM對class文件的解析主要由classFileParser#parseClassFile方法負責,而對象字段的內存佈局主要由classFileParser#layout_fields方法處理,感興趣的同學如果時間充足可以去研究研究。

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