圖文詳解 Java 對象內存佈局
轉自:碼農參上 / Dr Hydra
作爲一名 Java 程序員,我們在日常工作中使用這款面向對象的編程語言時,做的最頻繁的操作大概就是去創建一個個的對象了。對象的創建方式雖然有很多,可以通過new
、反射、clone
、反序列化等不同方式來創建,但最終使用時對象都要被放到內存中,那麼你知道在內存中的 java 對象是由哪些部分組成、又是怎麼存儲的嗎?
本文將基於代碼進行實例測試,詳細探討對象在內存中的組成結構。全文目錄結構如下:
-
1、對象內存結構概述
-
2、JOL 工具簡介
-
3、對象頭
-
4、實例數據
-
5、對齊填充字節
-
6、總結
文中代碼基於 JDK 1.8.0_261,64-Bit HotSpot 運行
1、對象內存結構概述
在介紹對象在內存中的組成結構前,我們先簡要回顧一個對象的創建過程:
1、jvm 將對象所在的class
文件加載到方法區中
2、jvm 讀取main
方法入口,將main
方法入棧,執行創建對象代碼
3、在main
方法的棧內存中分配對象的引用,在堆中分配內存放入創建的對象,並將棧中的引用指向堆中的對象
所以當對象在實例化完成之後,是被存放在堆內存中的,這裏的對象由 3 部分組成,如下圖所示:
對各個組成部分的功能簡要進行說明:
-
對象頭:對象頭存儲的是對象在運行時狀態的相關信息、指向該對象所屬類的元數據的指針,如果對象是數組對象那麼還會額外存儲對象的數組長度
-
實例數據:實例數據存儲的是對象的真正有效數據,也就是各個屬性字段的值,如果在擁有父類的情況下,還會包含父類的字段。字段的存儲順序會受到數據類型長度、以及虛擬機的分配策略的影響
-
對齊填充字節:在 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());
通過打印出來的信息,可以看到我們使用的是 64 位 jvm,並開啓了指針壓縮,對象默認使用 8 字節對齊方式。通過jol
查看對象內存佈局的方法,將在後面的例子中具體展示,下面開始對象內存佈局的正式學習。
3、對象頭
首先看一下對象頭(Object header
)的組成部分,根據普通對象和數組對象的不同,結構將會有所不同。只有當對象是數組對象纔會有數組長度部分,普通對象沒有該部分,如下圖所示:
在對象頭中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());
}
執行代碼,查看打印信息:
-
OFFSET
:偏移地址,單位爲字節 -
SIZE
:佔用內存大小,單位爲字節 -
TYPE
:Class
中定義的類型 -
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 基於 mark word 的鎖升級
在 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());
}
可以看到,在沒有調用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 時,啓動時會報錯:
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 字節映射後的地址(可以說是一個映射地址的編號)。
映射過程也非常簡單,由於使用了 8 字節對齊後每個對象的地址偏移量後 3 位必定爲 0,所以在存儲的時候可以將後 3 位 0 抹除(轉化爲bit
是抹除了最後 24 位),在此基礎上再去掉最高位,就完成了指針從 8 字節到 4 字節的壓縮。而在實際使用時,在壓縮後的指針後加 3 位 0,就能夠實現向真實地址的映射。
完成壓縮後,現在指針的 32 位中的每一個bit
,都可以代表 8 個字節,這樣就相當於使原有的內存地址得到了 8 倍的擴容。所以在 8 字節對齊的情況下,32 位最大能表示2^32*8=32GB
內存,內存地址範圍是:
0 ~ (2^32-1)*8
由於能夠表示的最大內存是 32GB,所以如果配置的最大的堆內存超過這個數值時,那麼指針壓縮將會失效。配置 jvm 啓動參數:
-Xmx32g
查看對象內存佈局:
此時,指針壓縮失效,指針長度恢復到 8 字節。那麼如果業務場景內存超過 32GB 怎麼辦呢,可以通過修改默認對齊長度進行再次擴展,我們將對齊長度修改爲 16 字節:
-XX:ObjectAlignmentInBytes=16 -Xmx32g
可以看到指針壓縮後佔 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());
}
運行代碼,結果如下:
內存結構從上到下分別爲:
-
8 字節
mark word
-
4 字節
klass pointer
-
4 字節數組長度,值爲 2,表示數組中有兩個元素
-
開啓指針壓縮後每個引用類型佔 4 字節,數組中兩個元素共佔 8 字節
需要注意的是,在未開啓指針壓縮的情況下,在數組長度後會有一段對齊填充字節:
通過計算:
8B (mark word) + 8B (klass pointer) + 4B (array length) + 16B (instance data)=36B
需要向 8 字節進行對齊,這裏選擇將對齊的 4 字節添加在了數組長度和實例數據之間。
4、實例數據
實例數據(Instance Data
)保存的是對象真正存儲的有效信息,保存了代碼中定義的各種數據類型的字段內容,並且如果有繼承關係存在,子類還會包含從父類繼承過來的字段。
- 基本數據類型:
- 引用數據類型:
開啓指針壓縮情況下佔 8 字節,開啓指針壓縮後佔 4 字節。
4.1 字段重排序
給 User 類添加基本數據類型的屬性字段:
public class User {
int id,age,weight;
byte sex;
long phone;
char local;
}
查看內存佈局:
可以看到,在內存中,屬性的排列順序與在類中定義的順序不同,這是因爲 jvm 會採用字段重排序技術,對原始類型進行重新排序,以達到內存對齊的目的。具體規則遵循如下:
-
按照數據類型的長度大小,從大到小排列
-
具有相同長度的字段,會被分配在相鄰位置
-
如果一個字段的長度是 L 個字節,那麼這個字段的偏移量(
OFFSET
)需要對齊至nL
(n 爲整數)
上面的前兩條規則相對容易理解,這裏通過舉例對第 3 條進行解釋:
因爲long
類型佔 8 字節,所以它的偏移量必定是 8n,再加上前面對象頭佔 12 字節,所以long
類型變量的最小偏移量是 16。通過打印對象內存佈局可以發現,當對象頭不是 8 字節的整數倍時(只存在8n+4
字節情況),會按從大到小的順序,使用 4、2、1 字節長度的屬性進行補位。爲了和對齊填充進行區分,可以稱其爲前置補位,如果在補位後仍然不滿足 8 字節整數倍,會進行對齊填充。在存在前置補位的情況下,字段的排序會打破上面的第一條規則。
因此在上面的內存佈局中,先使用 4 字節的int
進行前置補位,再按第一條規則從大到小順序進行排列。如果我們刪除 3 個int
類型的字段,再查看內存佈局:
char
和byte
類型的變量被提到前面進行前置補位,並在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;
}
查看內存結構:
- 如果父類需要後置補位的情況,可能會將子類中類型長度較短的變量提前,但是整體還是遵循子類在父類之後的原則
public class A {
int i1,i2;
long l1;
}
public class B extends A {
int i1,i2;
long l1;
}
查看內存結構:
可以看到,子類中較短長度的變量被提前到父類後進行了後置補位。
- 父類的前置對齊填充會被子類繼承
public class A {
long l;
}
public class B extends A{
long l2;
int i1;
}
查看內存結構:
當 B 類沒有繼承 A 類時,正好滿足 8 字節對齊,不需要進行對齊填充。當 B 類繼承 A 類後,會繼承 A 類的前置補位填充,因此在 B 類的末尾也需要對齊填充。
4.3 引用數據類型
在上面的例子中,僅探討了基本數據類型的排序情況,那麼如果存在引用數據類型時,排序情況是怎樣的呢?在User
類中添加引用類型:
public class User {
int id;
String firstName;
String lastName;
int age;
}
查看內存佈局:
可以看到默認情況下,基本數據類型的變量排在引用數據類型前。這個順序可以在jvm
啓動參數中進行修改:
-XX:FieldsAllocationStyle=0
重新運行,可以看到引用數據類型的排列順序被放在了前面:
對FieldsAllocationStyle
的不同取值簡要說明:
-
0:先放入普通對象的引用指針,再放入基本數據類型變量
-
1:默認情況,表示先放入基本數據類型變量,再放入普通對象的引用指針
4.4 靜態變量
在上面的基礎上,在類中加入靜態變量:
public class User {
int id;
static byte local;
}
查看內存佈局:
通過結果可以看到,靜態變量並不在對象的內存佈局中,它的大小是不計算在對象中的,因爲靜態變量屬於類而不是屬於某一個對象的。
5、對齊填充字節
在Hotspot
的自動內存管理系統中,要求對象的起始地址必須是 8 字節的整數倍,也就是說對象的大小必須滿足 8 字節的整數倍。因此如果實例數據沒有對齊,那麼需要進行對齊補全空缺,補全的bit
位僅起佔位符作用,不具有特殊含義。
在前面的例子中,我們已經對對齊填充有了充分的認識,下面再做一些補充:
- 在開啓指針壓縮的情況下,如果類中有
long/double
類型的變量時,會在對象頭和實例數據間形成間隙(gap
),爲了節省空間,會默認把較短長度的變量放在前邊,這一功能可以通過 jvm 參數進行開啓或關閉:
# 開啓
-XX:+CompactFields
# 關閉
-XX:-CompactFields
測試關閉情況,可以看到較短長度的變量沒有前移填充:
- 在前面指針壓縮中,我們提到了可以改變對齊寬度,這也是通過修改下面的 jvm 參數配置實現的:
-XX:ObjectAlignmentInBytes
默認情況下對齊寬度爲 8,這個值可以修改爲 2~256 以內 2 的整數冪,一般情況下都以 8 字節對齊或 16 字節對齊。測試修改爲 16 字節對齊:
上面的例子中,在調整爲 16 字節對齊的情況下,最後一行的屬性字段只佔了 6 字節,因此會添加 10 字節進行對齊填充。當然普通情況下不建議修改對齊長度參數,如果對齊寬度過長,可能會導致內存空間的浪費。
6、總結
本文通過使用jol
對 java 對象的結構進行調試,學習了對象內存佈局的基本知識。通過學習,能夠幫助我們:
-
掌握對象內存佈局,基於此基礎進行 jvm 參數調優
-
瞭解對象頭在
synchronize
的鎖升級過程中的作用 -
熟悉 jvm 中對象的尋址過程
-
通過計算對象大小,可以在評估業務量的基礎上在項目上線前預估需要使用多少內存,防止服務器頻繁 gc
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/b9gXZLgccK8mWZc0xVk4Ag