[轉帖]重磅硬核|一文聊透物件在JVM中的記憶體佈局等(一)

济南小老虎發表於2024-03-27
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都是開啟的

相關文章