圖文詳解Java物件記憶體佈局

碼農參上發表於2021-04-03

作為一名Java程式設計師,我們在日常工作中使用這款物件導向的程式語言時,做的最頻繁的操作大概就是去建立一個個的物件了。物件的建立方式雖然有很多,可以通過new、反射、clone、反序列化等不同方式來建立,但最終使用時物件都要被放到記憶體中,那麼你知道在記憶體中的java物件是由哪些部分組成、又是怎麼儲存的嗎?

本文將基於程式碼進行例項測試,詳細探討物件在記憶體中的組成結構。全文目錄結構如下:

文中程式碼基於 JDK 1.8.0_261,64-Bit HotSpot 執行

1、物件記憶體結構概述

在介紹物件在記憶體中的組成結構前,我們先簡要回顧一個物件的建立過程:

1、jvm將物件所在的class檔案載入到方法區中

2、jvm讀取main方法入口,將main方法入棧,執行建立物件程式碼

3、在main方法的棧記憶體中分配物件的引用,在堆中分配記憶體放入建立的物件,並將棧中的引用指向堆中的物件

所以當物件在例項化完成之後,是被存放在堆記憶體中的,這裡的物件由3部分組成,如下圖所示:

圖文詳解Java物件記憶體佈局

對各個組成部分的功能簡要進行說明:

  • 物件頭:物件頭儲存的是物件在執行時狀態的相關資訊、指向該物件所屬類的後設資料的指標,如果物件是陣列物件那麼還會額外儲存物件的陣列長度

  • 例項資料:例項資料儲存的是物件的真正有效資料,也就是各個屬性欄位的值,如果在擁有父類的情況下,還會包含父類的欄位。欄位的儲存順序會受到資料型別長度、以及虛擬機器的分配策略的影響

  • 對齊填充位元組:在java物件中,需要對齊填充位元組的原因是,64位的jvm中物件的大小被要求向8位元組對齊,因此當物件的長度不足8位元組的整數倍時,需要在物件中進行填充操作。注意圖中對齊填充部分使用了虛線,這是因為填充位元組並不是固定存在的部分,這點在後面計算物件大小時具體進行說明

2、JOL 工具簡介

在具體開始研究物件的記憶體結構之前,先介紹一下我們要用到的工具,openjdk官網提供了檢視物件記憶體佈局的工具jol (java object layout),可在maven中引入座標:

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

在程式碼中使用jol提供的方法檢視jvm資訊:

System.out.println(VM.current().details());
圖文詳解Java物件記憶體佈局

通過列印出來的資訊,可以看到我們使用的是64位 jvm,並開啟了指標壓縮,物件預設使用8位元組對齊方式。通過jol檢視物件記憶體佈局的方法,將在後面的例子中具體展示,下面開始物件記憶體佈局的正式學習。

3、物件頭

首先看一下物件頭(Object header)的組成部分,根據普通物件和陣列物件的不同,結構將會有所不同。只有當物件是陣列物件才會有陣列長度部分,普通物件沒有該部分,如下圖所示:

圖文詳解Java物件記憶體佈局

在物件頭中mark word 佔8位元組,預設開啟指標壓縮的情況下Klass pointer 佔4位元組,陣列物件的陣列長度佔4位元組。在瞭解了物件頭的基礎結構後,現在以一個不包含任何屬性的空物件為例,檢視一下它的記憶體佈局,建立User類:

public class User {
}

使用jol檢視物件頭的記憶體佈局:

public static void main(String[] args) {
    User user=new User();
    //檢視物件的記憶體佈局
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

執行程式碼,檢視列印資訊:

圖文詳解Java物件記憶體佈局
  • OFFSET:偏移地址,單位為位元組
  • SIZE:佔用記憶體大小,單位為位元組
  • TYPEClass中定義的型別
  • DESCRIPTION:型別描述,Obejct header 表示物件頭,alignment表示對齊填充
  • VALUE:對應記憶體中儲存的值

當前物件共佔用16位元組,因為8位元組標記字加4位元組的型別指標,不滿足向8位元組對齊,因此需要填充4個位元組:

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

這樣我們就通過直觀的方式,瞭解了一個不包含屬性的最簡單的空物件,在記憶體中的基本組成是怎樣的。在此基礎上,我們來深入學習物件頭中各個組成部分。

3.1 Mark Word 標記字

在物件頭中,mark word 一共有64個bit,用於儲存物件自身的執行時資料,標記物件處於以下5種狀態中的某一種:

3.1.1 鎖升級

在jdk6 之前,通過synchronized關鍵字加鎖時使用無差別的的重量級鎖,重量級鎖會造成執行緒的序列執行,並且使CPU在使用者態和核心態之間頻繁切換。隨著對synchronized的不斷優化,提出了鎖升級的概念,並引入了偏向鎖、輕量級鎖、重量級鎖。在mark word中,鎖(lock)標誌位佔用2個bit,結合1個bit偏向鎖(biased_lock)標誌位,這樣通過倒數的3位,就能用來標識當前物件持有的鎖的狀態,並判斷出其餘位儲存的是什麼資訊。

基於mark word的鎖升級的流程如下:

1、鎖物件剛建立時,沒有任何執行緒競爭,物件處於無鎖狀態。在上面列印的空物件的記憶體佈局中,根據大小端,得到最後8位是00000001,表示處於無鎖態,並且處於不可偏向狀態。這是因為在jdk中偏向鎖存在延遲4秒啟動,也就是說在jvm啟動後4秒後建立的物件才會開啟偏向鎖,我們通過jvm引數取消這個延遲時間:

-XX:BiasedLockingStartupDelay=0

這時最後3位為101,表示當前物件的鎖沒有被持有,並且處於可被偏向狀態。

2、在沒有執行緒競爭的條件下,第一個獲取鎖的執行緒通過CAS將自己的threadId寫入到該物件的mark word中,若後續該執行緒再次獲取鎖,需要比較當前執行緒threadId和物件mark word中的threadId是否一致,如果一致那麼可以直接獲取,並且鎖物件始終保持對該執行緒的偏向,也就是說偏向鎖不會主動釋放。

使用程式碼進行測試同一個執行緒重複獲取鎖的過程:

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

執行結果:

可以看到一個執行緒對一個物件加鎖、解鎖、重新獲取物件的鎖時,mark word都沒有發生變化,偏向鎖中的當前執行緒指標始終指向同一個執行緒。

3、當兩個或以上執行緒交替獲取鎖,但並沒有在物件上併發的獲取鎖時,偏向鎖升級為輕量級鎖。在此階段,執行緒採取CAS的自旋方式嘗試獲取鎖,避免阻塞執行緒造成的cpu在使用者態和核心態間轉換的消耗。測試程式碼如下:

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

先直接看一下結果:

整個加鎖狀態的變化流程如下:

  • 主執行緒首先對user物件加鎖,首次加鎖為101偏向鎖
  • 子執行緒等待主執行緒釋放鎖後,對user物件加鎖,這時將偏向鎖升級為00輕量級鎖
  • 輕量級鎖解鎖後,user物件無執行緒競爭,恢復為001無鎖態,並且處於不可偏向狀態。如果之後有執行緒再嘗試獲取user物件的鎖,會直接加輕量級鎖,而不是偏向鎖

4、當兩個或以上執行緒併發的在同一個物件上進行同步時,為了避免無用自旋消耗cpu,輕量級鎖會升級成重量級鎖。這時mark word中的指標指向的是monitor物件(也被稱為管程或監視器鎖)的起始地址。測試程式碼如下:

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

檢視結果:

可以看到,在兩個執行緒同時競爭user物件的鎖時,會升級為10重量級鎖。

3.1.2 其他資訊

mark word 中其他重要資訊進行說明:

  • hashcode:無鎖態下的hashcode採用了延遲載入技術,在第一次呼叫hashCode()方法時才會計算寫入。對這一過程進行驗證:
public static void main(String[] args) {
    User user=new User();
    //列印記憶體佈局
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
    //計算hashCode
    System.out.println(user.hashCode());
    //再次列印記憶體佈局
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
}
圖文詳解Java物件記憶體佈局

可以看到,在沒有呼叫hashCode()方法前,31位的雜湊值不存在,全部填充為0。在呼叫方法後,根據大小端,被填充的資料為:

1011001001101100011010010101101

將2進位制轉換為10進位制,對應雜湊值1496724653。需要注意,只有在呼叫沒有被重寫的Object.hashCode()方法或System.identityHashCode(Object)方法才會寫入mark word,執行使用者自定義的hashCode()方法不會被寫入。

大家可能會注意到,當物件被加鎖後,mark word中就沒有足夠空間來儲存hashCode了,這時hashcode會被移動到重量級鎖的Object Monitor中。

  • epoch:偏向鎖的時間戳

  • 分代年齡(age):在jvm的垃圾回收過程中,每當物件經過一次Young GC,年齡都會加1,這裡4位來表示分代年齡最大值為15,這也就是為什麼物件的年齡超過15後會被移到老年代的原因。在啟動時可以通過新增引數來改變年齡閾值:

-XX:MaxTenuringThreshold

當設定的閾值超過15時,啟動時會報錯:

圖文詳解Java物件記憶體佈局

3.2 Klass Pointer 型別指標

Klass Pointer是一個指向方法區中Class資訊的指標,虛擬機器通過這個指標確定該物件屬於哪個類的例項。在64位的JVM中,支援指標壓縮功能,根據是否開啟指標壓縮,Klass Pointer佔用的大小將會不同:

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

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

#開啟指標壓縮:
-XX:+UseCompressedOops
#關閉指標壓縮:
-XX:-UseCompressedOops

還是以剛才的User類為例,關閉指標壓縮後再次檢視物件的記憶體佈局:

物件大小雖然還是16位元組,但是組成發生了改變,8位元組標記字加8位元組型別指標,已經能滿足對齊條件,因此不需要填充。

8B (mark word) + 8B (klass pointer) + 0B (instance data) + 0B (padding)
3.2.1 指標壓縮原理

在瞭解了指標壓縮的作用後,我們來看一下指標壓縮是如何實現的。首先在不開啟指標壓縮的情況下,一個物件的記憶體地址使用64位表示,這時能描述的記憶體地址範圍是:

0 ~ 2^64-1

在開啟指標壓縮後,使用4個位元組也就是32位,可以表示2^32 個記憶體地址,如果這個地址是真實地址的話,由於CPU定址的最小單位是Byte,那麼就是4GB記憶體。這對於我們來說是遠遠不夠的,但是之前我們說過,java中物件預設使用了8位元組對齊,也就是說1個物件佔用的空間必須是8位元組的整數倍,這樣就創造了一個條件,使jvm在定位一個物件時不需要使用真正的記憶體地址,而是定位到由java進行了8位元組對映後的地址(可以說是一個對映地址的編號)。

完成壓縮後,現在指標的32位中的每一個bit,都可以代表8個位元組,這樣就相當於使原有的記憶體地址得到了8倍的擴容。所以在8位元組對齊的情況下,32位最大能表示2^32*8=32GB記憶體,記憶體地址範圍是:

0 ~ (2^32-1)*8

由於能夠表示的最大記憶體是32GB,所以如果配置的最大的堆記憶體超過這個數值時,那麼指標壓縮將會失效。配置jvm啟動引數:

-Xmx32g

檢視物件記憶體佈局:

圖文詳解Java物件記憶體佈局

此時,指標壓縮失效,指標長度恢復到8位元組。那麼如果業務場景記憶體超過32GB怎麼辦呢,可以通過修改預設對齊長度進行再次擴充套件,我們將對齊長度修改為16位元組:

-XX:ObjectAlignmentInBytes=16 -Xmx32g
圖文詳解Java物件記憶體佈局

可以看到指標壓縮後佔4位元組,同時物件向16位元組進行了填充對齊,按照上面的計算,這時配置最大堆記憶體為64GB時指標壓縮才會失效。

對指標壓縮做一下簡單總結:

  • 通過指標壓縮,利用對齊填充的特性,通過對映方式達到了記憶體地址擴充套件的效果
  • 指標壓縮能夠節省記憶體空間,同時提高了程式的定址效率
  • 堆記憶體設定時最好不要超過32GB,這時指標壓縮將會失效,造成空間的浪費
  • 此外,指標壓縮不僅可以作用於物件頭的型別指標,還可以作用於引用型別的欄位指標,以及引用型別陣列指標

3.3 陣列長度

如果當物件是一個陣列物件時,那麼在物件頭中有一個儲存陣列長度的空間,佔用4位元組(32bit)空間。通過下面程式碼進行測試:

public static void main(String[] args) {
    User[] user=new User[2];
    //檢視物件的記憶體佈局
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

執行程式碼,結果如下:

圖文詳解Java物件記憶體佈局

記憶體結構從上到下分別為:

  • 8位元組mark word
  • 4位元組klass pointer
  • 4位元組陣列長度,值為2,表示陣列中有兩個元素
  • 開啟指標壓縮後每個引用型別佔4位元組,陣列中兩個元素共佔8位元組

需要注意的是,在未開啟指標壓縮的情況下,在陣列長度後會有一段對齊填充位元組:

圖文詳解Java物件記憶體佈局

通過計算:

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

需要向8位元組進行對齊,這裡選擇將對齊的4位元組新增在了陣列長度和例項資料之間。

4、例項資料

例項資料(Instance Data)儲存的是物件真正儲存的有效資訊,儲存了程式碼中定義的各種資料型別的欄位內容,並且如果有繼承關係存在,子類還會包含從父類繼承過來的欄位。

  • 基本資料型別:
Type Bytes
byte,boolean 1
char,short 2
int,float 4
long,double 8
  • 引用資料型別:

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

4.1 欄位重排序

給User類新增基本資料型別的屬性欄位:

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

檢視記憶體佈局:

圖文詳解Java物件記憶體佈局

可以看到,在記憶體中,屬性的排列順序與在類中定義的順序不同,這是因為jvm會採用欄位重排序技術,對原始型別進行重新排序,以達到記憶體對齊的目的。具體規則遵循如下:

  • 按照資料型別的長度大小,從大到小排列
  • 具有相同長度的欄位,會被分配在相鄰位置
  • 如果一個欄位的長度是L個位元組,那麼這個欄位的偏移量(OFFSET)需要對齊至nL(n為整數)

上面的前兩條規則相對容易理解,這裡通過舉例對第3條進行解釋:

因為long型別佔8位元組,所以它的偏移量必定是8n,再加上前面物件頭佔12位元組,所以long型別變數的最小偏移量是16。通過列印物件記憶體佈局可以發現,當物件頭不是8位元組的整數倍時(只存在8n+4位元組情況),會按從大到小的順序,使用4、2、1位元組長度的屬性進行補位。為了和對齊填充進行區分,可以稱其為前置補位,如果在補位後仍然不滿足8位元組整數倍,會進行對齊填充。在存在前置補位的情況下,欄位的排序會打破上面的第一條規則。

因此在上面的記憶體佈局中,先使用4位元組的int進行前置補位,再按第一條規則從大到小順序進行排列。如果我們刪除3個int型別的欄位,再檢視記憶體佈局:

圖文詳解Java物件記憶體佈局

charbyte型別的變數被提到前面進行前置補位,並在long型別前進行了1位元組的對齊填充。

4.2 擁有父類情況

  • 當一個類擁有父類時,整體遵循在父類中定義的變數出現在子類中定義的變數之前的原則
public class A {
    int i1,i2;
    long l1,l2;
    char c1,c2;
}
public class B extends A{
    boolean b1;
    double d1,d2;
}

檢視記憶體結構:

圖文詳解Java物件記憶體佈局
  • 如果父類需要後置補位的情況,可能會將子類中型別長度較短的變數提前,但是整體還是遵循子類在父類之後的原則
public class A {
    int i1,i2;
    long l1;
}
public class B extends A {
    int i1,i2;
    long l1;
}

檢視記憶體結構:

圖文詳解Java物件記憶體佈局

可以看到,子類中較短長度的變數被提前到父類後進行了後置補位。

  • 父類的前置對齊填充會被子類繼承
public class A {
    long l;
}
public class B extends A{
    long l2;
    int i1;
}

檢視記憶體結構:

圖文詳解Java物件記憶體佈局

當B類沒有繼承A類時,正好滿足8位元組對齊,不需要進行對齊填充。當B類繼承A類後,會繼承A類的前置補位填充,因此在B類的末尾也需要對齊填充。

4.3 引用資料型別

在上面的例子中,僅探討了基本資料型別的排序情況,那麼如果存在引用資料型別時,排序情況是怎樣的呢?在User類中新增引用型別:

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

檢視記憶體佈局:

圖文詳解Java物件記憶體佈局

可以看到預設情況下,基本資料型別的變數排在引用資料型別前。這個順序可以在jvm啟動引數中進行修改:

-XX:FieldsAllocationStyle=0

重新執行,可以看到引用資料型別的排列順序被放在了前面:

圖文詳解Java物件記憶體佈局

FieldsAllocationStyle的不同取值簡要說明:

  • 0:先放入普通物件的引用指標,再放入基本資料型別變數

  • 1:預設情況,表示先放入基本資料型別變數,再放入普通物件的引用指標

4.4 靜態變數

在上面的基礎上,在類中加入靜態變數:

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

檢視記憶體佈局:

圖文詳解Java物件記憶體佈局

通過結果可以看到,靜態變數並不在物件的記憶體佈局中,它的大小是不計算在物件中的,因為靜態變數屬於類而不是屬於某一個物件的。

5、對齊填充位元組

Hotspot的自動記憶體管理系統中,要求物件的起始地址必須是8位元組的整數倍,也就是說物件的大小必須滿足8位元組的整數倍。因此如果例項資料沒有對齊,那麼需要進行對齊補全空缺,補全的bit位僅起佔位符作用,不具有特殊含義。

在前面的例子中,我們已經對對齊填充有了充分的認識,下面再做一些補充:

  • 在開啟指標壓縮的情況下,如果類中有long/double型別的變數時,會在物件頭和例項資料間形成間隙(gap),為了節省空間,會預設把較短長度的變數放在前邊,這一功能可以通過jvm引數進行開啟或關閉:
# 開啟
-XX:+CompactFields
# 關閉
-XX:-CompactFields

測試關閉情況,可以看到較短長度的變數沒有前移填充:

圖文詳解Java物件記憶體佈局
  • 在前面指標壓縮中,我們提到了可以改變對齊寬度,這也是通過修改下面的jvm引數配置實現的:
-XX:ObjectAlignmentInBytes

預設情況下對齊寬度為8,這個值可以修改為2~256以內2的整數冪,一般情況下都以8位元組對齊或16位元組對齊。測試修改為16位元組對齊:

圖文詳解Java物件記憶體佈局

上面的例子中,在調整為16位元組對齊的情況下,最後一行的屬性欄位只佔了6位元組,因此會新增10位元組進行對齊填充。當然普通情況下不建議修改對齊長度引數,如果對齊寬度過長,可能會導致記憶體空間的浪費。

6、總結

本文通過使用jol 對java物件進行測試,學習了物件記憶體佈局的基本知識。通過學習,能夠幫助我們:

  • 掌握物件記憶體佈局,基於此基礎進行jvm引數調優
  • 瞭解物件頭在synchronize 的鎖升級過程中的作用
  • 熟悉 jvm 中物件的定址過程
  • 通過計算物件大小,可以在評估業務量的基礎上在專案上線前預估需要使用多少記憶體,防止伺服器頻繁gc

如果文章對您有所幫助,歡迎關注公眾號 碼農參上

圖文詳解Java物件記憶體佈局

相關文章