Java 物件的揭秘

fuxing.發表於2024-05-26

前言

作為一個 Java 程式設計師,我們在開發中最多的操作要屬建立物件了。那麼你瞭解物件多少?它是如何建立?如何儲存佈局以及如何使用的?本文將對 Java 物件進行揭秘,以及講解如何使用 JOL 檢視物件記憶體使用情況。

本文是基於 HotSpot 虛擬機器。


一、物件是如何建立的

1. 類載入檢查

虛擬機器遇到一條 new 指令時,首先將去檢查這個指令的引數是否能在常量池中定位到這個類的符號引用,並且檢查這個符號引用代表的類是否已被載入過、解析和初始化過。如果沒有,那必須先執行相應的類載入過程。
關於如何載入詳情見 JVM 類載入機制

2. 分配記憶體空間

在類載入檢查透過後,接下來虛擬機器將為新生物件分配記憶體。物件所需的記憶體大小在類載入完成後便可確定(如何確定見下方物件的儲存佈局)。

為物件分配空間的任務等同於把一塊確定大小的記憶體從 Java 堆中劃分出來。
根據Java 堆中的記憶體是否完整,可分為“指標碰撞” 和 “空閒列表” 兩種分配方式。

分配方式 原理 使用場景 特點
指標碰撞 使用過的記憶體放到一邊,未使用過的記憶體放在另一邊。中間放一個指標作為分界點,然後向空閒區域移動與物件大小相等的距離。 堆記憶體完整 簡單高效
空閒列表 虛擬機器維護一個列表,記錄上哪些記憶體塊是可用的,在分配的時候,找足夠大的記憶體塊兒來劃分給物件例項,最後更新列表記錄。 堆記憶體不完整 (有空間碎片) 較為複雜

那麼如何判斷 Java 堆中的記憶體是否完整呢?這個是由採用的垃圾收集器決定:

  • 帶有整理記憶體空間的能力的,如 Serial、Par New 等,記憶體會比較完整。
  • 基於清除演算法的,如 CMS,記憶體會產生空間碎片。

3. 記憶體初始化

記憶體分配完成後,虛擬機器必須將分配到的記憶體空間都初始化為零值(不包括物件頭),這步操作保證了物件的例項欄位在 Java 程式碼中可以不賦初始值就直接使用,使程式能訪問到這些欄位的資料型別所對應的零值。

4. 設定物件頭

初始化零值完成之後,虛擬機器要對物件進行必要的設定,例如物件是哪個類的例項、如何才能找到類的後設資料資訊、物件的雜湊碼、物件的 GC 分代年齡等資訊,這些資訊存放在物件頭中。

另外,根據虛擬機器當前執行狀態的不同,如是否啟用偏向鎖等,物件頭會有不同的設定方式(具體描述見下方物件的儲存佈局)。

5. 執行 init 方法

在上面工作都完成之後,從虛擬機器的視角來看,一個新的物件已經產生了,但從 Java 程式的視角來看,物件建立才剛開始。Class 檔案中的init方法還沒有執行,所有的欄位都是預設的零值。

所以一般來說,執行new 指令之後會接著執行init方法,把物件按照程式設計師的意願進行初始化,這樣一個真正可用的物件才算完全產生出來。

二、物件的儲存佈局

1. 簡介

1.1 儲存佈局

如圖,在 HotSpot 虛擬機器裡,物件在堆記憶體中的儲存佈局可以劃分為三個部分:

  • 物件頭(header)
  • 例項資料(instancedata)
  • 對齊填充(padding)

1.2 資料結構

HotSpot 虛擬機器使用名為oops(Ordinary Object Pointers) 的資料結構來表示物件。這些oops等同於本地 C 指標。 instanceOops 是一種特殊的oop,表示 Java 中的物件例項。

下圖表示物件的資料結構,以及佔用記憶體大小

1.3 使用 JOL 檢視物件記憶體佈局

JOL 是 OpenJDK 官網提供了檢視物件記憶體佈局的工具,使用步驟如下。後續的列印的控制檯資訊都是透過該工具實現的。

  1. 匯入依賴
<dependency>
	<groupId>org.codehaus.plexus</groupId>
	<artifactId>plexus-utils</artifactId>
	<version>4.0.0</version>
</dependency>
  1. 使用 JOL 提供的方法
 public static void main(String[] args)  {
	 //檢視當前虛擬機器資訊
	 System.out.println(VM.current().details());

	 //檢視物件內部資訊
	 System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());

	 //檢視物件外部資訊包括引用
	 System.out.println(GraphLayout.parseInstance(new Object()).toPrintable());

	 //檢視物件佔用總大小
	 System.out.println(GraphLayout.parseInstance(new Object()).totalSize());
}

1.4 一個空的 Object 佔用多大記憶體?

空 Object 是隻有物件頭,沒有例項資料,也無需填充對齊。

如圖所示,HotSpot 64 位 虛擬機器中:

  • 普通物件: 佔用 16 位元組
  • 陣列物件: 佔用 24 位元組(物件大小須為 8 位元組整數倍)

物件頭

程式碼示例:

public static void main(String[] args)  {
	//使用 JOT 檢視物件記憶體
	System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
	System.out.println("---------------------------");
	System.out.println(ClassLayout.parseInstance(new ArrayList<>()).toPrintable());
}

image.png

2. 物件頭

2.1 Mark Word

Mark Word 用於儲存物件自身的執行時資料,如雜湊碼、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等。這部分資料的長度在32位和64位的虛擬機器(未開啟壓縮指標)中分別為 4 個位元組和 8 個位元組。

物件頭裡的資訊是與物件自身定義的資料無關的額外儲存成本,考慮到虛擬機器的空間效率,Mark Word 被設計成一個有著動態定義的資料結構。

它會根據物件的狀態複用自己的儲存空間,也就是說在執行期間 MarkWord 裡儲存的資料會隨著鎖標誌位的變化而變化,如下圖為 HotSpot 64 位的物件的儲存內容。

image.png

2.2 Klass Pointer

型別指標,即物件指向它的型別後設資料的指標,虛擬機器透過這個指標來確定該物件是哪個類的例項。注意是 Klass 不是 Class,Class Pointer是類的指標,而 Klass Pointer指的是底層 c++ 對應的類的指標。

大致流程是一個物件 new 出來以後是被放在堆裡的,類的後設資料資訊是放在方法區裡的,在 new 物件的頭部有一個指標指向方法區中該類的後設資料資訊,這個頭部的指標就是 Klass Pointer。

並且並不是所有的虛擬機器實現都必須在物件資料上保留型別指標,換句話說就是查詢物件的後設資料資訊並不一定要經過物件本身,這點我會在下一節“如何找到物件”具體討論。

HotSpot 64 位支援指標壓縮功能,根據是否開啟指標壓縮,Class Pointer 佔用的大小將會不同:

  • 未開啟指標壓縮時,佔用 8 byte (64bit)
  • 開啟指標壓縮情況下,佔用 4 byte (32bit)

2.3 Length

陣列物件特有,表示陣列長度,佔用 4 位元組(32bit)空間,因為虛擬機器可以透過普通物件的後設資料資訊確定物件的大小,但是如果陣列的長度是不確定的,將無法透過後設資料中的資訊推斷出陣列的大小。

3. 例項資料

例項資料部分是物件真正儲存的有效資訊,即我們在程式碼裡面所定義的各種資料型別的欄位,無論是從父類繼承下來的,還是在子類中定義的欄位都會記錄起來。

3.1 基本資料型別

image.png

3.2 引用資料型別

開啟指標壓縮情況下佔 8 位元組,開啟指標壓縮後佔 4 位元組。

4. 對齊填充

物件的第三部分是對齊填充,這並不是必然存在的,也沒有特別的含義,它僅僅起著佔位符的作用。

由於 HotSpot 虛擬機器的自動記憶體管理系統要求物件起始地址必須是 8 位元組的整數倍,換句話說就是任何物件的大小都必須是 8 位元組的整數倍。

物件頭部分被精心設計成正好是 8 位元組的倍數,因此,如果物件例項資料部分沒有對齊的話,就需要透過對齊填充來補全。

如何關閉對齊填充:

# VM引數

# 開啟
-XX:+CompactFields
# 關閉
-XX:-CompactFields

如何設定對齊填充長度:

#VM 引數
-XX:ObjectAlignmentInBytes=16

三、物件的指標壓縮詳解

1. 什麼是指標壓縮

指標壓縮是對型別指標或普通物件指標進行壓縮,主要包含以下幾種:

壓縮指標型別 壓縮目標 壓縮變化
壓縮型別指標 Klass Pointer 8 位元組變為 4 位元組
壓縮普通物件指標 物件引用 8 位元組變為 4 位元組
陣列物件 8 位元組變為 4 位元組

注意:堆記憶體設定不要超過 32 GB,否則指標壓縮會失效。

在 JDK 6 之後的版本中,指標壓縮是被預設開啟的,可透過啟動引數開啟或關閉該功能:

# 開啟壓縮型別指標
-XX:+UseCompressedClassPointers 
# 關閉壓縮型別指標
-XX:-UseCompressedClassPointers 

# 開啟壓縮普通物件指標
-XX:+UseCompressedOops 
# 關閉壓縮普通物件指標
-XX:-UseCompressedOops  

程式碼示例

public static void main(String[] args)  {
	//使用 JOT 檢視物件記憶體
	System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
}

正常預設情況下,指標壓縮是開啟的,此時型別指標位 4 位元組

image.png

設定-XX:-UseCompressedClassPointers 關閉指標壓縮,型別指標變為 8 位元組。

image.png

2. JVM 記憶體不建議超過 32GB?

一般系統記憶體的最小 IO 單位是位元組(byte),按照8 bit 為一組,也就是 1 位元組(byte)分配一個地址。指標的 bit 和記憶體中的 bit 其實是有區別的。指標的 bit 對應著一個記憶體地址,一個記憶體地址對應著 1 位元組(byte)。

使用 4 位元組(32 bit)指標,可以表示 \(2^{32}\) 個記憶體地址,而每一個地址指向的是 1 位元組(8 bit),故最大可表示 4GB 記憶體。

透過物件填充我們瞭解到 Java 物件預設使用了 8 位元組對齊填充,也就是我在使用這塊記憶體時候,最小的分配單元就是 8 位元組。這樣我們的指標指向的地址就是 8 位元組,而不是一般系統的 1 位元組。

所以虛擬機器在開啟指標壓縮的情況下,Klass Pointer 最大可表示 32GB 記憶體,超過則指標壓縮失效,故不建議堆記憶體設定超過 32GB

那麼如果業務場景記憶體超過32GB怎麼辦呢?可以透過修改預設對齊長度進行再次擴充套件,將對齊長度修改為 16 位元組。

3. 指標壓縮的原理

我們透過指標壓縮,將Klass Pointer 壓縮到 4 自己,這樣有什麼問題嗎?原來的指標大小為 8 位元組(64 bit),可以表示 \(2^{64}\) 個記憶體地址,這樣壓縮完可以表示的不就少很多?

其實沒有。透過上面簡介中的描述,瞭解到 Java 物件預設使用了 8 位元組對齊,也就是 1 個物件佔用的空間必須是 8 位元組的整數倍。

這樣就可以透過基址 + 偏移量來表示物件的真正地址,基址其實就是物件地址的開始,但是不一定是 Java 堆的開始地址。

那麼這個真正地址怎麼計算呢?公式如下,符號不瞭解可以看運算子這篇文章。

64 位地址 = 基址 + (壓縮物件指標 << 物件對齊偏移)

壓縮物件指標 = (64 位地址 - 基址) >> 物件對齊偏移

物件對齊偏移與對齊填充相關,它的值就是對齊填充長度的指數值,比如,我們預設的對齊填充長度為 8 位元組,也就是 \(2^{3}\),則物件對齊偏移的值就是 3。偏移量就是壓縮物件指標 << 3,這就是為什麼網上很多文章描述的去掉後三位。

這樣虛擬機器在定位一個物件時不需要使用真正的記憶體地址,而是定位偏移量對映後的地址即可。

四、如何找到物件

建立物件自然是為了後續使用該物件,Java 中是透過棧楨裡的 reference 資料來指向堆上的具體物件。目前主流的訪問方式主要有使用控制代碼和直接指標兩種。

1. 透過控制代碼

如下圖, Java 堆中將可能會劃分出一塊記憶體來作為控制代碼池,reference 中儲存的就是物件的控制代碼地址,而控制代碼中包含了物件例項資料與型別資料各自具體的地址資訊。

2. 透過直接指標

使用直接指標訪問的話,Java 堆中物件的記憶體佈局就必須考慮如何放置訪問型別資料的相關資訊,reference 中儲存的直接就是物件地址,如果只是訪問物件本身的話,就不需要多一次間接訪問的開銷。

使用直接指標來訪問最大的好處就是速度更快,它節省了一次指標定位的時間開銷,由於物件訪問在Java中非常頻繁,因此這類開銷積少成多也是一項極為可觀的執行成本,也是 HotSpot 使用的方式。


參考:

[1] 周志明. 深入理解 Java 虛擬機器(第3版).

[2]峰哥學Java. 物件的記憶體佈局.

相關文章