App下載

圖文詳解Java對(duì)象內(nèi)存布局

猿友 2021-05-14 11:09:13 瀏覽數(shù) (2624)
反饋

作為一名 Java 程序員,我們?cè)谌粘9ぷ髦惺褂眠@款面向?qū)ο蟮木幊陶Z(yǔ)言時(shí),做的最頻繁的操作大概就是去創(chuàng)建一個(gè)個(gè)的對(duì)象了。對(duì)象的創(chuàng)建方式雖然有很多,可以通過new、反射、clone、反序列化等不同方式來(lái)創(chuàng)建,但最終使用時(shí)對(duì)象都要被放到內(nèi)存中,那么你知道在內(nèi)存中的 java 對(duì)象是由哪些部分組成、又是怎么存儲(chǔ)的嗎?

本文將基于代碼進(jìn)行實(shí)例測(cè)試,詳細(xì)探討對(duì)象在內(nèi)存中的組成結(jié)構(gòu)。全文目錄結(jié)構(gòu)如下:

  • 1、對(duì)象內(nèi)存結(jié)構(gòu)概述
  • 2、JOL 工具簡(jiǎn)介
  • 3、對(duì)象頭
  • 4、實(shí)例數(shù)據(jù)
  • 5、對(duì)齊填充字節(jié)
  • 6、總結(jié)

文中代碼基于 JDK 1.8.0_261,64-Bit HotSpot 運(yùn)行

1、對(duì)象內(nèi)存結(jié)構(gòu)概述

在介紹對(duì)象在內(nèi)存中的組成結(jié)構(gòu)前,我們先簡(jiǎn)要回顧一個(gè)對(duì)象的創(chuàng)建過程:

1、jvm 將對(duì)象所在的class文件加載到方法區(qū)中

2、jvm 讀取main方法入口,將main方法入棧,執(zhí)行創(chuàng)建對(duì)象代碼

3、在main方法的棧內(nèi)存中分配對(duì)象的引用,在堆中分配內(nèi)存放入創(chuàng)建的對(duì)象,并將棧中的引用指向堆中的對(duì)象

所以當(dāng)對(duì)象在實(shí)例化完成之后,是被存放在堆內(nèi)存中的,這里的對(duì)象由 3 部分組成,如下圖所示:

java對(duì)象內(nèi)存結(jié)構(gòu)概述

對(duì)各個(gè)組成部分的功能簡(jiǎn)要進(jìn)行說(shuō)明:

  • 對(duì)象頭:對(duì)象頭存儲(chǔ)的是對(duì)象在運(yùn)行時(shí)狀態(tài)的相關(guān)信息、指向該對(duì)象所屬類的元數(shù)據(jù)的指針,如果對(duì)象是數(shù)組對(duì)象那么還會(huì)額外存儲(chǔ)對(duì)象的數(shù)組長(zhǎng)度
  • 實(shí)例數(shù)據(jù):實(shí)例數(shù)據(jù)存儲(chǔ)的是對(duì)象的真正有效數(shù)據(jù),也就是各個(gè)屬性字段的值,如果在擁有父類的情況下,還會(huì)包含父類的字段。字段的存儲(chǔ)順序會(huì)受到數(shù)據(jù)類型長(zhǎng)度、以及虛擬機(jī)的分配策略的影響
  • 對(duì)齊填充字節(jié):在 java 對(duì)象中,需要對(duì)齊填充字節(jié)的原因是,64 位的 jvm 中對(duì)象的大小被要求向8字節(jié)對(duì)齊,因此當(dāng)對(duì)象的長(zhǎng)度不足8字節(jié)的整數(shù)倍時(shí),需要在對(duì)象中進(jìn)行填充操作。注意圖中對(duì)齊填充部分使用了虛線,這是因?yàn)樘畛渥止?jié)并不是固定存在的部分,這點(diǎn)在后面計(jì)算對(duì)象大小時(shí)具體進(jìn)行說(shuō)明

2、JOL 工具簡(jiǎn)介

在具體開始研究對(duì)象的內(nèi)存結(jié)構(gòu)之前,先介紹一下我們要用到的工具,openjdk官網(wǎng)提供了查看對(duì)象內(nèi)存布局的工具jol (java object layout),可在maven中引入坐標(biāo):

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

在代碼中使用jol提供的方法查看 jvm 信息:

System.out.println(VM.current().details());

JOL 工具簡(jiǎn)介

通過打印出來(lái)的信息,可以看到我們使用的是 64 位 jvm,并開啟了指針壓縮,對(duì)象默認(rèn)使用 8 字節(jié)對(duì)齊方式。通過jol查看對(duì)象內(nèi)存布局的方法,將在后面的例子中具體展示,下面開始對(duì)象內(nèi)存布局的正式學(xué)習(xí)。

3、對(duì)象頭

首先看一下對(duì)象頭(Object header)的組成部分,根據(jù)普通對(duì)象和數(shù)組對(duì)象的不同,結(jié)構(gòu)將會(huì)有所不同。只有當(dāng)對(duì)象是數(shù)組對(duì)象才會(huì)有數(shù)組長(zhǎng)度部分,普通對(duì)象沒有該部分,如下圖所示:

java對(duì)象頭

在對(duì)象頭中mark word 占8字節(jié),默認(rèn)開啟指針壓縮的情況下klass pointer 占4字節(jié),數(shù)組對(duì)象的數(shù)組長(zhǎng)度占4字節(jié)。在了解了對(duì)象頭的基礎(chǔ)結(jié)構(gòu)后,現(xiàn)在以一個(gè)不包含任何屬性的空對(duì)象為例,查看一下它的內(nèi)存布局,創(chuàng)建User類:

public class User {
}

使用jol查看對(duì)象頭的內(nèi)存布局:

public static void main(String[] args) {
    User user=new User();
    //查看對(duì)象的內(nèi)存布局
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

執(zhí)行代碼,查看打印信息:

使用jol查看對(duì)象頭的內(nèi)存布局

  • OFFSET:偏移地址,單位為字節(jié)
  • SIZE:占用內(nèi)存大小,單位為字節(jié)
  • TYPEClass中定義的類型
  • DESCRIPTION:類型描述,Obejct header 表示對(duì)象頭,alignment表示對(duì)齊填充
  • VALUE:對(duì)應(yīng)內(nèi)存中存儲(chǔ)的值

當(dāng)前對(duì)象共占用16字節(jié),因?yàn)?字節(jié)標(biāo)記字加4字節(jié)的類型指針,不滿足向8字節(jié)對(duì)齊,因此需要填充4個(gè)字節(jié):

8B (mark word) + 4B (klass pointer) + 0B (instance data) + 4B (padding)

這樣我們就通過直觀的方式,了解了一個(gè)不包含屬性的最簡(jiǎn)單的空對(duì)象,在內(nèi)存中的基本組成是怎樣的。在此基礎(chǔ)上,我們來(lái)深入學(xué)習(xí)對(duì)象頭中各個(gè)組成部分。

3.1 Mark Word 標(biāo)記字

在對(duì)象頭中,mark word 一共有64個(gè)bit,用于存儲(chǔ)對(duì)象自身的運(yùn)行時(shí)數(shù)據(jù),標(biāo)記對(duì)象處于以下5種狀態(tài)中的某一種:

Mark Word 標(biāo)記字

3.1.1 基于mark word的鎖升級(jí)

在jdk6 之前,通過synchronized關(guān)鍵字加鎖時(shí)使用無(wú)差別的的重量級(jí)鎖,重量級(jí)鎖會(huì)造成線程的串行執(zhí)行,并且使cpu在用戶態(tài)和核心態(tài)之間頻繁切換。隨著對(duì)synchronized的不斷優(yōu)化,提出了鎖升級(jí)的概念,并引入了偏向鎖、輕量級(jí)鎖、重量級(jí)鎖。在mark word中,鎖(lock)標(biāo)志位占用2個(gè)bit,結(jié)合1個(gè)bit偏向鎖(biased_lock)標(biāo)志位,這樣通過倒數(shù)的3位,就能用來(lái)標(biāo)識(shí)當(dāng)前對(duì)象持有的鎖的狀態(tài),并判斷出其余位存儲(chǔ)的是什么信息。

基于mark word的鎖升級(jí)的流程如下:

1、鎖對(duì)象剛創(chuàng)建時(shí),沒有任何線程競(jìng)爭(zhēng),對(duì)象處于無(wú)鎖狀態(tài)。在上面打印的空對(duì)象的內(nèi)存布局中,根據(jù)大小端,得到最后8位是00000001,表示處于無(wú)鎖態(tài),并且處于不可偏向狀態(tài)。這是因?yàn)樵趈dk中偏向鎖存在延遲4秒啟動(dòng),也就是說(shuō)在jvm啟動(dòng)后4秒后創(chuàng)建的對(duì)象才會(huì)開啟偏向鎖,我們通過jvm參數(shù)取消這個(gè)延遲時(shí)間:

-XX:BiasedLockingStartupDelay=0

基于mark word的鎖升級(jí)的流程

這時(shí)最后3位為101,表示當(dāng)前對(duì)象的鎖沒有被持有,并且處于可被偏向狀態(tài)。

2、在沒有線程競(jìng)爭(zhēng)的條件下,第一個(gè)獲取鎖的線程通過CAS將自己的threadId寫入到該對(duì)象的mark word中,若后續(xù)該線程再次獲取鎖,需要比較當(dāng)前線程threadId和對(duì)象mark word中的threadId是否一致,如果一致那么可以直接獲取,并且鎖對(duì)象始終保持對(duì)該線程的偏向,也就是說(shuō)偏向鎖不會(huì)主動(dòng)釋放。

使用代碼進(jìn)行測(cè)試同一個(gè)線程重復(fù)獲取鎖的過程:

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

執(zhí)行結(jié)果:

使用代碼進(jìn)行測(cè)試同一個(gè)線程重復(fù)獲取鎖的執(zhí)行結(jié)果

可以看到一個(gè)線程對(duì)一個(gè)對(duì)象加鎖、解鎖、重新獲取對(duì)象的鎖時(shí),mark word都沒有發(fā)生變化,偏向鎖中的當(dāng)前線程指針始終指向同一個(gè)線程。

3、當(dāng)兩個(gè)或以上線程交替獲取鎖,但并沒有在對(duì)象上并發(fā)的獲取鎖時(shí),偏向鎖升級(jí)為輕量級(jí)鎖。在此階段,線程采取CAS的自旋方式嘗試獲取鎖,避免阻塞線程造成的cpu在用戶態(tài)和內(nèi)核態(tài)間轉(zhuǎn)換的消耗。測(cè)試代碼如下:

public static void main(String[] args) throws InterruptedException {
    User user=new User();
    synchronized (user){
        System.out.println("--MAIN--:"+ClassLayout.parseInstance(user).toPrintable());
    }


    Thread thread = new Thread(() -> {
        synchronized (user) {
            System.out.println("--THREAD--:"+ClassLayout.parseInstance(user).toPrintable());
        }
    });
    thread.start();
    thread.join();
    System.out.println("--END--:"+ClassLayout.parseInstance(user).toPrintable());
}

先直接看一下結(jié)果:

基于mark word的鎖升級(jí)

整個(gè)加鎖狀態(tài)的變化流程如下:

  • 主線程首先對(duì) user 對(duì)象加鎖,首次加鎖為101偏向鎖
  • 子線程等待主線程釋放鎖后,對(duì)user對(duì)象加鎖,這時(shí)將偏向鎖升級(jí)為00輕量級(jí)鎖
  • 輕量級(jí)鎖解鎖后,user 對(duì)象無(wú)線程競(jìng)爭(zhēng),恢復(fù)為001無(wú)鎖態(tài),并且處于不可偏向狀態(tài)。如果之后有線程再嘗試獲取 user 對(duì)象的鎖,會(huì)直接加輕量級(jí)鎖,而不是偏向鎖

4、當(dāng)兩個(gè)或以上線程并發(fā)的在同一個(gè)對(duì)象上進(jìn)行同步時(shí),為了避免無(wú)用自旋消耗 cpu,輕量級(jí)鎖會(huì)升級(jí)成重量級(jí)鎖。這時(shí)mark word中的指針指向的是monitor對(duì)象(也被稱為管程或監(jiān)視器鎖)的起始地址。測(cè)試代碼如下:

public static void main(String[] args) {
    User user = new User();
    new Thread(() -> {
        synchronized (user) {
            System.out.println("--THREAD1--:" + ClassLayout.parseInstance(user).toPrintable());
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }).start();
    new Thread(() -> {
        synchronized (user) {
            System.out.println("--THREAD2--:" + ClassLayout.parseInstance(user).toPrintable());
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }).start();
}

查看結(jié)果:

兩個(gè)線程同時(shí)競(jìng)爭(zhēng)user對(duì)象的鎖時(shí),會(huì)升級(jí)為10重量級(jí)鎖

可以看到,在兩個(gè)線程同時(shí)競(jìng)爭(zhēng)user對(duì)象的鎖時(shí),會(huì)升級(jí)為10重量級(jí)鎖。

3.1.2 其他信息

對(duì)mark word 中其他重要信息進(jìn)行說(shuō)明:

  • hashcode:無(wú)鎖態(tài)下的hashcode采用了延遲加載技術(shù),在第一次調(diào)用hashCode()方法時(shí)才會(huì)計(jì)算寫入。對(duì)這一過程進(jìn)行驗(yàn)證:

public static void main(String[] args) {
    User user=new User();
    //打印內(nèi)存布局
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
    //計(jì)算hashCode
    System.out.println(user.hashCode());
    //再次打印內(nèi)存布局
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

java對(duì)象

可以看到,在沒有調(diào)用hashCode()方法前,31位的哈希值不存在,全部填充為0。在調(diào)用方法后,根據(jù)大小端,被填充的數(shù)據(jù)為:

1011001001101100011010010101101

將2進(jìn)制轉(zhuǎn)換為10進(jìn)制,對(duì)應(yīng)哈希值1496724653。需要注意,只有在調(diào)用沒有被重寫的Object.hashCode()方法或System.identityHashCode(Object)方法才會(huì)寫入mark word,執(zhí)行用戶自定義的hashCode()方法不會(huì)被寫入。

大家可能會(huì)注意到,當(dāng)對(duì)象被加鎖后,mark word中就沒有足夠空間來(lái)保存hashCode了,這時(shí)hashcode會(huì)被移動(dòng)到重量級(jí)鎖的Object Monitor中。

  • epoch:偏向鎖的時(shí)間戳
  • 分代年齡(age):在jvm的垃圾回收過程中,每當(dāng)對(duì)象經(jīng)過一次Young GC,年齡都會(huì)加1,這里4位來(lái)表示分代年齡最大值為15,這也就是為什么對(duì)象的年齡超過15后會(huì)被移到老年代的原因。在啟動(dòng)時(shí)可以通過添加參數(shù)來(lái)改變年齡閾值:

-XX:MaxTenuringThreshold

當(dāng)設(shè)置的閾值超過15時(shí),啟動(dòng)時(shí)會(huì)報(bào)錯(cuò):

java對(duì)象閾值報(bào)錯(cuò)

3.2 Klass Pointer 類型指針

Klass Pointer是一個(gè)指向方法區(qū)中Class信息的指針,虛擬機(jī)通過這個(gè)指針確定該對(duì)象屬于哪個(gè)類的實(shí)例。在64位的JVM中,支持指針壓縮功能,根據(jù)是否開啟指針壓縮,Klass Pointer占用的大小將會(huì)不同:

  • 未開啟指針壓縮時(shí),類型指針占用8B (64bit)
  • 開啟指針壓縮情況下,類型指針占用4B (32bit)

jdk6之后的版本中,指針壓縮是被默認(rèn)開啟的,可通過啟動(dòng)參數(shù)開啟或關(guān)閉該功能:

#開啟指針壓縮:
-XX:+UseCompressedOops
#關(guān)閉指針壓縮:
-XX:-UseCompressedOops

還是以剛才的User類為例,關(guān)閉指針壓縮后再次查看對(duì)象的內(nèi)存布局:

Klass Pointer 類型指針

對(duì)象大小雖然還是16字節(jié),但是組成發(fā)生了改變,8字節(jié)標(biāo)記字加8字節(jié)類型指針,已經(jīng)能滿足對(duì)齊條件,因此不需要填充。

8B (mark word) + 8B (klass pointer) + 0B (instance data) + 0B (padding)

3.2.1 指針壓縮原理

在了解了指針壓縮的作用后,我們來(lái)看一下指針壓縮是如何實(shí)現(xiàn)的。首先在不開啟指針壓縮的情況下,一個(gè)對(duì)象的內(nèi)存地址使用64位表示,這時(shí)能描述的內(nèi)存地址范圍是:

0 ~ 2^64-1

在開啟指針壓縮后,使用4個(gè)字節(jié)也就是32位,可以表示2^32 個(gè)內(nèi)存地址,如果這個(gè)地址是真實(shí)地址的話,由于CPU尋址的最小單位是Byte,那么就是4GB內(nèi)存。這對(duì)于我們來(lái)說(shuō)是遠(yuǎn)遠(yuǎn)不夠的,但是之前我們說(shuō)過,java中對(duì)象默認(rèn)使用了8字節(jié)對(duì)齊,也就是說(shuō)1個(gè)對(duì)象占用的空間必須是8字節(jié)的整數(shù)倍,這樣就創(chuàng)造了一個(gè)條件,使jvm在定位一個(gè)對(duì)象時(shí)不需要使用真正的內(nèi)存地址,而是定位到由java進(jìn)行了8字節(jié)映射后的地址(可以說(shuō)是一個(gè)映射地址的編號(hào))。

映射過程也非常簡(jiǎn)單,由于使用了8字節(jié)對(duì)齊后每個(gè)對(duì)象的地址偏移量后3位必定為0,所以在存儲(chǔ)的時(shí)候可以將后3位0抹除(轉(zhuǎn)化為bit是抹除了最后24位),在此基礎(chǔ)上再去掉最高位,就完成了指針從8字節(jié)到4字節(jié)的壓縮。而在實(shí)際使用時(shí),在壓縮后的指針后加3位0,就能夠?qū)崿F(xiàn)向真實(shí)地址的映射。

指針壓縮原理

完成壓縮后,現(xiàn)在指針的32位中的每一個(gè)bit,都可以代表8個(gè)字節(jié),這樣就相當(dāng)于使原有的內(nèi)存地址得到了8倍的擴(kuò)容。所以在8字節(jié)對(duì)齊的情況下,32位最大能表示2^32*8=32GB內(nèi)存,內(nèi)存地址范圍是:

0 ~ (2^32-1)*8

由于能夠表示的最大內(nèi)存是32GB,所以如果配置的最大的堆內(nèi)存超過這個(gè)數(shù)值時(shí),那么指針壓縮將會(huì)失效。配置jvm啟動(dòng)參數(shù):

-Xmx32g

查看對(duì)象內(nèi)存布局:

查看對(duì)象內(nèi)存布局

此時(shí),指針壓縮失效,指針長(zhǎng)度恢復(fù)到8字節(jié)。那么如果業(yè)務(wù)場(chǎng)景內(nèi)存超過32GB怎么辦呢,可以通過修改默認(rèn)對(duì)齊長(zhǎng)度進(jìn)行再次擴(kuò)展,我們將對(duì)齊長(zhǎng)度修改為16字節(jié):

-XX:ObjectAlignmentInBytes=16 -Xmx32g

指針壓縮失效

可以看到指針壓縮后占4字節(jié),同時(shí)對(duì)象向16字節(jié)進(jìn)行了填充對(duì)齊,按照上面的計(jì)算,這時(shí)配置最大堆內(nèi)存為64GB時(shí)指針壓縮才會(huì)失效。

對(duì)指針壓縮做一下簡(jiǎn)單總結(jié):

  • 通過指針壓縮,利用對(duì)齊填充的特性,通過映射方式達(dá)到了內(nèi)存地址擴(kuò)展的效果
  • 指針壓縮能夠節(jié)省內(nèi)存空間,同時(shí)提高了程序的尋址效率
  • 堆內(nèi)存設(shè)置時(shí)最好不要超過32GB,這時(shí)指針壓縮將會(huì)失效,造成空間的浪費(fèi)
  • 此外,指針壓縮不僅可以作用于對(duì)象頭的類型指針,還可以作用于引用類型的字段指針,以及引用類型數(shù)組指針

3.3 數(shù)組長(zhǎng)度

如果當(dāng)對(duì)象是一個(gè)數(shù)組對(duì)象時(shí),那么在對(duì)象頭中有一個(gè)保存數(shù)組長(zhǎng)度的空間,占用4字節(jié)(32bit)空間。通過下面代碼進(jìn)行測(cè)試:

public static void main(String[] args) {
    User[] user=new User[2];
    //查看對(duì)象的內(nèi)存布局
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

運(yùn)行代碼,結(jié)果如下:

數(shù)組長(zhǎng)度

內(nèi)存結(jié)構(gòu)從上到下分別為:

  • 8字節(jié)mark word
  • 4字節(jié)klass pointer
  • 4字節(jié)數(shù)組長(zhǎng)度,值為2,表示數(shù)組中有兩個(gè)元素
  • 開啟指針壓縮后每個(gè)引用類型占4字節(jié),數(shù)組中兩個(gè)元素共占8字節(jié)

需要注意的是,在未開啟指針壓縮的情況下,在數(shù)組長(zhǎng)度后會(huì)有一段對(duì)齊填充字節(jié):

未開啟指針壓縮

通過計(jì)算:

8B (mark word) + 8B (klass pointer) + 4B (array length) + 16B (instance data)=36B

需要向8字節(jié)進(jìn)行對(duì)齊,這里選擇將對(duì)齊的4字節(jié)添加在了數(shù)組長(zhǎng)度和實(shí)例數(shù)據(jù)之間。

4、實(shí)例數(shù)據(jù)

實(shí)例數(shù)據(jù)(Instance Data)保存的是對(duì)象真正存儲(chǔ)的有效信息,保存了代碼中定義的各種數(shù)據(jù)類型的字段內(nèi)容,并且如果有繼承關(guān)系存在,子類還會(huì)包含從父類繼承過來(lái)的字段。

  • 基本數(shù)據(jù)類型:
Type Bytes
byte,boolean 1
char,short 2
int,float 4
long,double 8

  • 引用數(shù)據(jù)類型:

開啟指針壓縮情況下占8字節(jié),開啟指針壓縮后占4字節(jié)。

4.1 字段重排序

給User類添加基本數(shù)據(jù)類型的屬性字段:

public class User {
    int id,age,weight;
    byte sex;
    long phone;
    char local;
}

查看內(nèi)存布局:

查看內(nèi)存布局

可以看到,在內(nèi)存中,屬性的排列順序與在類中定義的順序不同,這是因?yàn)閖vm會(huì)采用字段重排序技術(shù),對(duì)原始類型進(jìn)行重新排序,以達(dá)到內(nèi)存對(duì)齊的目的。具體規(guī)則遵循如下:

  • 按照數(shù)據(jù)類型的長(zhǎng)度大小,從大到小排列
  • 具有相同長(zhǎng)度的字段,會(huì)被分配在相鄰位置
  • 如果一個(gè)字段的長(zhǎng)度是L個(gè)字節(jié),那么這個(gè)字段的偏移量(OFFSET)需要對(duì)齊至nL(n為整數(shù))

上面的前兩條規(guī)則相對(duì)容易理解,這里通過舉例對(duì)第3條進(jìn)行解釋:

因?yàn)?code>long類型占8字節(jié),所以它的偏移量必定是8n,再加上前面對(duì)象頭占12字節(jié),所以long類型變量的最小偏移量是16。通過打印對(duì)象內(nèi)存布局可以發(fā)現(xiàn),當(dāng)對(duì)象頭不是8字節(jié)的整數(shù)倍時(shí)(只存在8n+4字節(jié)情況),會(huì)按從大到小的順序,使用4、2、1字節(jié)長(zhǎng)度的屬性進(jìn)行補(bǔ)位。為了和對(duì)齊填充進(jìn)行區(qū)分,可以稱其為前置補(bǔ)位,如果在補(bǔ)位后仍然不滿足8字節(jié)整數(shù)倍,會(huì)進(jìn)行對(duì)齊填充。在存在前置補(bǔ)位的情況下,字段的排序會(huì)打破上面的第一條規(guī)則。

因此在上面的內(nèi)存布局中,先使用4字節(jié)的int進(jìn)行前置補(bǔ)位,再按第一條規(guī)則從大到小順序進(jìn)行排列。如果我們刪除3個(gè)int類型的字段,再查看內(nèi)存布局:

內(nèi)存布局

charbyte類型的變量被提到前面進(jìn)行前置補(bǔ)位,并在long類型前進(jìn)行了1字節(jié)的對(duì)齊填充。

4.2 擁有父類情況

  • 當(dāng)一個(gè)類擁有父類時(shí),整體遵循在父類中定義的變量出現(xiàn)在子類中定義的變量之前的原則

public class A {
    int i1,i2;
    long l1,l2;
    char c1,c2;
}
public class B extends A{
    boolean b1;
    double d1,d2;
}

查看內(nèi)存結(jié)構(gòu):

查看內(nèi)存結(jié)構(gòu)

  • 如果父類需要后置補(bǔ)位的情況,可能會(huì)將子類中類型長(zhǎng)度較短的變量提前,但是整體還是遵循子類在父類之后的原則

public class A {
    int i1,i2;
    long l1;
}
public class B extends A {
    int i1,i2;
    long l1;
}

查看內(nèi)存結(jié)構(gòu):

查看內(nèi)存結(jié)構(gòu)

可以看到,子類中較短長(zhǎng)度的變量被提前到父類后進(jìn)行了后置補(bǔ)位。

  • 父類的前置對(duì)齊填充會(huì)被子類繼承

public class A {
    long l;
}
public class B extends A{
    long l2;
    int i1;
}

查看內(nèi)存結(jié)構(gòu):

查看內(nèi)存結(jié)構(gòu)

當(dāng)B類沒有繼承A類時(shí),正好滿足8字節(jié)對(duì)齊,不需要進(jìn)行對(duì)齊填充。當(dāng)B類繼承A類后,會(huì)繼承A類的前置補(bǔ)位填充,因此在B類的末尾也需要對(duì)齊填充。

4.3 引用數(shù)據(jù)類型

在上面的例子中,僅探討了基本數(shù)據(jù)類型的排序情況,那么如果存在引用數(shù)據(jù)類型時(shí),排序情況是怎樣的呢?在User類中添加引用類型:

public class User {
     int id;
     String firstName;
     String lastName;
     int age;
}

查看內(nèi)存布局:

查看內(nèi)存布局

可以看到默認(rèn)情況下,基本數(shù)據(jù)類型的變量排在引用數(shù)據(jù)類型前。這個(gè)順序可以在jvm啟動(dòng)參數(shù)中進(jìn)行修改:

-XX:FieldsAllocationStyle=0

重新運(yùn)行,可以看到引用數(shù)據(jù)類型的排列順序被放在了前面:

jvm啟動(dòng)參數(shù)

對(duì)FieldsAllocationStyle的不同取值簡(jiǎn)要說(shuō)明:

  • 0:先放入普通對(duì)象的引用指針,再放入基本數(shù)據(jù)類型變量
  • 1:默認(rèn)情況,表示先放入基本數(shù)據(jù)類型變量,再放入普通對(duì)象的引用指針

4.4 靜態(tài)變量

在上面的基礎(chǔ)上,在類中加入靜態(tài)變量:

public class User {
     int id;
     static byte local;
}

查看內(nèi)存布局:

查看內(nèi)存布局

通過結(jié)果可以看到,靜態(tài)變量并不在對(duì)象的內(nèi)存布局中,它的大小是不計(jì)算在對(duì)象中的,因?yàn)殪o態(tài)變量屬于類而不是屬于某一個(gè)對(duì)象的。

5、對(duì)齊填充字節(jié)

Hotspot的自動(dòng)內(nèi)存管理系統(tǒng)中,要求對(duì)象的起始地址必須是8字節(jié)的整數(shù)倍,也就是說(shuō)對(duì)象的大小必須滿足8字節(jié)的整數(shù)倍。因此如果實(shí)例數(shù)據(jù)沒有對(duì)齊,那么需要進(jìn)行對(duì)齊補(bǔ)全空缺,補(bǔ)全的bit位僅起占位符作用,不具有特殊含義。

在前面的例子中,我們已經(jīng)對(duì)對(duì)齊填充有了充分的認(rèn)識(shí),下面再做一些補(bǔ)充:

  • 在開啟指針壓縮的情況下,如果類中有long/double類型的變量時(shí),會(huì)在對(duì)象頭和實(shí)例數(shù)據(jù)間形成間隙(gap),為了節(jié)省空間,會(huì)默認(rèn)把較短長(zhǎng)度的變量放在前邊,這一功能可以通過jvm參數(shù)進(jìn)行開啟或關(guān)閉:

# 開啟
-XX:+CompactFields
# 關(guān)閉
-XX:-CompactFields

測(cè)試關(guān)閉情況,可以看到較短長(zhǎng)度的變量沒有前移填充:

較短長(zhǎng)度的變量沒有前移填充

  • 在前面指針壓縮中,我們提到了可以改變對(duì)齊寬度,這也是通過修改下面的jvm參數(shù)配置實(shí)現(xiàn)的:

-XX:ObjectAlignmentInBytes

默認(rèn)情況下對(duì)齊寬度為8,這個(gè)值可以修改為2~256以內(nèi)2的整數(shù)冪,一般情況下都以8字節(jié)對(duì)齊或16字節(jié)對(duì)齊。測(cè)試修改為16字節(jié)對(duì)齊:

java對(duì)象

上面的例子中,在調(diào)整為16字節(jié)對(duì)齊的情況下,最后一行的屬性字段只占了6字節(jié),因此會(huì)添加10字節(jié)進(jìn)行對(duì)齊填充。當(dāng)然普通情況下不建議修改對(duì)齊長(zhǎng)度參數(shù),如果對(duì)齊寬度過長(zhǎng),可能會(huì)導(dǎo)致內(nèi)存空間的浪費(fèi)。

6、總結(jié)

本文通過使用jol 對(duì)java對(duì)象的結(jié)構(gòu)進(jìn)行調(diào)試,學(xué)習(xí)了對(duì)象內(nèi)存布局的基本知識(shí)。通過學(xué)習(xí),能夠幫助我們:

  • 掌握對(duì)象內(nèi)存布局,基于此基礎(chǔ)進(jìn)行jvm參數(shù)調(diào)優(yōu)
  • 了解對(duì)象頭在synchronize 的鎖升級(jí)過程中的作用
  • 熟悉 jvm 中對(duì)象的尋址過程
  • 通過計(jì)算對(duì)象大小,可以在評(píng)估業(yè)務(wù)量的基礎(chǔ)上在項(xiàng)目上線前預(yù)估需要使用多少內(nèi)存,防止服務(wù)器頻繁 gc

來(lái)源:公眾號(hào) 碼農(nóng)參上 作者:Dr Hydra

0 人點(diǎn)贊