深入理解Java物件結構

狂盗一枝梅發表於2024-09-20

一、Java物件結構

例項化一個Java物件之後,該物件在記憶體中的結構是怎麼樣的?Java物件(Object例項)結構包括三部分:物件頭、物件體和對齊位元組,具體下圖所示

Java物件結構

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在不同的鎖狀態下的結構資訊:

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例項處於什麼樣的鎖狀態。二者組合的含義具體如下表所示

image-20240919171225689

(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());
    }
}

輸出結果:

image-20240920092758780

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位元組

image-20240920101332652

這部分輸出表示引用型別、boolean、byte、char、short、int、float、long、double型別的資料所佔的位元組數大小以及在陣列中的大小和偏移量。

需要注意的是陣列偏移量的概念,陣列偏移量的數值其實就是物件頭的大小,在上圖中的16位元組表示如果當前物件是陣列,那物件頭就是16位元組,不要忘了,物件頭中還有陣列長度,在未開啟物件指標壓縮的情況下,它要佔據4位元組大小。

接下來是物件結構的輸出分析。

三、物件結構輸出解析

先回顧下物件結構

Java物件結構

再來回顧下物件結構輸出結果

image-20240920103046465
  • OFF:偏移量,單位位元組

  • SZ:大小,單位位元組

  • TYPE DESCRIPTION:型別描述,這裡顯示的比較直觀,甚至可以看到是物件頭的哪一部分

  • VALUE:值,使用十六進位制字串表示,注意一個位元組是8bit,佔據兩個16進位制字串,JOL0.15版本之前是小端序展示,0.15(包含0.15)版本之後使用大端序展示。

    1、Mark Word解析

image-20240920104856901

因為當前虛擬機器是64位的虛擬機器,所以Mark Word在物件頭中佔據8位元組,也就是64位。它不受指標壓縮的影響,佔據記憶體大小隻和當前虛擬機器有關係。

當前的值是十六進位制數值:0x0000000000000001,為了好看點,將它按照位元組分割開:00 00 00 00 00 00 00 01,然後,來回顧下mark workd的記憶體結構:

64位Mark Word的結構資訊

最後一個位元組是十六進位制的01,轉化為二進位制數,就是00000001,那倒數三個bit就是001,偏向鎖標誌位biased是0,lock標誌位是01,對應的是無鎖狀態下的mark word資料結構。

2、Class Pointer 解析

image-20240920110627257

該欄位在64位虛擬機器下開啟指標壓縮佔據4位元組,未開啟指標壓縮佔據8位元組,它指向方法區的記憶體地址,即Class物件所在的位置。

3、物件體解析

image-20240920111056379

Hello類只有一個Integer型別的變數a,它在64位虛擬機器下開啟指標壓縮佔據4位元組,未開啟指標壓縮佔據8位元組大小。需要注意的是,這裡的8位元組儲存的是Integer物件指標大小,而非int型別的數值所佔記憶體大小。

四、不同條件下的物件結構變化

1、Mark Word中的hashCode

在無鎖狀態下,物件頭中的mark word欄位有31bit是用於存放hashCode的值的,但是在之前的列印輸出中,hashCode全是0,這是為什麼?

64位Mark Word的結構資訊

想要hashCode的值能夠在mark word中展示,需要滿足兩個條件:

  1. 目標類不能重寫hashCode方法
  2. 目標物件需要呼叫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());
    }
}

輸出結果

image-20240920132209032

可以看到,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());
    }
}

執行結果:

image-20240920133520395

果然,為了對齊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());
    }
}

輸出結果

image-20240920134321859

標紅部分相對於普通的物件,陣列物件多了個陣列長度的欄位;而且接下來3個整數,共佔據了12位元組大小的記憶體空間。

再仔細看看,加上陣列長度部分,物件頭部分一共佔據了16位元組大小的空間,這個和上面的Array base offsets的大小一致,這是因為要想訪問到真正的物件值,從物件開始要經過16位元組的物件頭才能讀取到物件,這16位元組也就是每個元素讀取的“偏移量”了。

4、指標壓縮

開啟指標壓縮: -XX:+UseCompressedOops

關閉指標壓縮: -XX:-UseCompressedOops

在Intelij中,在下圖中的VM Options中新增該引數即可

image-20240920140730247

需要注意的是,指標壓縮在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());
    }
}

開啟指標壓縮的解析結果:

image-20240920141222851

未開啟指標壓縮的結果:

image-20240920141306194

以開啟指標壓縮後的結果為基礎,觀察下未開啟指標壓縮的結果

image-20240920142324117

需要注意的是這裡的Integer[]陣列裡面都是Integer物件,而非int型別的數值,它是Integer基本型別包裝類的例項,這裡的陣列記憶體地址中儲存的是每個Integer物件的指標引用,從輸出的VM資訊的對照表中,“ref”型別佔據8位元組,所以才是3*8為24位元組大小。

可以看到,開啟指標壓縮以後,會產生兩個影響

  1. 物件引用型別會從8位元組變成4位元組
  2. 物件頭中的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());
    }
}

輸出結果:

image-20240920151446421

先不管老版本的輸出和新版本的差異性,總之可以看到小端序手動算的hashCode和jol解析得到的hashCode是一致的,說明老版本的jol(0.15之前)輸出是小端序的,對應我們的Mark Word圖例來看

64位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>

執行結果如下

image-20240920151945745

可以看到手動計算的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)

原始碼這樣子:

image-20240920152601176

很明顯的bug,它判斷了小端序標誌,但是卻返回了大端序的位元組陣列,不出意外的,我的程式碼執行的有矛盾。。所以我專門去gitee上看了下,發現它的master程式碼已經發生了變化

image-20240920152920125

沒錯,它master程式碼已經修復了。。找了找commit,發現了這個

image-20240920153048975

對應的COMMIT記錄連結:https://gitee.com/dromara/hutool/commit/d4a7ddac3b30db516aec752562cae3436a4877c0

image-20240920153256248

還被人吐槽了,哈哈哈,引入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

相關文章