[轉帖]重磅硬核|一文聊透對象在JVM中的內存佈局等(一)

https://ost.51cto.com/posts/14747

 

大家好,我是bin,又到了每週我們見面的時刻了,我的公衆號在1月10號那天發佈了第一篇文章?《從內核角度看IO模型的演變》,在這篇文章中我們通過圖解的方式以一個C10k的問題爲主線,從內核角度詳細闡述了5種IO模型的演變過程,以及兩種IO線程模型的介紹,最後引出了Netty的網絡IO線程模型。讀者朋友們後臺留言都覺得非常的硬核,在大家的支持下這篇文章的目前閱讀量爲2038,點贊量爲80,在看爲32。這對於剛剛誕生一個多月的小號來說,是一種莫大的鼓勵。在這裏bin再次感謝大家的認可,鼓勵和支持~~

 

今天bin將再來爲大家帶來一篇硬核的技術文章,本文我們將從計算機組成原理的角度詳細闡述對象在JVM內存中是如何佈局的,以及什麼是內存對齊,如果我們頭比較鐵,就是不進行內存對齊會造成什麼樣的後果,最後引出壓縮指針的原理和應用。同時我們還介紹了在高併發場景下,False Sharing產生的原因以及帶來的性能影響。

 

相信大家看完本文後,一定會收穫很多,話不多說,下面我們正式開始本文的內容~~

重磅硬核|一文聊透對象在JVM中的內存佈局等(一)-鴻蒙開發者社區

 本文概要.png


在我們的日常工作中,有時候我們爲了防止線上應用發生OOM,所以我們需要在開發的過程中計算一些核心對象在內存中的佔用大小,目的是爲了更好的瞭解我們的應用程序內存佔用的一個大概情況。

 

進而根據我們服務器的內存資源限制以及預估的對象創建數量級計算出應用程序佔用內存的高低水位線,如果內存佔用量超過高水位線,那麼就有可能有發生OOM的風險。

 

我們可以在程序中根據估算出的高低水位線,做一些防止OOM的處理邏輯或者發出告警。

 

那麼核心問題是如何計算一個Java對象在內存中的佔用大小呢??

 

在爲大家解答這個問題之前,筆者先來介紹下Java對象在內存中的佈局,也就是本文的主題。

 

1. Java對象的內存佈局

重磅硬核|一文聊透對象在JVM中的內存佈局等(一)-鴻蒙開發者社區Java對象的內存佈局.png


如圖所示,Java對象在JVM中是用instanceOopDesc 結構表示而Java對象在JVM堆中的內存佈局可以分爲三部分:

 

1.1 對象頭(Header)


每個Java對象都包含一個對象頭,對象頭中包含了兩類信息:

 

 • MarkWord:在JVM中用markOopDesc 結構表示用於存儲對象自身運行時的數據。比如:hashcode,GC分代年齡,鎖狀態標誌,線程持有的鎖,偏向線程Id,偏向時間戳等。在32位操作系統和64位操作系統中MarkWord分別佔用4B和8B大小的內存。

 

 • 類型指針:JVM中的類型指針封裝在klassOopDesc 結構中,類型指針指向了InstanceKclass對象,Java類在JVM中是用InstanceKclass對象封裝的,裏邊包含了Java類的元信息,比如:繼承結構,方法,靜態變量,構造函數等。

 

     ◆在不開啓指針壓縮的情況下(-XX:-UseCompressedOops)。在32位操作系統和64位操作系統中類型指針分別佔用4B和8B大小的內存。

 

     ◆在開啓指針壓縮的情況下(-XX:+UseCompressedOops)。在32位操作系統和64位操作系統中類型指針分別佔用4B和4B大小的內存。

 

 • 如果Java對象是一個數組類型的話,那麼在數組對象的對象頭中還會包含一個4B大小的用於記錄數組長度的屬性。

 

由於在對象頭中用於記錄數組長度大小的屬性只佔4B的內存,所以Java數組可以申請的最大長度爲:2^32。

 

1.2 實例數據(Instance Data)


Java對象在內存中的實例數據區用來存儲Java類中定義的實例字段,包括所有父類中的實例字段。也就是說,雖然子類無法訪問父類的私有實例字段,或者子類的實例字段隱藏了父類的同名實例字段,但是子類的實例還是會爲這些父類實例字段分配內存。

 

Java對象中的字段類型分爲兩大類:

 

• 基礎類型:Java類中實例字段定義的基礎類型在實例數據區的內存佔用如下:


    ◆long | double佔用8個字節。
    ◆int | float佔用4個字節。
    ◆short | char佔用2個字節。
    ◆byte | boolean佔用1個字節。


• 引用類型:Java類中實例字段的引用類型在實例數據區內存佔用分爲兩種情況:


    ◆不開啓指針壓縮(-XX:-UseCompressedOops):在32位操作系統中引用類型的內存佔用爲4個字節。在64位操作系統中引用類型的內存佔用爲8個字節。
    ◆開啓指針壓縮(-XX:+UseCompressedOops):在64爲操作系統下,引用類型內存佔用則變爲爲4個字節,32位操作系統中引用類型的內存佔用繼續爲4個字節。

 

爲什麼32位操作系統的引用類型佔4個字節,而64位操作系統引用類型佔8字節?

 

在Java中,引用類型所保存的是被引用對象的內存地址。在32位操作系統中內存地址是由32個bit表示,因此需要4個字節來記錄內存地址,能夠記錄的虛擬地址空間是2^32大小,也就是隻能夠表示4G大小的內存。

 

而在64位操作系統中內存地址是由64個bit表示,因此需要8個字節來記錄內存地址,但在 64 位系統裏只使用了低 48 位,所以它的虛擬地址空間是 2^48大小,能夠表示256T大小的內存,其中低 128T 的空間劃分爲用戶空間,高 128T 劃分爲內核空間,可以說是非常大了。

在我們從整體上介紹完Java對象在JVM中的內存佈局之後,下面我們來看下Java對象中定義的這些實例字段在實例數據區是如何排列布局的:

 

2. 字段重排列


其實我們在編寫Java源代碼文件的時候定義的那些實例字段的順序會被JVM重新分配排列,這樣做的目的其實是爲了內存對齊,那麼什麼是內存對齊,爲什麼要進行內存對齊,筆者會隨着文章深入的解讀爲大家逐層揭曉答案~~

 

本小節中,筆者先來爲大家介紹一下JVM字段重排列的規則:

 

JVM重新分配字段的排列順序受-XX:FieldsAllocationStyle參數的影響,默認值爲1,實例字段的重新分配策略遵循以下規則:

 

1.如果一個字段佔用X個字節,那麼這個字段的偏移量OFFSET需要對齊至NX

 

偏移量是指字段的內存地址與Java對象的起始內存地址之間的差值。比如long類型的字段,它內存佔用8個字節,那麼它的OFFSET應該是8的倍數8N。不足8N的需要填充字節。

 

2.在開啓了壓縮指針的64位JVM中,Java類中的第一個字段的OFFSET需要對齊至4N,在關閉壓縮指針的情況下類中第一個字段的OFFSET需要對齊至8N。

 

3.JVM默認分配字段的順序爲:long / double,int / float,short / char,byte / boolean,oops(Ordianry Object Point 引用類型指針),並且父類中定義的實例變量會出現在子類實例變量之前。當設置JVM參數-XX +CompactFields 時(默認),佔用內存小於long / double 的字段會允許被插入到對象中第一個 long / double字段之前的間隙中,以避免不必要的內存填充。

 

CompactFields選項參數在JDK14中以被標記爲過期了,並在將來的版本中很可能被刪除。詳細細節可查看issue:https://bugs.openjdk.java.net/browse/JDK-8228750

 

上邊的三條字段重排列規則非常非常重要,但是讀起來比較繞腦,很抽象不容易理解,筆者把它們先列出來的目的是爲了讓大家先有一個朦朦朧朧的感性認識,下面筆者舉一個具體的例子來爲大家詳細說明下,在閱讀這個例子的過程中也方便大家深刻的理解這三條重要的字段重排列規則。

假設現在我們有這樣一個類定義

public class Parent {
    long l;
    int i;
}

public class Child extends Parent {
    long l;
    int i;
}

 • 根據上面介紹的規則3我們知道父類中的變量是出現在子類變量之前的,並且字段分配順序應該是long型字段l,應該在int型字段i之前。

 

如果JVM開啓了-XX +CompactFields時,int型字段是可以插入對象中的第一個long型字段(也就是Parent.l字段)之前的空隙中的。如果JVM設置了-XX -CompactFields則int型字段的這種插入行爲是不被允許的。

 

 •根據規則1我們知道long型字段在實例數據區的OFFSET需要對齊至8N,而int型字段的OFFSET需要對齊至4N。

 

 •根據規則2我們知道如果開啓壓縮指針-XX:+UseCompressedOops,Child對象的第一個字段的OFFSET需要對齊至4N,關閉壓縮指針時-XX:-UseCompressedOops,Child對象的第一個字段的OFFSET需要對齊至8N。

 

由於JVM參數UseCompressedOops 和CompactFields 的存在,導致Child對象在實例數據區字段的排列順序分爲四種情況,下面我們結合前邊提煉出的這三點規則來看下字段排列順序在這四種情況下的表現。

 

2.1 -XX:+UseCompressedOops  -XX -CompactFields 開啓壓縮指針,關閉字段壓縮

重磅硬核|一文聊透對象在JVM中的內存佈局等(一)-鴻蒙開發者社區image.png


 •偏移量OFFSET = 8的位置存放的是類型指針,由於開啓了壓縮指針所以佔用4個字節。對象頭總共佔用12個字節:MarkWord(8字節) + 類型指針(4字節)。
 

•根據規則3:父類Parent中的字段是要出現在子類Child的字段之前的並且long型字段在int型字段之前。
 

•根據規則2:在開啓壓縮指針的情況下,Child對象中的第一個字段需要對齊至4N。這裏Parent.l字段的OFFSET可以是12也可以是16。
 

•根據規則1:long型字段在實例數據區的OFFSET需要對齊至8N,所以這裏Parent.l字段的OFFSET只能是16,因此OFFSET = 12的位置就需要被填充。Child.l字段只能在

OFFSET = 32處存儲,不能夠使用OFFSET = 28位置,因爲28的位置不是8的倍數無法對齊8N,因此OFFSET = 28的位置被填充了4個字節。

 

規則1也規定了int型字段的OFFSET需要對齊至4N,所以Parent.i與Child.i分別存儲以OFFSET = 24和OFFSET = 40的位置。

 

因爲JVM中的內存對齊除了存在於字段與字段之間還存在於對象與對象之間,Java對象之間的內存地址需要對齊至8N。

 

所以Child對象的末尾處被填充了4個字節,對象大小由開始的44字節被填充到48字節。

 

2.2  -XX:+UseCompressedOops  -XX +CompactFields 開啓壓縮指針,開啓字段壓縮

重磅硬核|一文聊透對象在JVM中的內存佈局等(一)-鴻蒙開發者社區image.png


 •在第一種情況的分析基礎上,我們開啓了-XX +CompactFields壓縮字段,所以導致int型的Parent.i字段可以插入到OFFSET = 12的位置處,以避免不必要的字節填充。

 

 •根據規則2:Child對象的第一個字段需要對齊至4N,這裏我們看到int型的Parent.i字段是符合這個規則的。
 

•根據規則1:Child對象的所有long型字段都對齊至8N,所有的int型字段都對齊至4N。

 

最終得到Child對象大小爲36字節,由於Java對象與對象之間的內存地址需要對齊至8N,所以最後Child對象的末尾又被填充了4個字節最終變爲40字節。

 

這裏我們可以看到在開啓字段壓縮-XX +CompactFields的情況下,Child對象的大小由48字節變成了40字節。

 

2.3 -XX:-UseCompressedOops  -XX -CompactFields 關閉壓縮指針,關閉字段壓縮

重磅硬核|一文聊透對象在JVM中的內存佈局等(一)-鴻蒙開發者社區image.png


首先在關閉壓縮指針-UseCompressedOops的情況下,對象頭中的類型指針佔用字節變成了8字節。導致對象頭的大小在這種情況下變爲了16字節。

 

 •根據規則1:long型的變量OFFSET需要對齊至8N。根據規則2:在關閉壓縮指針的情況下,Child對象的第一個字段Parent.l需要對齊至8N。所以這裏的Parent.l字段的OFFSET  = 16。

 

 •由於long型的變量OFFSET需要對齊至8N,所以Child.l字段的OFFSET 需要是32,因此OFFSET = 28的位置被填充了4個字節。

 

這樣計算出來的Child對象大小爲44字節,但是考慮到Java對象與對象的內存地址需要對齊至8N,於是又在對象末尾處填充了4個字節,最終Child對象的內存佔用爲48字節。

 

2.4  -XX:-UseCompressedOops  -XX +CompactFields 關閉壓縮指針,開啓字段壓縮

 

在第三種情況的分析基礎上,我們來看下第四種情況的字段排列情況:

重磅硬核|一文聊透對象在JVM中的內存佈局等(一)-鴻蒙開發者社區

 image.png


由於在關閉指針壓縮的情況下類型指針的大小變爲了8個字節,所以導致Child對象中第一個字段Parent.l前邊並沒有空隙,剛好對齊8N,並不需要int型變量的插入。所以即使開啓了字段壓縮-XX +CompactFields,字段的總體排列順序還是不變的。

 

默認情況下指針壓縮-XX:+UseCompressedOops以及字段壓縮-XX +CompactFields都是開啓的

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章