一、Java物件結構
例項化一個Java物件之後,該物件在記憶體中的結構是怎麼樣的?Java物件(Object例項)結構包括三部分:物件頭、物件體和對齊位元組,具體下圖所示
1、Java物件的三部分
(1)物件頭
物件頭包括三個欄位,第一個欄位叫作Mark Word(標記字),用於儲存自身執行時的資料,例如GC標誌位、雜湊碼、鎖狀態等資訊。
第二個欄位叫作Class Pointer(類物件指標),用於存放方法區Class物件的地址,虛擬機器透過這個指標來確定這個物件是哪個類的例項。
第三個欄位叫作Array Length(陣列長度)。如果物件是一個Java陣列,那麼此欄位必須有,用於記錄陣列長度的資料;如果物件不是一個Java陣列,那麼此欄位不存在,所以這是一個可選欄位。
(2)物件體
物件體包含物件的例項變數(成員變數),用於成員屬性值,包括父類的成員屬性值。這部分記憶體按4位元組對齊。
(3)對齊位元組
對齊位元組也叫作填充對齊,其作用是用來保證Java物件所佔記憶體位元組數為8的倍數HotSpot VM的記憶體管理要求物件起始地址必須是8位元組的整數倍。物件頭本身是8的倍數,當物件的例項變數資料不是8的倍數時,便需要填充資料來保證8位元組的對齊。
2、Mark Word的結構資訊
Mark Word是物件頭中的第一部分,Java內建鎖有很多重要的資訊都存在這裡。Mark Word的位長度為JVM的一個Word大小,也就是說32位JVM的Mark Word為32位,64位JVM為64位。Mark Word的位長度不會受到Oop物件指標壓縮選項的影響。
Java內建鎖的狀態總共有4種,級別由低到高依次為:無鎖、偏向鎖、輕量級鎖和重量級鎖。其實在JDK 1.6之前,Java內建鎖還是一個重量級鎖,是一個效率比較低下的鎖,在JDK 1.6之後,JVM為了提高鎖的獲取與釋放效率,對synchronized的實現進行了最佳化,引入了偏向鎖和輕量級鎖,從此以後Java內建鎖的狀態就有了4種(無鎖、偏向鎖、輕量級鎖和重量級鎖),並且4種狀態會隨著競爭的情況逐漸升級,而且是不可逆的過程,即不可降級,也就是說只能進行鎖升級(從低階別到高階別)。以下是64位的Mark Word在不同的鎖狀態下的結構資訊:
由於目前主流的JVM都是64位,因此我們使用64位的Mark Word。接下來對64位的Mark Word中各部分的內容進行具體介紹。
(1)lock:鎖狀態標記位,佔兩個二進位制位,由於希望用盡可能少的二進位制位表示儘可能多的資訊,因此設定了lock標記。該標記的值不同,整個Mark Word表示的含義就不同。
(2)biased_lock:物件是否啟用偏向鎖標記,只佔1個二進位制位。為1時表示物件啟用偏向鎖,為0時表示物件沒有偏向鎖。
lock和biased_lock兩個標記位組合在一起共同表示Object例項處於什麼樣的鎖狀態。二者組合的含義具體如下表所示
(3)age:4位的Java物件分代年齡。在GC中,物件在Survivor區複製一次,年齡就增加1。當物件達到設定的閾值時,將會晉升到老年代。預設情況下,並行GC的年齡閾值為15,併發GC的年齡閾值為6。由於age只有4位,因此最大值為15,這就是-XX:MaxTenuringThreshold選項最大值為15的原因。
(4)identity_hashcode:31位的物件標識HashCode(雜湊碼)採用延遲載入技術,當呼叫Object.hashCode()方法或者System.identityHashCode()方法計算物件的HashCode後,其結果將被寫到該物件頭中。當物件被鎖定時,該值會移動到Monitor(監視器)中。
(5)thread:54位的執行緒ID值為持有偏向鎖的執行緒ID。
(6)epoch:偏向時間戳。
(7)ptr_to_lock_record:佔62位,在輕量級鎖的狀態下指向棧幀中鎖記錄的指標。
二、使用JOL工具檢視物件的佈局
1、JOL工具的使用
JOL工具是一個jar包,使用它提供的工具類可以輕鬆解析出執行時java物件在記憶體中的結構,使用時首先需要引入maven GAV資訊
<!--Java Object Layout -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
當前最新版本是0.17版本,據觀察,它和0.15之前(不包含0.15)的版本輸出資訊差異比較大,而普遍現在使用的版本都比較低,但是不妨礙在這裡使用該工具做實驗。
jol-core 常用的幾個方法
ClassLayout.parseInstance(object).toPrintable()
:檢視物件內部資訊.GraphLayout.parseInstance(object).toPrintable()
:檢視物件外部資訊,包括引用的物件.GraphLayout.parseInstance(object).totalSize()
:檢視物件總大小.VM.current().details()
:輸出當前虛擬機器資訊
首先建立一個簡單的類Hello
public class Hello {
private Integer a = 1;
}
接下來寫一個啟動類測試下
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
/**
* @author kdyzm
* @date 2024/9/19
*/
@Slf4j
public class JalTest {
public static void main(String[] args) {
log.info(VM.current().details());
Hello hello = new Hello();
log.info("hello obj status:{}", ClassLayout.parseInstance(hello).toPrintable());
}
}
輸出結果:
2、結果分析
在程式碼中,首先使用了VM.current().details()
方法獲取到了當前java虛擬機器的相關資訊:
-
VM mode: 64 bits - 表示當前虛擬機器是64位虛擬機器
-
Compressed references (oops): 3-bit shift - 開啟了物件指標壓縮,在64位的Java虛擬機器上,物件指標通常需要佔用8位元組(64位),但透過使用壓縮指標技術,可以減少物件指標的佔用空間,提高記憶體利用率。"3-bit shift" 意味著使用3位的位移操作來對物件指標進行壓縮。透過將物件指標右移3位,可以消除指標中的一些無用位,從而減少物件指標的實際大小,使其佔用更少的記憶體。
-
Compressed class pointers: 3-bit shift - 開啟了類指標壓縮,其餘同上。
-
Object alignment: 8 bytes - 位元組對齊使用8位元組
這部分輸出表示引用型別、boolean、byte、char、short、int、float、long、double型別的資料所佔的位元組數大小以及在陣列中的大小和偏移量。
需要注意的是陣列偏移量的概念,陣列偏移量的數值其實就是物件頭的大小,在上圖中的16位元組表示如果當前物件是陣列,那物件頭就是16位元組,不要忘了,物件頭中還有陣列長度,在未開啟物件指標壓縮的情況下,它要佔據4位元組大小。
接下來是物件結構的輸出分析。
三、物件結構輸出解析
先回顧下物件結構
再來回顧下物件結構輸出結果
-
OFF:偏移量,單位位元組
-
SZ:大小,單位位元組
-
TYPE DESCRIPTION:型別描述,這裡顯示的比較直觀,甚至可以看到是物件頭的哪一部分
-
VALUE:值,使用十六進位制字串表示,注意一個位元組是8bit,佔據兩個16進位制字串,JOL0.15版本之前是小端序展示,0.15(包含0.15)版本之後使用大端序展示。
1、Mark Word解析
因為當前虛擬機器是64位的虛擬機器,所以Mark Word在物件頭中佔據8位元組,也就是64位。它不受指標壓縮的影響,佔據記憶體大小隻和當前虛擬機器有關係。
當前的值是十六進位制數值:0x0000000000000001
,為了好看點,將它按照位元組分割開:00 00 00 00 00 00 00 01
,然後,來回顧下mark workd的記憶體結構:
最後一個位元組是十六進位制的01,轉化為二進位制數,就是00000001
,那倒數三個bit就是001
,偏向鎖標誌位biased是0,lock標誌位是01,對應的是無鎖狀態下的mark word資料結構。
2、Class Pointer 解析
該欄位在64位虛擬機器下開啟指標壓縮佔據4位元組,未開啟指標壓縮佔據8位元組,它指向方法區的記憶體地址,即Class物件所在的位置。
3、物件體解析
Hello類只有一個Integer型別的變數a,它在64位虛擬機器下開啟指標壓縮佔據4位元組,未開啟指標壓縮佔據8位元組大小。需要注意的是,這裡的8位元組儲存的是Integer物件指標大小,而非int型別的數值所佔記憶體大小。
四、不同條件下的物件結構變化
1、Mark Word中的hashCode
在無鎖狀態下,物件頭中的mark word欄位有31bit是用於存放hashCode的值的,但是在之前的列印輸出中,hashCode全是0,這是為什麼?
想要hashCode的值能夠在mark word中展示,需要滿足兩個條件:
- 目標類不能重寫hashCode方法
- 目標物件需要呼叫hashCode方法生成hashCode
上面的實驗中,Hello類很簡單
public class Hello {
private Integer a = 1;
}
沒有重寫hashCode方法,使用JOL工具分析沒有看到hashCode值,是因為沒有呼叫hashCode()方法生成hashCode值
接下來改下啟動類,呼叫下hashCode方法,重新輸出解析結果
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
/**
* @author kdyzm
* @date 2024/9/19
*/
@Slf4j
public class JalTest {
public static void main(String[] args) {
log.info(VM.current().details());
Hello hello = new Hello();
hello.hashCode();
log.info("hello obj status:{}", ClassLayout.parseInstance(hello).toPrintable());
}
}
輸出結果
可以看到,Mark Word中已經有了hashCode的值。
2、位元組對齊
從JOL輸出上來看,使用的是8位元組對齊,而物件正好是16位元組,是8的整數倍,所以並沒有使用位元組對齊,為了能看到位元組對齊的效果,再給Hello類新增一個成員變數Integer b = 2
,已知一個整型變數在這裡佔用4位元組大小空間,物件大小會變成20位元組,那就不是8的整數倍,會有4位元組的對齊位元組填充,改下Hello類
public class Hello {
private Integer a = 1;
private Integer b = 2;
}
然後執行啟動類
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
/**
* @author kdyzm
* @date 2024/9/19
*/
@Slf4j
public class JalTest {
public static void main(String[] args) {
log.info(VM.current().details());
Hello hello = new Hello();
hello.hashCode();
log.info("hello obj status:{}", ClassLayout.parseInstance(hello).toPrintable());
}
}
執行結果:
果然,為了對齊8位元組,多了4位元組的填充,整個物件例項大小變成了24位元組。
3、陣列型別的物件結構
陣列型別的物件和普通的物件肯定不一樣,甚至在物件頭中專門有個“陣列長度”來記錄陣列的長度。改變下啟動類,看看Integer陣列的物件結構
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
/**
* @author kdyzm
* @date 2024/9/19
*/
@Slf4j
public class JalTest {
public static void main(String[] args) {
log.info(VM.current().details());
Integer[] a = new Integer[]{1, 2, 3};
a.hashCode();
log.info("hello obj status:{}", ClassLayout.parseInstance(a).toPrintable());
}
}
輸出結果
標紅部分相對於普通的物件,陣列物件多了個陣列長度的欄位;而且接下來3個整數,共佔據了12位元組大小的記憶體空間。
再仔細看看,加上陣列長度部分,物件頭部分一共佔據了16位元組大小的空間,這個和上面的Array base offsets的大小一致,這是因為要想訪問到真正的物件值,從物件開始要經過16位元組的物件頭才能讀取到物件,這16位元組也就是每個元素讀取的“偏移量”了。
4、指標壓縮
開啟指標壓縮: -XX:+UseCompressedOops
關閉指標壓縮: -XX:-UseCompressedOops
在Intelij中,在下圖中的VM Options中新增該引數即可
需要注意的是,指標壓縮在java8及以後的版本中是預設開啟的。
接下來看看指標壓縮在開啟和沒開啟的情況下,相同的解析程式碼列印出來的結果
程式碼:
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
/**
* @author kdyzm
* @date 2024/9/19
*/
@Slf4j
public class JalTest {
public static void main(String[] args) {
log.info("\n{}",VM.current().details());
Integer[] a = new Integer[]{1, 2, 3};
a.hashCode();
log.info("hello obj status:\n{}", ClassLayout.parseInstance(a).toPrintable());
}
}
開啟指標壓縮的解析結果:
未開啟指標壓縮的結果:
以開啟指標壓縮後的結果為基礎,觀察下未開啟指標壓縮的結果
需要注意的是這裡的Integer[]陣列裡面都是Integer物件,而非int型別的數值,它是Integer基本型別包裝類的例項,這裡的陣列記憶體地址中儲存的是每個Integer物件的指標引用,從輸出的VM資訊的對照表中,“ref”型別佔據8位元組,所以才是3*8為24位元組大小。
可以看到,開啟指標壓縮以後,會產生兩個影響
- 物件引用型別會從8位元組變成4位元組
- 物件頭中的Class Pointer型別會從8位元組變成4位元組
確實能節省空間。
五、擴充套件閱讀
1、大端序和小端序
大端序(Big Endian)和小端序(Little Endian)是兩種不同的儲存資料的方式,特別是在多位元組資料型別(比如整數)在計算機記憶體中的儲存順序方面有所體現。
- 大端序(Big Endian):在大端序中,資料的高位位元組儲存在低地址,而低位位元組儲存在高地址。類比於數字的書寫方式,高位數字在左邊,低位數字在右邊。因此,資料的最高有效位元組(Most Significant Byte,MSB)儲存在最低的地址處。
- 小端序(Little Endian):相反地,在小端序中,資料的低位位元組儲存在低地址,而高位位元組儲存在高地址。這種方式與我們閱讀數字的順序一致,即從低位到高位。因此,資料的最低有效位元組(Least Significant Byte,LSB)儲存在最低的地址處。
這兩種儲存方式可以用一個簡單的例子來說明:
假設要儲存一個 4 位元組的整數 0x12345678
:
- 在大端序中,儲存順序為
12 34 56 78
。 - 在小端序中,儲存順序為
78 56 34 12
。
2、老版本的JOL
老版本的JOL(0.15之前)輸出的值是小端序的,可以做個實驗,將maven座標改成0.14版本
<!--Java Object Layout -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.14</version>
</dependency>
同時要引入新的工具類
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.32</version>
</dependency>
然後修改Hello類
import cn.hutool.core.util.ByteUtil;
import cn.hutool.core.util.HexUtil;
/**
* @author kdyzm
* @date 2024/9/19
*/
public class Hello {
private int a = 1;
private int b = 2;
public String hexHash() {
//物件的原始 hashCode,Java預設為大端模式
int hashCode = this.hashCode();
//轉成小端模式的位元組陣列
byte[] hashCode_LE = ByteUtil.intToBytes(hashCode, ByteOrder.LITTLE_ENDIAN);
//轉成十六進位制形式的字串
return HexUtil.encodeHexStr(hashCode_LE);
}
}
啟動類:
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
/**
* @author kdyzm
* @date 2024/9/19
*/
@Slf4j
public class JalTest {
public static void main(String[] args) {
log.info("\n{}", VM.current().details());
Hello hello = new Hello();
log.info("算的十六進位制hashCode:{}", hello.hexHash());
log.info("JOL解析工具輸出物件結構:{}", ClassLayout.parseInstance(hello).toPrintable());
}
}
輸出結果:
先不管老版本的輸出和新版本的差異性,總之可以看到小端序手動算的hashCode和jol解析得到的hashCode是一致的,說明老版本的jol(0.15之前)輸出是小端序的,對應我們的Mark Word圖例來看
我們的圖例是按照大端序來畫的,所以老版本的輸出第一個位元組01才是Mark Word上述圖例的最後一個位元組。
程式碼不變,改變JOL版本號為0.15
<!--Java Object Layout -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.15</version>
</dependency>
執行結果如下
可以看到手動計算的hashCode和jol解析的hashCode位元組碼顛倒過來了,也就是說,從0.15版本號開始,jol的輸出變成了大端序輸出了。
3、hutool的bug
上面的程式碼中有用到hutool工具類計算hashCode值的十六進位制字串,一開始我引入的依賴是這樣的
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.3</version>
</dependency>
其中有個很重要的int型別轉換位元組陣列的方法:cn.hutool.core.util.ByteUtil#intToBytes(int, java.nio.ByteOrder)
原始碼這樣子:
很明顯的bug,它判斷了小端序標誌,但是卻返回了大端序的位元組陣列,不出意外的,我的程式碼執行的有矛盾。。所以我專門去gitee上看了下,發現它的master程式碼已經發生了變化
沒錯,它master程式碼已經修復了。。找了找commit,發現了這個
對應的COMMIT記錄連結:https://gitee.com/dromara/hutool/commit/d4a7ddac3b30db516aec752562cae3436a4877c0
還被人吐槽了,哈哈哈,引入5.8.32版本就解決了
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.32</version>
</dependency>
END.
參考文件
《Java高併發核心程式設計 卷2:多執行緒、鎖、JMM、JUC、高併發設計模式》
Java物件的記憶體佈局
看到最後了,歡迎關注我的個人部落格⌯'▾'⌯:https://blog.kdyzm.cn