Java物件的記憶體佈局

JaJian發表於2020-09-21

今天來講些抽象的東西 -- 物件頭,因為我在學習的過程中發現很多地方都關聯到了物件頭的知識點,例如JDK中的 synchronized鎖優化 和 JVM 中物件年齡升級等等。要深入理解這些知識的原理,瞭解物件頭的概念很有必要,而且可以為後面分享 synchronized 原理和 JVM 知識的時候做準備。

物件記憶體構成

Java 中通過 new 關鍵字建立一個類的例項物件,物件存於記憶體的堆中並給其分配一個記憶體地址,那麼是否想過如下這些問題:

  • 這個例項物件是以怎樣的形態存在記憶體中的?
  • 一個Object物件在記憶體中佔用多大?
  • 物件中的屬性是如何在記憶體中分配的?

在 JVM 中,Java物件儲存在堆中時,由以下三部分組成:

  • 物件頭(object header):包括了關於堆物件的佈局、型別、GC狀態、同步狀態和標識雜湊碼的基本資訊。Java物件和vm內部物件都有一個共同的物件頭格式。
  • 例項資料(Instance Data):主要是存放類的資料資訊,父類的資訊,物件欄位屬性資訊。
  • 對齊填充(Padding):為了位元組對齊,填充的資料,不是必須的。

物件頭

我們可以在Hotspot官方文件中找到它的描述(下圖)。從中可以發現,它是Java物件和虛擬機器內部物件都有的共同格式,由兩個(計算機術語)組成。另外,如果物件是一個Java陣列,那在物件頭中還必須有一塊用於記錄陣列長度的資料,因為虛擬機器可以通過普通Java物件的後設資料資訊確定Java物件的大小,但是從陣列的後設資料中無法確定陣列的大小。

它裡面提到了物件頭由兩個組成,這兩個是什麼呢?我們還是在上面的那個Hotspot官方文件中往上看,可以發現還有另外兩個名詞的定義解釋,分別是 mark wordklass pointer

從中可以發現物件頭中那兩個字:第一個字就是 mark word,第二個就是 klass pointer

Mark Word

用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等等。

Mark Word在32位JVM中的長度是32bit,在64位JVM中長度是64bit。我們開啟openjdk的原始碼包,對應路徑/openjdk/hotspot/src/share/vm/oops,Mark Word對應到C++的程式碼markOop.hpp,可以從註釋中看到它們的組成,本文所有程式碼是基於Jdk1.8。

Mark Word在不同的鎖狀態下儲存的內容不同,在32位JVM中是這麼存的

在64位JVM中是這麼存的

雖然它們在不同位數的JVM中長度不一樣,但是基本組成內容是一致的。

  • 鎖標誌位(lock):區分鎖狀態,11時表示物件待GC回收狀態, 只有最後2位鎖標識(11)有效。
  • biased_lock:是否偏向鎖,由於正常鎖和偏向鎖的鎖標識都是 01,沒辦法區分,這裡引入一位的偏向鎖標識位。
  • 分代年齡(age):表示物件被GC的次數,當該次數到達閾值的時候,物件就會轉移到老年代。
  • 物件的hashcode(hash):執行期間呼叫System.identityHashCode()來計算,延遲計算,並把結果賦值到這裡。當物件加鎖後,計算的結果31位不夠表示,在偏向鎖,輕量鎖,重量鎖,hashcode會被轉移到Monitor中。
  • 偏向鎖的執行緒ID(JavaThread):偏向模式的時候,當某個執行緒持有物件的時候,物件這裡就會被置為該執行緒的ID。 在後面的操作中,就無需再進行嘗試獲取鎖的動作。
  • epoch:偏向鎖在CAS鎖操作過程中,偏向性標識,表示物件更偏向哪個鎖。
  • ptr_to_lock_record:輕量級鎖狀態下,指向棧中鎖記錄的指標。當鎖獲取是無競爭的時,JVM使用原子操作而不是OS互斥。這種技術稱為輕量級鎖定。在輕量級鎖定的情況下,JVM通過CAS操作在物件的標題字中設定指向鎖記錄的指標。
  • ptr_to_heavyweight_monitor:重量級鎖狀態下,指向物件監視器Monitor的指標。如果兩個不同的執行緒同時在同一個物件上競爭,則必須將輕量級鎖定升級到Monitor以管理等待的執行緒。在重量級鎖定的情況下,JVM在物件的ptr_to_heavyweight_monitor設定指向Monitor的指標。

Klass Pointer

即型別指標,是物件指向它的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。

例項資料

如果物件有屬性欄位,則這裡會有資料資訊。如果物件無屬性欄位,則這裡就不會有資料。根據欄位型別的不同佔不同的位元組,例如boolean型別佔1個位元組,int型別佔4個位元組等等;

對齊資料

物件可以有對齊資料也可以沒有。預設情況下,Java虛擬機器堆中物件的起始地址需要對齊至8的倍數。如果一個物件用不到8N個位元組則需要對其填充,以此來補齊物件頭和例項資料佔用記憶體之後剩餘的空間大小。如果物件頭和例項資料已經佔滿了JVM所分配的記憶體空間,那麼就不用再進行對齊填充了。

所有的物件分配的位元組總SIZE需要是8的倍數,如果前面的物件頭和例項資料佔用的總SIZE不滿足要求,則通過對齊資料來填滿。

為什麼要對齊資料?欄位記憶體對齊的其中一個原因,是讓欄位只出現在同一CPU的快取行中。如果欄位不是對齊的,那麼就有可能出現跨快取行的欄位。也就是說,該欄位的讀取可能需要替換兩個快取行,而該欄位的儲存也會同時汙染兩個快取行。這兩種情況對程式的執行效率而言都是不利的。其實對其填充的最終目的是為了計算機高效定址。

至此,我們已經瞭解了物件在堆記憶體中的整體結構佈局,如下圖所示

Talk is cheap, show me code

概念的東西是抽象的,你說它是這樣組成的,就真的是嗎?學習是需要持懷疑的態度的,任何理論和概念只有自己證實和實踐之後才能接受它。還好 openjdk 給我們提供了一個工具包,可以用來獲取物件的資訊和虛擬機器的資訊,我們只需引入 jol-core 依賴,如下

<dependency>
  <groupId>org.openjdk.jol</groupId>
  <artifactId>jol-core</artifactId>
  <version>0.8</version>
</dependency>

jol-core 常用的三個方法

  • ClassLayout.parseInstance(object).toPrintable():檢視物件內部資訊.
  • GraphLayout.parseInstance(object).toPrintable():檢視物件外部資訊,包括引用的物件.
  • GraphLayout.parseInstance(object).totalSize():檢視物件總大小.

普通物件

為了簡單化,我們不用複雜的物件,自己建立一個類 D,先看無屬性欄位的時候

public class D {
}

通過 jol-core 的 api,我們將物件的內部資訊列印出來

public static void main(String[] args) {
    D d = new D();
    System.out.println(ClassLayout.parseInstance(d).toPrintable());
}

最後的列印結果為

可以看到有 OFFSET、SIZE、TYPE DESCRIPTION、VALUE 這幾個名詞頭,它們的含義分別是

  • OFFSET:偏移地址,單位位元組;
  • SIZE:佔用的記憶體大小,單位為位元組;
  • TYPE DESCRIPTION:型別描述,其中object header為物件頭;
  • VALUE:對應記憶體中當前儲存的值,二進位制32位;

可以看到,d物件例項共佔據16byte,物件頭(object header)佔據12byte(96bit),其中 mark word佔8byte(64bit),klass pointe 佔4byte,另外剩餘4byte是填充對齊的。

這裡由於預設開啟了指標壓縮 ,所以物件頭佔了12byte,具體的指標壓縮的概念這裡就不再闡述了,感興趣的讀者可以自己查閱下官方文件。jdk8版本是預設開啟指標壓縮的,可以通過配置vm引數開啟關閉指標壓縮,-XX:-UseCompressedOops

如果關閉指標壓縮重新列印物件的記憶體佈局,可以發現總SIZE變大了,從下圖中可以看到,物件頭所佔用的記憶體大小變為16byte(128bit),其中 mark word佔8byte,klass pointe 佔8byte,無對齊填充。

開啟指標壓縮可以減少物件的記憶體使用。從兩次列印的D物件佈局資訊來看,關閉指標壓縮時,物件頭的SIZE增加了4byte,這裡由於D物件是無屬性的,讀者可以試試增加幾個屬性欄位來看下,這樣會明顯的發現SIZE增長。因此開啟指標壓縮,理論上來講,大約能節省百分之五十的記憶體。jdk8及以後版本已經預設開啟指標壓縮,無需配置。

陣列物件

上面使用的是普通物件,我們來看下陣列物件的記憶體佈局,比較下有什麼異同

public static void main(String[] args) {
    int[] a = {1};
    System.out.println(ClassLayout.parseInstance(a).toPrintable());
}

列印的記憶體佈局資訊,如下

可以看到這時總SIZE為共24byte,物件頭佔16byte,其中Mark Work佔8byte,Klass Point 佔4byte,array length 佔4byte,因為裡面只有一個int 型別的1,所以陣列物件的例項資料佔據4byte,剩餘對齊填充佔據4byte。

結尾

經過以上的內容我們瞭解了物件在記憶體中的佈局,瞭解物件的記憶體佈局和物件頭的概念,特別是物件頭的Mark Word的內容,在我們後續分析 synchronize 鎖優化 和 JVM 垃圾回收年齡代的時候會有很大作用。

JVM中大家是否還記得物件在Suvivor中每熬過一次MinorGC,年齡就增加1,當它的年齡增加到一定程度後就會被晉升到老年代中,這個次數預設是15歲,有想過為什麼是15嗎?在Mark Word中可以發現標記物件分代年齡的分配的空間是4bit,而4bit能表示的最大數就是2^4-1 = 15。

相關文章