做爲一名Java程序員,咱們在平常工做中使用這款面向對象的編程語言時,作的最頻繁的操做大概就是去建立一個個的對象了。對象的建立方式雖然有不少,能夠經過new
、反射、clone
、反序列化等不一樣方式來建立,但最終使用時對象都要被放到內存中,那麼你知道在內存中的java對象是由哪些部分組成、又是怎麼存儲的嗎?java
本文將基於代碼進行實例測試,詳細探討對象在內存中的組成結構。全文目錄結構以下:程序員
文中代碼基於 JDK 1.8.0_261,64-Bit HotSpot 運行shell
在介紹對象在內存中的組成結構前,咱們先簡要回顧一個對象的建立過程:編程
一、jvm將對象所在的class
文件加載到方法區中數組
二、jvm讀取main
方法入口,將main
方法入棧,執行建立對象代碼服務器
三、在main
方法的棧內存中分配對象的引用,在堆中分配內存放入建立的對象,並將棧中的引用指向堆中的對象併發
因此當對象在實例化完成以後,是被存放在堆內存中的,這裏的對象由3部分組成,以下圖所示:jvm
對各個組成部分的功能簡要進行說明:maven
對象頭:對象頭存儲的是對象在運行時狀態的相關信息、指向該對象所屬類的元數據的指針,若是對象是數組對象那麼還會額外存儲對象的數組長度編程語言
實例數據:實例數據存儲的是對象的真正有效數據,也就是各個屬性字段的值,若是在擁有父類的狀況下,還會包含父類的字段。字段的存儲順序會受到數據類型長度、以及虛擬機的分配策略的影響
對齊填充字節:在java對象中,須要對齊填充字節的緣由是,64位的jvm中對象的大小被要求向8字節對齊,所以當對象的長度不足8字節的整數倍時,須要在對象中進行填充操做。注意圖中對齊填充部分使用了虛線,這是由於填充字節並非固定存在的部分,這點在後面計算對象大小時具體進行說明
在具體開始研究對象的內存結構以前,先介紹一下咱們要用到的工具,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
查看對象內存佈局的方法,將在後面的例子中具體展現,下面開始對象內存佈局的正式學習。
首先看一下對象頭(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)
這樣咱們就經過直觀的方式,瞭解了一個不包含屬性的最簡單的空對象,在內存中的基本組成是怎樣的。在此基礎上,咱們來深刻學習對象頭中各個組成部分。
在對象頭中,mark word
一共有64個bit,用於存儲對象自身的運行時數據,標記對象處於如下5種狀態中的某一種:
在jdk6 以前,經過synchronized
關鍵字加鎖時使用無差異的的重量級鎖,重量級鎖會形成線程的串行執行,而且使CPU在用戶態和核心態之間頻繁切換。隨着對synchronized
的不斷優化,提出了鎖升級的概念,並引入了偏向鎖、輕量級鎖、重量級鎖。在mark word
中,鎖(lock
)標誌位佔用2個bit,結合1個bit偏向鎖(biased_lock
)標誌位,這樣經過倒數的3位,就能用來標識當前對象持有的鎖的狀態,並判斷出其他位存儲的是什麼信息。
基於mark word
的鎖升級的流程以下:
一、鎖對象剛建立時,沒有任何線程競爭,對象處於無鎖狀態。在上面打印的空對象的內存佈局中,根據大小端,獲得最後8位是00000001
,表示處於無鎖態,而且處於不可偏向狀態。這是由於在jdk中偏向鎖存在延遲4秒啓動,也就是說在jvm啓動後4秒後建立的對象纔會開啓偏向鎖,咱們經過jvm參數取消這個延遲時間:
-XX:BiasedLockingStartupDelay=0
這時最後3位爲101
,表示當前對象的鎖沒有被持有,而且處於可被偏向狀態。
二、在沒有線程競爭的條件下,第一個獲取鎖的線程經過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
都沒有發生變化,偏向鎖中的當前線程指針始終指向同一個線程。
三、當兩個或以上線程交替獲取鎖,但並無在對象上併發的獲取鎖時,偏向鎖升級爲輕量級鎖。在此階段,線程採起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()); }
先直接看一下結果:
整個加鎖狀態的變化流程以下:
101
偏向鎖00
輕量級鎖001
無鎖態,而且處於不可偏向狀態。若是以後有線程再嘗試獲取user對象的鎖,會直接加輕量級鎖,而不是偏向鎖四、當兩個或以上線程併發的在同一個對象上進行同步時,爲了不無用自旋消耗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
重量級鎖。
對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時,啓動時會報錯:
Klass Pointer
是一個指向方法區中Class
信息的指針,虛擬機經過這個指針肯定該對象屬於哪一個類的實例。在64位的JVM中,支持指針壓縮功能,根據是否開啓指針壓縮,Klass Pointer
佔用的大小將會不一樣:
在jdk6
以後的版本中,指針壓縮是被默認開啓的,可經過啓動參數開啓或關閉該功能:
#開啓指針壓縮: -XX:+UseCompressedOops #關閉指針壓縮: -XX:-UseCompressedOops
仍是以剛纔的User
類爲例,關閉指針壓縮後再次查看對象的內存佈局:
對象大小雖然仍是16字節,可是組成發生了改變,8字節標記字加8字節類型指針,已經能知足對齊條件,所以不須要填充。
8B (mark word) + 8B (klass pointer) + 0B (instance data) + 0B (padding)
在瞭解了指針壓縮的做用後,咱們來看一下指針壓縮是如何實現的。首先在不開啓指針壓縮的狀況下,一個對象的內存地址使用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
查看對象內存佈局:
此時,指針壓縮失效,指針長度恢復到8字節。那麼若是業務場景內存超過32GB怎麼辦呢,能夠經過修改默認對齊長度進行再次擴展,咱們將對齊長度修改成16字節:
-XX:ObjectAlignmentInBytes=16 -Xmx32g
能夠看到指針壓縮後佔4字節,同時對象向16字節進行了填充對齊,按照上面的計算,這時配置最大堆內存爲64GB時指針壓縮纔會失效。
對指針壓縮作一下簡單總結:
若是當對象是一個數組對象時,那麼在對象頭中有一個保存數組長度的空間,佔用4字節(32bit)空間。經過下面代碼進行測試:
public static void main(String[] args) { User[] user=new User[2]; //查看對象的內存佈局 System.out.println(ClassLayout.parseInstance(user).toPrintable()); }
運行代碼,結果以下:
內存結構從上到下分別爲:
mark word
klass pointer
須要注意的是,在未開啓指針壓縮的狀況下,在數組長度後會有一段對齊填充字節:
經過計算:
8B (mark word) + 8B (klass pointer) + 4B (array length) + 16B (instance data)=36B
須要向8字節進行對齊,這裏選擇將對齊的4字節添加在了數組長度和實例數據之間。
實例數據(Instance Data
)保存的是對象真正存儲的有效信息,保存了代碼中定義的各類數據類型的字段內容,而且若是有繼承關係存在,子類還會包含從父類繼承過來的字段。
Type | Bytes |
---|---|
byte,boolean | 1 |
char,short | 2 |
int,float | 4 |
long,double | 8 |
開啓指針壓縮狀況下佔8字節,開啓指針壓縮後佔4字節。
給User類添加基本數據類型的屬性字段:
public class User { int id,age,weight; byte sex; long phone; char local; }
查看內存佈局:
能夠看到,在內存中,屬性的排列順序與在類中定義的順序不一樣,這是由於jvm會採用字段重排序技術,對原始類型進行從新排序,以達到內存對齊的目的。具體規則遵循以下:
OFFSET
)須要對齊至nL
(n爲整數)上面的前兩條規則相對容易理解,這裏經過舉例對第3條進行解釋:
由於long
類型佔8字節,因此它的偏移量一定是8n,再加上前面對象頭佔12字節,因此long
類型變量的最小偏移量是16。經過打印對象內存佈局能夠發現,當對象頭不是8字節的整數倍時(只存在8n+4
字節狀況),會按從大到小的順序,使用四、二、1字節長度的屬性進行補位。爲了和對齊填充進行區分,能夠稱其爲前置補位,若是在補位後仍然不知足8字節整數倍,會進行對齊填充。在存在前置補位的狀況下,字段的排序會打破上面的第一條規則。
所以在上面的內存佈局中,先使用4字節的int
進行前置補位,再按第一條規則從大到小順序進行排列。若是咱們刪除3個int
類型的字段,再查看內存佈局:
char
和byte
類型的變量被提到前面進行前置補位,並在long
類型前進行了1字節的對齊填充。
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類的末尾也須要對齊填充。
在上面的例子中,僅探討了基本數據類型的排序狀況,那麼若是存在引用數據類型時,排序狀況是怎樣的呢?在User
類中添加引用類型:
public class User { int id; String firstName; String lastName; int age; }
查看內存佈局:
能夠看到默認狀況下,基本數據類型的變量排在引用數據類型前。這個順序能夠在jvm
啓動參數中進行修改:
-XX:FieldsAllocationStyle=0
從新運行,能夠看到引用數據類型的排列順序被放在了前面:
對FieldsAllocationStyle
的不一樣取值簡要說明:
0:先放入普通對象的引用指針,再放入基本數據類型變量
1:默認狀況,表示先放入基本數據類型變量,再放入普通對象的引用指針
在上面的基礎上,在類中加入靜態變量:
public class User { int id; static byte local; }
查看內存佈局:
經過結果能夠看到,靜態變量並不在對象的內存佈局中,它的大小是不計算在對象中的,由於靜態變量屬於類而不是屬於某一個對象的。
在Hotspot
的自動內存管理系統中,要求對象的起始地址必須是8字節的整數倍,也就是說對象的大小必須知足8字節的整數倍。所以若是實例數據沒有對齊,那麼須要進行對齊補全空缺,補全的bit
位僅起佔位符做用,不具備特殊含義。
在前面的例子中,咱們已經對對齊填充有了充分的認識,下面再作一些補充:
long/double
類型的變量時,會在對象頭和實例數據間造成間隙(gap
),爲了節省空間,會默認把較短長度的變量放在前邊,這一功能能夠經過jvm參數進行開啓或關閉:# 開啓 -XX:+CompactFields # 關閉 -XX:-CompactFields
測試關閉狀況,能夠看到較短長度的變量沒有前移填充:
-XX:ObjectAlignmentInBytes
默認狀況下對齊寬度爲8,這個值能夠修改成2~256之內2的整數冪,通常狀況下都以8字節對齊或16字節對齊。測試修改成16字節對齊:
上面的例子中,在調整爲16字節對齊的狀況下,最後一行的屬性字段只佔了6字節,所以會添加10字節進行對齊填充。固然普通狀況下不建議修改對齊長度參數,若是對齊寬度過長,可能會致使內存空間的浪費。
本文經過使用jol
對java對象進行測試,學習了對象內存佈局的基本知識。經過學習,可以幫助咱們:
synchronize
的鎖升級過程當中的做用若是文章對您有所幫助,歡迎關注公衆號 碼農參上