重磅硬核 | 一文聊透物件在 JVM 中的記憶體佈局,以及記憶體對齊和壓縮指標的原理及應用

2022-07-06 12:06:46

歡迎關注公眾號:bin的技術小屋,大家如果看到圖片顯示不了的話,可以檢視公眾號原文

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

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

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

在我們的日常工作中,有時候我們為了防止線上應用發生OOM,所以我們需要在開發的過程中計算一些核心物件在記憶體中的佔用大小,目的是為了更好的瞭解我們的應用程式記憶體佔用的一個大概情況。

進而根據我們伺服器的記憶體資源限制以及預估的物件建立數量級計算出應用程式佔用記憶體的高低水位線,如果記憶體佔用量超過高水位線,那麼就有可能有發生OOM的風險。

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

那麼核心問題是如何計算一個Java物件在記憶體中的佔用大小呢??

在為大家解答這個問題之前,筆者先來介紹下Java物件在記憶體中的佈局,也就是本文的主題。

1. Java物件的記憶體佈局

如圖所示,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的需要填充位元組。

  1. 在開啟了壓縮指標的64位元JVM中,Java類中的第一個欄位的OFFSET需要對齊至4N,在關閉壓縮指標的情況下類中第一個欄位的OFFSET需要對齊至8N。

  2. 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 開啟壓縮指標,關閉欄位壓縮

  • 偏移量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 開啟壓縮指標,開啟欄位壓縮

  • 在第一種情況的分析基礎上,我們開啟了-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 關閉壓縮指標,關閉欄位壓縮

首先在關閉壓縮指標-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 關閉壓縮指標,開啟欄位壓縮

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

由於在關閉指標壓縮的情況下型別指標的大小變為了8個位元組,所以導致Child物件中第一個欄位Parent.l前邊並沒有空隙,剛好對齊8N,並不需要int型變數的插入。所以即使開啟了欄位壓縮-XX +CompactFields,欄位的總體排列順序還是不變的。

預設情況下指標壓縮-XX:+UseCompressedOops以及欄位壓縮-XX +CompactFields都是開啟的

3. 對齊填充(Padding)

在前一小節關於範例資料區欄位重排列的介紹中為了記憶體對齊而導致的位元組填充不僅會出現在欄位與欄位之間,還會出現在物件與物件之間。

前邊我們介紹了欄位重排列需要遵循的三個重要規則,其中規則1,規則2定義了欄位與欄位之間的記憶體對齊規則。 規則3定義的是物件欄位之間的排列規則。

為了記憶體對齊的需要,物件頭與欄位之間,以及欄位與欄位之間需要填充一些不必要的位元組。

比如前邊提到的欄位重排列的第一種情況-XX:+UseCompressedOops -XX -CompactFields

而以上提到的四種情況都會在物件範例資料區的後邊在填充4位元組大小的空間,原因是除了需要滿足欄位與欄位之間的記憶體對齊之外,還需要滿足物件與物件之間的記憶體對齊。

Java 虛擬機器器堆中物件之間的記憶體地址需要對齊至8N(8的倍數),如果一個物件佔用記憶體不到8N個位元組,那麼就必須在物件後填充一些不必要的位元組對齊至8N個位元組。

虛擬機器器中記憶體對齊的選項為-XX:ObjectAlignmentInBytes,預設為8。也就是說物件與物件之間的記憶體地址需要對齊至多少倍,是由這個JVM引數控制的。

我們還是以上邊第一種情況為例說明:圖中物件實際佔用是44個位元組,但是不是8的倍數,那麼就需要再填充4個位元組,記憶體對齊至48個位元組。

以上這些為了記憶體對齊的目的而在欄位與欄位之間,物件與物件之間填充的不必要位元組,我們就稱之為對齊填充(Padding)

4. 對齊填充的應用

在我們知道了對齊填充的概念之後,大家可能好奇了,為啥我們要進行對齊填充,是要解決什麼問題嗎?

那麼就讓我們帶著這個問題,來接著聽筆者往下聊~~

4.1 解決偽共用問題帶來的對齊填充

除了以上介紹的兩種對齊填充的場景(欄位與欄位之間,物件與物件之間),在JAVA中還有一種對齊填充的場景,那就是通過對齊填充的方式來解決False Sharing(偽共用)的問題。

在介紹False Sharing(偽共用)之前,筆者先來介紹下CPU讀取記憶體中資料的方式。

4.1.1 CPU快取

根據摩爾定律:晶片中的電晶體數量每隔18個月就會翻一番。導致CPU的效能和處理速度變得越來越快,而提升CPU的執行速度比提升記憶體的執行速度要容易和便宜的多,所以就導致了CPU與記憶體之間的速度差距越來越大。

為了彌補CPU與記憶體之間巨大的速度差異,提高CPU的處理效率和吞吐,於是人們引入了L1,L2,L3快取記憶體整合到CPU中。當然還有L0也就是暫存器,暫存器離CPU最近,存取速度也最快,基本沒有時延。

一個CPU裡面包含多個核心,我們在購買電腦的時候經常會看到這樣的處理器設定,比如4核8執行緒。意思是這個CPU包含4個物理核心8個邏輯核心。4個物理核心表示在同一時間可以允許4個執行緒並行執行,8個邏輯核心表示處理器利用超執行緒的技術將一個物理核心模擬出了兩個邏輯核心,一個物理核心在同一時間只會執行一個執行緒,而超執行緒晶片可以做到執行緒之間快速切換,當一個執行緒在存取記憶體的空隙,超執行緒晶片可以馬上切換去執行另外一個執行緒。因為切換速度非常快,所以在效果上看到是8個執行緒在同時執行。

圖中的CPU核心指的是物理核心。

從圖中我們可以看到L1Cache是離CPU核心最近的快取記憶體,緊接著就是L2Cache,L3Cache,記憶體。

離CPU核心越近的快取存取速度也越快,造價也就越高,當然容量也就越小。

其中L1Cache和L2Cache是CPU物理核心私有的(注意:這裡是物理核心不是邏輯核心

而L3Cache是整個CPU所有物理核心共用的。

CPU邏輯核心共用其所屬物理核心的L1Cache和L2Cache

L1Cache

L1Cache離CPU是最近的,它的存取速度最快,容量也最小。

從圖中我們看到L1Cache分為兩個部分,分別是:Data Cache和Instruction Cache。它們一個是儲存資料的,一個是儲存程式碼指令的。

我們可以通過cd /sys/devices/system/cpu/來檢視linux機器上的CPU資訊。

/sys/devices/system/cpu/目錄裡,我們可以看到CPU的核心數,當然這裡指的是邏輯核心

筆者機器上的處理器並沒有使用超執行緒技術所以這裡其實是4個物理核心。

下面我們進入其中一顆CPU核心(cpu0)中去看下L1Cache的情況:

CPU快取的情況在/sys/devices/system/cpu/cpu0/cache目錄下檢視:

index0描述的是L1Cache中DataCache的情況:

  • level:表示該cache資訊屬於哪一級,1表示L1Cache。
  • type:表示屬於L1Cache的DataCache。
  • size:表示DataCache的大小為32K。
  • shared_cpu_list:之前我們提到L1Cache和L2Cache是CPU物理核所私有的,而由物理核模擬出來的邏輯核是共用L1Cache和L2Cache的/sys/devices/system/cpu/目錄下描述的資訊是邏輯核。shared_cpu_list描述的正是哪些邏輯核共用這個物理核。

index1描述的是L1Cache中Instruction Cache的情況:

我們看到L1Cache中的Instruction Cache大小也是32K。

L2Cache

L2Cache的資訊儲存在index2目錄下:

L2Cache的大小為256K,比L1Cache要大些。

L3Cache

L3Cache的資訊儲存在index3目錄下:

到這裡我們可以看到L1Cache中的DataCache和InstructionCache大小一樣都是32K而L2Cache的大小為256K,L3Cache的大小為6M。

當然這些數值在不同的CPU設定上會是不同的,但是總體上來說L1Cache的量級是幾十KB,L2Cache的量級是幾百KB,L3Cache的量級是幾MB。

4.1.2 CPU快取行

前邊我們介紹了CPU的快取記憶體結構,引入快取記憶體的目的在於消除CPU與記憶體之間的速度差距,根據程式的區域性性原理我們知道,CPU的快取記憶體肯定是用來存放熱點資料的。

程式區域性性原理表現為:時間區域性性和空間區域性性。時間區域性性是指如果程式中的某條指令一旦執行,則不久之後該指令可能再次被執行;如果某塊資料被存取,則不久之後該資料可能再次被存取。空間區域性性是指一旦程式存取了某個儲存單元,則不久之後,其附近的儲存單元也將被存取。

那麼在快取記憶體中存取資料的基本單位又是什麼呢??

事實上熱點資料在CPU快取記憶體中的存取並不是我們想象中的以單獨的變數或者單獨的指標為單位存取的。

CPU快取記憶體中存取資料的基本單位叫做快取行cache line。快取行存取位元組的大小為2的倍數,在不同的機器上,快取行的大小範圍在32位元組到128位元組之間。目前所有主流的處理器中快取行的大小均為64位元組注意:這裡的單位是位元組)。

從圖中我們可以看到L1Cache,L2Cache,L3Cache中快取行的大小都是64位元組

這也就意味著每次CPU從記憶體中獲取資料或者寫入資料的大小為64個位元組,即使你唯讀一個bit,CPU也會從記憶體中載入64位元組資料進來。同樣的道理,CPU從快取記憶體中同步資料到記憶體也是按照64位元組的單位來進行。

比如你存取一個long型陣列,當CPU去載入陣列中第一個元素時也會同時將後邊的7個元素一起載入進快取中。這樣一來就加快了遍歷陣列的效率。

long型別在Java中佔用8個位元組,一個快取行可以存放8個long型變數。

事實上,你可以非常快速的遍歷在連續的記憶體塊中分配的任意資料結構,如果你的資料結構中的項在記憶體中不是彼此相鄰的(比如:連結串列),這樣就無法利用CPU快取的優勢。由於資料在記憶體中不是連續存放的,所以在這些資料結構中的每一個項都可能會出現快取行未命中(程式區域性性原理)的情況。

還記得我們在《Reactor在Netty中的實現(建立篇)》中介紹Selector的建立時提到,Netty利用陣列實現的自定義SelectedSelectionKeySet型別替換掉了JDK利用HashSet型別實現的sun.nio.ch.SelectorImpl#selectedKeys目的就是利用CPU快取的優勢來提高IO活躍的SelectionKeys集合的遍歷效能

4.2 False Sharing(偽共用)

我們先來看一個這樣的例子,筆者定義了一個範例類FalseSharding,類中有兩個long型的volatile欄位a,b。

public class FalseSharding {

    volatile long a;

    volatile long b;

}

欄位a,b之間邏輯上是獨立的,它們之間一點關係也沒有,分別用來儲存不同的資料,資料之間也沒有關聯。

FalseSharding類中欄位之間的記憶體佈局如下:

FalseSharding類中的欄位a,b在記憶體中是相鄰儲存,分別佔用8個位元組。

如果恰好欄位a,b被CPU讀進了同一個快取行,而此時有兩個執行緒,執行緒a用來修改欄位a,同時執行緒b用來讀取欄位b。

在這種場景下,會對執行緒b的讀取操作造成什麼影響呢

我們知道宣告了volatile關鍵字的變數可以在多執行緒處理環境下,確保記憶體的可見性。計算機硬體層會保證對被volatile關鍵字修飾的共用變數進行寫操作後的記憶體可見性,而這種記憶體可見性是由Lock字首指令以及快取一致性協定(MESI控制協定)共同保證的。

  • Lock字首指令可以使修改執行緒所在的處理器中的相應快取行資料被修改後立馬重新整理回記憶體中,並同時鎖定所有處理器核心中快取了該修改變數的快取行,防止多個處理器核心並行修改同一快取行。

  • 快取一致性協定主要是用來維護多個處理器核心之間的CPU快取一致性以及與記憶體資料的一致性。每個處理器會在匯流排上嗅探其他處理器準備寫入的記憶體地址,如果這個記憶體地址在自己的處理器中被快取的話,就會將自己處理器中對應的快取行置為無效,下次需要讀取的該快取行中的資料的時候,就需要存取記憶體獲取。

基於以上volatile關鍵字原則,我們首先來看第一種影響

  • 當執行緒a在處理器core0中對欄位a進行修改時,Lock字首指令會將所有處理器中快取了欄位a的對應快取行進行鎖定這樣就會導致執行緒b在處理器core1中無法讀取和修改自己快取行的欄位b

  • 處理器core0將修改後的欄位a所在的快取行重新整理回記憶體中。

從圖中我們可以看到此時欄位a的值在處理器core0的快取行中以及在記憶體中已經發生變化了。但是處理器core1中欄位a的值還沒有變化,並且core1中欄位a所在的快取行處於鎖定狀態,無法讀取也無法寫入欄位b。

從上述過程中我們可以看出即使欄位a,b之間邏輯上是獨立的,它們之間一點關係也沒有,但是執行緒a對欄位a的修改,導致了執行緒b無法讀取欄位b。

第二種影響

當處理器core0將欄位a所在的快取行重新整理回記憶體的時候,處理器core1會在匯流排上嗅探到欄位a的記憶體地址正在被其他處理器修改,所以將自己的快取行置為失效。當執行緒b在處理器core1中讀取欄位b的值時,發現快取行已被置為失效,core1需要重新從記憶體中讀取欄位b的值即使欄位b沒有發生任何變化。

從以上兩種影響我們看到欄位a與欄位b實際上並不存在共用,它們之間也沒有相互關聯關係,理論上執行緒a對欄位a的任何操作,都不應該影響執行緒b對欄位b的讀取或者寫入。

但事實上執行緒a對欄位a的修改導致了欄位b在core1中的快取行被鎖定(Lock字首指令),進而使得執行緒b無法讀取欄位b。

執行緒a所在處理器core0將欄位a所在快取行同步重新整理回記憶體後,導致欄位b在core1中的快取行被置為失效(快取一致性協定),進而導致執行緒b需要重新回到記憶體讀取欄位b的值無法利用CPU快取的優勢。

由於欄位a和欄位b在同一個快取行中,導致了欄位a和欄位b事實上的共用(原本是不應該被共用的)。這種現象就叫做False Sharing(偽共用)

在高並行的場景下,這種偽共用的問題,會對程式效能造成非常大的影響。

如果執行緒a對欄位a進行修改,與此同時執行緒b對欄位b也進行修改,這種情況對效能的影響更大,因為這會導致core0和core1中相應的快取行相互失效。

4.3 False Sharing的解決方案

既然導致False Sharing出現的原因是欄位a和欄位b在同一個快取行導致的,那麼我們就要想辦法讓欄位a和欄位b不在一個快取行中。

那麼我們怎麼做才能夠使得欄位a和欄位b一定不會被分配到同一個快取行中呢?

這時候,本小節的主題位元組填充就派上用場了~~

在Java8之前我們通常會在欄位a和欄位b前後分別填充7個long型變數(快取行大小64位元組),目的是讓欄位a和欄位b各自獨佔一個快取行避免False Sharing

比如我們將一開始的範例程式碼修改成這個這樣子,就可以保證欄位a和欄位b各自獨佔一個快取行了。

public class FalseSharding {

    long p1,p2,p3,p4,p5,p6,p7;
    volatile long a;
    long p8,p9,p10,p11,p12,p13,p14;
    volatile long b;
    long p15,p16,p17,p18,p19,p20,p21;

}

修改後的物件在記憶體中佈局如下:

我們看到為了解決False Sharing問題,我們將原本佔用32位元組的FalseSharding範例物件硬生生的填充到了200位元組。這對記憶體的消耗是非常可觀的。通常為了極致的效能,我們會在一些高並行框架或者JDK的原始碼中看到False Sharing的解決場景。因為在高並行場景中,任何微小的效能損失比如False Sharing,都會被無限放大。

但解決False Sharing的同時又會帶來巨大的記憶體消耗,所以即使在高並行框架比如disrupter或者JDK中也只是針對那些在多執行緒場景下被頻繁寫入的共用變數

這裡筆者想強調的是在我們日常工作中,我們不能因為自己手裡拿著錘子,就滿眼都是釘子,看到任何釘子都想上去錘兩下。

我們要清晰的分辨出一個問題會帶來哪些影響和損失,這些影響和損失在我們當前業務階段是否可以接受?是否是瓶頸?同時我們也要清晰的瞭解要解決這些問題我們所要付出的代價。一定要綜合評估,講究一個投入產出比。某些問題雖然是問題,但是在某些階段和場景下並不需要我們投入解決。而有些問題則對於我們當前業務發展階段是瓶頸,我們不得不去解決。我們在架構設計或者程式設計中,方案一定要簡單合適。並預估一些提前量留有一定的演化空間

4.3.1 @Contended註解

在Java8中引入了一個新註解@Contended,用於解決False Sharing的問題,同時這個註解也會影響到Java物件中的欄位排列。

在上一小節的內容介紹中,我們通過手段填充欄位的方式解決了False Sharing的問題,但是這裡也有一個問題,因為我們在手動填充欄位的時候還需要考慮CPU快取行的大小,因為雖然現在所有主流的處理器快取行大小均為64位元組,但是也還是有處理器的快取行大小為32位元組,有的甚至是128位元組。我們需要考慮很多硬體的限制因素。

Java8中通過引入@Contended註解幫我們解決了這個問題,我們不在需要去手動填充欄位了。下面我們就來看下@Contended註解是如何幫助我們來解決這個問題的~~

上小節介紹的手動填充位元組是在共用變數前後填充64位元組大小的空間,這樣只能確保程式在快取行大小為32位元組或者64位元組的CPU下獨佔快取行。但是如果CPU的快取行大小為128位元組,這樣依然存在False Sharing的問題。

引入@Contended註解可以使我們忽略底層硬體裝置的差異性,做到Java語言的初衷:平臺無關性。

@Contended註解預設只是在JDK內部起作用,如果我們的程式程式碼中需要使用到@Contended註解,那麼需要開啟JVM引數-XX:-RestrictContended才會生效。

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface Contended {
    //contention group tag
    String value() default "";
}

@Contended註解可以標註在類上也可以標註在類中的欄位上,被@Contended標註的物件會獨佔快取行,不會和任何變數或者物件共用快取行。

  • @Contended標註在類上表示該類物件中的範例資料整體需要獨佔快取行。不能與其他範例資料共用快取行。

  • @Contended標註在類中的欄位上表示該欄位需要獨佔快取行。

  • 除此之外@Contended還提供了分組的概念,註解中的value屬性表示contention group 。屬於統一分組下的變數,它們在記憶體中是連續存放的,可以允許共用快取行。不同分組之間不允許共用快取行。

下面我們來分別看下@Contended註解在這三種使用場景下是怎樣影響欄位之間的排列的。

@Contended標註在類上
@Contended
public class FalseSharding {
    volatile long a;
    volatile long b;

    volatile int c;
    volatile int d;
}

當@Contended標註在FalseSharding範例類上時,表示FalseSharding範例物件中的整個範例資料區需要獨佔快取行,不能與其他物件或者變數共用快取行。

這種情況下的記憶體佈局:

如圖中所示,FalseSharding範例類被標註了@Contended之後,JVM會在FalseSharding範例物件的範例資料區前後填充128個位元組,保證範例資料區內的欄位之間記憶體是連續的,並且保證整個範例資料區獨佔快取行,不會與範例資料區之外的資料共用快取行。

細心的朋友可能已經發現了問題,我們之前不是提到快取行的大小為64位元組嗎?為什麼這裡會填充128位元組呢

而且之前介紹的手動填充也是填充的64位元組,為什麼@Contended註解會採用兩倍的快取行大小來填充呢?

其實這裡的原因有兩個:

  1. 首先第一個原因,我們之前也已經提到過了,目前大部分主流的CPU快取行是64位元組,但是也有部分CPU快取行是32位元組或者128位元組,如果只填充64位元組的話,在快取行大小為32位元組和64位元組的CPU中是可以做到獨佔快取行從而避免FalseSharding的,但在快取行大小為128位元組的CPU中還是會出現FalseSharding問題,這裡Java採用了悲觀的一種做法,預設都是填充128位元組,雖然對於大部分情況下比較浪費,但是遮蔽了底層硬體的差異。

不過@Contended註解填充位元組的大小我們可以通過JVM引數
-XX:ContendedPaddingWidth指定,有效值範圍0 - 8192,預設為128

  • 第二個原因其實是最為核心的一個原因,主要是為了防止CPU Adjacent Sector Prefetch(CPU相鄰磁區預取)特性所帶來的FalseSharding問題。

CPU Adjacent Sector Prefetch:https://www.techarp.com/bios-guide/cpu-adjacent-sector-prefetch/

CPU Adjacent Sector Prefetch是Intel處理器特有的BIOS功能特性,預設是enabled。主要作用就是利用程式區域性性原理,當CPU從記憶體中請求資料,並讀取當前請求資料所在快取行時,會進一步預取與當前快取行相鄰的下一個快取行,這樣當我們的程式在順序處理資料時,會提高CPU處理效率。這一點也體現了程式區域性性原理中的空間區域性性特徵

當CPU Adjacent Sector Prefetch特性被disabled禁用時,CPU就只會獲取當前請求資料所在的快取行,不會預取下一個快取行。

所以在當CPU Adjacent Sector Prefetch啟用(enabled)的時候,CPU其實同時處理的是兩個快取行,在這種情況下,就需要填充兩倍快取行大小(128位元組)來避免CPU Adjacent Sector Prefetch所帶來的的FalseSharding問題。

@Contended標註在欄位上
public class FalseSharding {

    @Contended
    volatile long a;
    @Contended
    volatile long b;

    volatile int c;
    volatile long d;
}

這次我們將 @Contended註解標註在了FalseSharding範例類中的欄位a和欄位b上,這樣帶來的效果是欄位a和欄位b各自獨佔快取行。從記憶體佈局上看,欄位a和欄位b前後分別被填充了128個位元組,來確保欄位a和欄位b不與任何資料共用快取行。

而沒有被@Contended註解標註欄位c和欄位d則在記憶體中連續儲存,可以共用快取行。

@Contended分組
public class FalseSharding {

    @Contended("group1")
    volatile int a;
    @Contended("group1")
    volatile long b;

    @Contended("group2")
    volatile long  c;
    @Contended("group2")
    volatile long d;
}

這次我們將欄位a與欄位b放在同一content group下,欄位c與欄位d放在另一個content group下。

這樣處在同一分組group1下的欄位a與欄位b在記憶體中是連續儲存的,可以共用快取行。

同理處在同一分組group2下的欄位c與欄位d在記憶體中也是連續儲存的,也允許共用快取行。

但是分組之間是不能共用快取行的,所以在欄位分組的前後各填充128位元組,來保證分組之間的變數不能共用快取行。

5. 記憶體對齊

通過以上內容我們瞭解到Java物件中的範例資料區欄位需要進行記憶體對齊而導致在JVM中會被重排列以及通過填充快取行避免false sharding的目的所帶來的位元組對齊填充。

我們也瞭解到記憶體對齊不僅發生在物件與物件之間,也發生在物件中的欄位之間。

那麼在本小節中筆者將為大家介紹什麼是記憶體對齊,在本節的內容開始之前筆者先來丟擲兩個問題:

  • 為什麼要進行記憶體對齊?如果就是頭比較鐵,就是不記憶體對齊,會產生什麼樣的後果?

  • Java 虛擬機器器堆中物件的起始地址為什麼需要對齊至 8的倍數?為什麼不對齊至4的倍數或16的倍數或32的倍數呢?

帶著這兩個問題,下面我們正式開始本節的內容~~~

5.1 記憶體結構

我們平時所稱的記憶體也叫隨機存取記憶體(random-access memory)也叫RAM。而RAM分為兩類:

  • 一類是靜態RAM(SRAM),這類SRAM用於前邊介紹的CPU快取記憶體L1Cache,L2Cache,L3Cache。其特點是存取速度快,存取速度為1 - 30個時鐘週期,但是容量小,造價高。

  • 另一類則是動態RAM(DRAM),這類DRAM用於我們常說的主記憶體上,其特點的是存取速度慢(相對快取記憶體),存取速度為50 - 200個時鐘週期,但是容量大,造價便宜些(相對快取記憶體)。

記憶體由一個一個的記憶體模組(memory module)組成,它們插在主機板的擴充套件槽上。常見的記憶體模組通常以64位元為單位(8個位元組)傳輸資料到儲存控制器上或者從儲存控制器傳出資料。

如圖所示記憶體條上黑色的元器件就是記憶體模組(memory module)。多個記憶體模組連線到儲存控制器上,就聚合成了主記憶體。

而前邊介紹到的DRAM晶片就包裝在記憶體模組中,每個記憶體模組中包含8個DRAM晶片,依次編號為0 - 7

而每一個DRAM晶片的儲存結構是一個二維矩陣,二維矩陣中儲存的元素我們稱為超單元(supercell),每個supercell大小為一個位元組(8 bit)。每個supercell都由一個座標地址(i,j)。

i表示二維矩陣中的行地址,在計算機中行地址稱為RAS(row access strobe,行存取選通脈衝)。
j表示二維矩陣中的列地址,在計算機中列地址稱為CAS(column access strobe,列存取選通脈衝)。

下圖中的supercell的RAS = 2,CAS = 2。

DRAM晶片中的資訊通過引腳流入流出DRAM晶片。每個引腳攜帶1 bit的訊號。

圖中DRAM晶片包含了兩個地址引腳(addr),因為我們要通過RAS,CAS來定位要獲取的supercell。還有8個資料引腳(data),因為DRAM晶片的IO單位為一個位元組(8 bit),所以需要8個data引腳從DRAM晶片傳入傳出資料。

注意這裡只是為了解釋地址引腳和資料引腳的概念,實際硬體中的引腳數量是不一定的。

5.2 DRAM晶片的存取

我們現在就以讀取上圖中座標地址為(2,2)的supercell為例,來說明存取DRAM晶片的過程。

  1. 首先儲存控制器將行地址RAS = 2通過地址引腳傳送給DRAM晶片

  2. DRAM晶片根據RAS = 2將二維矩陣中的第二行的全部內容拷貝到內部行緩衝區中。

  3. 接下來儲存控制器會通過地址引腳傳送CAS = 2到DRAM晶片中。

  4. DRAM晶片從內部行緩衝區中根據CAS = 2拷貝出第二列的supercell並通過資料引腳傳送給儲存控制器。

DRAM晶片的IO單位為一個supercell,也就是一個位元組(8 bit)。

5.3 CPU如何讀寫主記憶體

前邊我們介紹了記憶體的物理結構,以及如何存取記憶體中的DRAM晶片獲取supercell中儲存的資料(一個位元組)。

本小節我們來介紹下CPU是如何存取記憶體的。

其中關於CPU晶片的內部結構我們在介紹false sharding的時候已經詳細的介紹過了,這裡我們主要聚焦在CPU與記憶體之間的匯流排架構上。

5.3.1 匯流排結構

CPU與記憶體之間的資料互動是通過匯流排(bus)完成的,而資料在匯流排上的傳送是通過一系列的步驟完成的,這些步驟稱為匯流排事務(bus transaction)。

其中資料從記憶體傳送到CPU稱之為讀事務(read transaction),資料從CPU傳送到記憶體稱之為寫事務(write transaction)

匯流排上傳輸的訊號包括:地址訊號,資料訊號,控制訊號。其中控制匯流排上傳輸的控制訊號可以同步事務,並能夠標識出當前正在被執行的事務資訊:

  • 當前這個事務是到記憶體的?還是到磁碟的?或者是到其他IO裝置的?
  • 這個事務是讀還是寫?
  • 匯流排上傳輸的地址訊號(記憶體地址),還是資料訊號(資料)?。

還記得我們前邊講到的MESI快取一致性協定嗎?當core0修改欄位a的值時,其他CPU核心會在匯流排上嗅探欄位a的記憶體地址,如果嗅探到匯流排上出現欄位a的記憶體地址,說明有人在修改欄位a,這樣其他CPU核心就會失效自己快取欄位a所在的cache line

如上圖所示,其中系統匯流排是連線CPU與IO bridge的,儲存匯流排是來連線IO bridge和主記憶體的。

IO bridge負責將系統匯流排上的電子訊號轉換成儲存匯流排上的電子訊號。IO bridge也會將系統匯流排和儲存匯流排連線到IO匯流排(磁碟等IO裝置)上。這裡我們看到IO bridge其實起的作用就是轉換不同匯流排上的電子訊號。

5.3.2 CPU從記憶體讀取資料過程

假設CPU現在要將記憶體地址為A的內容載入到暫存器中進行運算。

首先CPU晶片中的匯流排介面會在匯流排上發起讀事務(read transaction)。 該讀事務分為以下步驟進行:

  1. CPU將記憶體地址A放到系統匯流排上。隨後IO bridge將訊號傳遞到儲存匯流排上。

  2. 主記憶體感受到儲存匯流排上的地址訊號並通過儲存控制器將儲存匯流排上的記憶體地址A讀取出來。

  3. 儲存控制器通過記憶體地址A定位到具體的記憶體模組,從DRAM晶片中取出記憶體地址A對應的資料X

  4. 儲存控制器將讀取到的資料X放到儲存匯流排上,隨後IO bridge將儲存匯流排上的資料訊號轉換為系統匯流排上的資料訊號,然後繼續沿著系統匯流排傳遞。

  5. CPU晶片感受到系統匯流排上的資料訊號,將資料從系統匯流排上讀取出來並拷貝到暫存器中。

以上就是CPU讀取記憶體資料到暫存器中的完整過程。

但是其中還涉及到一個重要的過程,這裡我們還是需要攤開來介紹一下,那就是儲存控制器如何通過記憶體地址A從主記憶體中讀取出對應的資料X的?

接下來我們結合前邊介紹的記憶體結構以及從DRAM晶片讀取資料的過程,來總體介紹下如何從主記憶體中讀取資料。

5.3.3 如何根據記憶體地址從主記憶體中讀取資料

前邊介紹到,當主記憶體中的儲存控制器感受到了儲存匯流排上的地址訊號時,會將記憶體地址從儲存匯流排上讀取出來。

隨後會通過記憶體地址定位到具體的記憶體模組。還記得記憶體結構中的記憶體模組嗎??

而每個記憶體模組中包含了8個DRAM晶片,編號從0 - 7

儲存控制器會將記憶體地址轉換為DRAM晶片中supercell在二維矩陣中的座標地址(RASCAS)。並將這個座標地址傳送給對應的記憶體模組。隨後記憶體模組會將RASCAS廣播到記憶體模組中的所有DRAM晶片。依次通過(RASCAS)從DRAM0到DRAM7讀取到相應的supercell。

我們知道一個supercell儲存了8 bit資料,這裡我們從DRAM0到DRAM7
依次讀取到了8個supercell也就是8個位元組,然後將這8個位元組返回給儲存控制器,由儲存控制器將資料放到儲存匯流排上。

CPU總是以word size為單位從記憶體中讀取資料,在64位元處理器中的word size為8個位元組。64位元的記憶體也只能每次吞吐8個位元組。

CPU每次會向記憶體讀寫一個cache line大小的資料(64個位元組),但是記憶體一次只能吞吐8個位元組

所以在記憶體地址對應的記憶體模組中,DRAM0晶片儲存第一個低位位元組(supercell),DRAM1晶片儲存第二個位元組,......依次類推DRAM7晶片儲存最後一個高位位元組。

記憶體一次讀取和寫入的單位是8個位元組。而且在程式設計師眼裡連續的記憶體地址實際上在物理上是不連續的。因為這連續的8個位元組其實是儲存於不同的DRAM晶片上的。每個DRAM晶片儲存一個位元組(supercell)。

5.3.4 CPU向記憶體寫入資料過程

我們現在假設CPU要將暫存器中的資料X寫到記憶體地址A中。同樣的道理,CPU晶片中的匯流排介面會向匯流排發起寫事務(write transaction)。寫事務步驟如下:

  1. CPU將要寫入的記憶體地址A放入系統匯流排上。

  2. 通過IO bridge的訊號轉換,將記憶體地址A傳遞到儲存匯流排上。

  3. 儲存控制器感受到儲存匯流排上的地址訊號,將記憶體地址A從儲存匯流排上讀取出來,並等待資料的到達。

  4. CPU將暫存器中的資料拷貝到系統匯流排上,通過IO bridge的訊號轉換,將資料傳遞到儲存匯流排上。

  5. 儲存控制器感受到儲存匯流排上的資料訊號,將資料從儲存匯流排上讀取出來。

  6. 儲存控制器通過記憶體地址A定位到具體的記憶體模組,最後將資料寫入記憶體模組中的8個DRAM晶片中。

6. 為什麼要記憶體對齊

我們在瞭解了記憶體結構以及CPU讀寫記憶體的過程之後,現在我們回過頭來討論下本小節開頭的問題:為什麼要記憶體對齊?

下面筆者從三個方面來介紹下要進行記憶體對齊的原因:

速度

CPU讀取資料的單位是根據word size來的,在64位元處理器中word size = 8位元組,所以CPU向記憶體讀寫資料的單位為8位元組

在64位元記憶體中,記憶體IO單位為8個位元組,我們前邊也提到記憶體結構中的記憶體模組通常以64位元為單位(8個位元組)傳輸資料到儲存控制器上或者從儲存控制器傳出資料。因為每次記憶體IO讀取資料都是從資料所在具體的記憶體模組中包含的這8個DRAM晶片中以相同的(RAM,CAS)依次讀取一個位元組,然後在儲存控制器中聚合成8個位元組返回給CPU。

由於記憶體模組中這種由8個DRAM晶片組成的物理儲存結構的限制,記憶體讀取資料只能是按照地址順序8個位元組的依次讀取----8個位元組8個位元組地來讀取資料。

  • 假設我們現在讀取0x0000 - 0x0007這段連續記憶體地址上的8個位元組。由於記憶體讀取是按照8個位元組為單位依次順序讀取的,而我們要讀取的這段記憶體地址的起始地址是0(8的倍數),所以0x0000 - 0x0007中每個地址的座標都是相同的(RAS,CAS)。所以他可以在8個DRAM晶片中通過相同的(RAS,CAS)一次性讀取出來。

  • 如果我們現在讀取0x0008 - 0x0015這段連續記憶體上的8個位元組也是一樣的,因為記憶體段起始地址為8(8的倍數),所以這段記憶體上的每個記憶體地址在DREAM晶片中的座標地址(RAS,CAS)也是相同的,我們也可以一次性讀取出來。

注意:0x0000 - 0x0007記憶體段中的座標地址(RAS,CAS)與0x0008 - 0x0015記憶體段中的座標地址(RAS,CAS)是不相同的。

  • 但如果我們現在讀取0x0007 - 0x0014這段連續記憶體上的8個位元組情況就不一樣了,由於起始地址0x0007在DRAM晶片中的(RAS,CAS)與後邊地址0x0008 - 0x0014的(RAS,CAS)不相同,所以CPU只能先從0x0000 - 0x0007讀取8個位元組出來先放入結果暫存器中並左移7個位元組(目的是隻獲取0x0007),然後CPU在從0x0008 - 0x0015讀取8個位元組出來放入臨時暫存器中並右移1個位元組(目的是獲取0x0008 - 0x0014)最後與結果暫存器或運算。最終得到0x0007 - 0x0014地址段上的8個位元組。

從以上分析過程來看,當CPU存取記憶體對齊的地址時,比如0x00000x0008這兩個起始地址都是對齊至8的倍數。CPU可以通過一次read transaction讀取出來。

但是當CPU存取記憶體沒有對齊的地址時,比如0x0007這個起始地址就沒有對齊至8的倍數。CPU就需要兩次read transaction才能將資料讀取出來。

還記得筆者在小節開頭提出的問題嗎 ?
"Java 虛擬機器器堆中物件的起始地址為什麼需要對齊至 8的倍數?為什麼不對齊至4的倍數或16的倍數或32的倍數呢?"
現在你能回答了嗎???

原子性

CPU可以原子地操作一個對齊的word size memory。64位元處理器中word size = 8位元組

儘量分配在一個快取行中

前邊在介紹false sharding的時候我們提到目前主流處理器中的cache line大小為64位元組,堆中物件的起始地址通過記憶體對齊至8的倍數,可以讓物件儘可能的分配到一個快取行中。一個記憶體起始地址未對齊的物件可能會跨快取行儲存,這樣會導致CPU的執行效率慢2倍

其中物件中欄位記憶體對齊的其中一個重要原因也是讓欄位只出現在同一 CPU 的快取行中。如果欄位不是對齊的,那麼就有可能出現跨快取行的欄位。也就是說,該欄位的讀取可能需要替換兩個快取行,而該欄位的儲存也會同時汙染兩個快取行。這兩種情況對程式的執行效率而言都是不利的。

另外在《2. 欄位重排列》這一小節介紹的三種欄位對齊規則,是保證在欄位記憶體對齊的基礎上使得範例資料區佔用記憶體儘可能的小

7. 壓縮指標

在介紹完關於記憶體對齊的相關內容之後,我們來介紹下前邊經常提到的壓縮指標。可以通過JVM引數XX:+UseCompressedOops開啟,當然預設是開啟的。

在本小節內容開啟之前,我們先來討論一個問題,那就是為什麼要使用壓縮指標??

假設我們現在正在準備將32位元系統切換到64位元系統,起初我們可能會期望系統效能會立馬得到提升,但現實情況可能並不是這樣的。

在JVM中導致效能下降的最主要原因就是64位元系統中的物件參照。在前邊我們也提到過,64位元系統中物件的參照以及型別指標占用64 bit也就是8個位元組。

這就導致了在64位元系統中的物件參照佔用的記憶體空間是32位元系統中的兩倍大小,因此間接的導致了在64位元系統中更多的記憶體消耗以及更頻繁的GC發生,GC佔用的CPU時間越多,那麼我們的應用程式佔用CPU的時間就越少。

另外一個就是物件的參照變大了,那麼CPU可快取的物件相對就少了,增加了對記憶體的存取。綜合以上幾點從而導致了系統效能的下降。

從另一方面來說,在64位元系統中記憶體的定址空間為2^48 = 256T,在現實情況中我們真的需要這麼大的定址空間嗎??好像也沒必要吧~~

於是我們就有了新的想法:那麼我們是否應該切換回32位元系統呢?

如果我們切換回32位元系統,我們怎麼解決在32位元系統中擁有超過4G的記憶體定址空間呢?因為現在4G的記憶體大小對於現在的應用來說明顯是不夠的。

我想以上的這些問題,也是當初JVM的開發者需要面對和解決的,當然他們也交出了非常完美的答卷,那就是使用壓縮指標可以在64位元系統中利用32位元的物件參照獲得超過4G的記憶體定址空間

7.1 壓縮指標是如何做到的呢?

還記得之前我們在介紹對齊填充和記憶體對齊小節中提到的,在Java虛擬機器器堆中物件的起始地址必須對齊至8的倍數嗎?

由於堆中物件的起始地址均是對齊至8的倍數,所以物件參照在開啟壓縮指標情況下的32位元二進位制的後三位始終是0(因為它們始終可以被8整除)。

既然JVM已經知道了這些物件的記憶體地址後三位始終是0,那麼這些無意義的0就沒必要在堆中繼續儲存。相反,我們可以利用儲存0的這3位bit儲存一些有意義的資訊,這樣我們就多出3位bit的定址空間。

這樣在儲存的時候,JVM還是按照32位元來儲存,只不過後三位原本用來儲存0的bit現在被我們用來存放有意義的地址空間資訊。

當定址的時候,JVM將這32位元的物件參照左移3位(後三位補0)。這就導致了在開啟壓縮指標的情況下,我們原本32位元的記憶體定址空間一下變成了35位。可定址的記憶體空間變為2^32 * 2^3 = 32G。

這樣一來,JVM雖然額外的執行了一些位運算但是極大的提高了定址空間,並且將物件參照佔用記憶體大小降低了一半,節省了大量空間。況且這些位運算對於CPU來說是非常容易且輕量的操作

通過壓縮指標的原理我挖掘到了記憶體對齊的另一個重要原因就是通過記憶體對齊至8的倍數,我們可以在64位元系統中使用壓縮指標通過32位元的物件參照將定址空間提升至32G.

從Java7開始,當maximum heap size小於32G的時候,壓縮指標是預設開啟的。但是當maximum heap size大於32G的時候,壓縮指標就會關閉。

那麼我們如何在壓縮指標開啟的情況下進一步擴大定址空間呢???

7.2 如何進一步擴大定址空間

前邊提到我們在Java虛擬機器器堆中物件起始地址均需要對其至8的倍數,不過這個數值我們可以通過JVM引數-XX:ObjectAlignmentInBytes 來改變(預設值為8)。當然這個數值的必須是2的次冪,數值範圍需要在8 - 256之間

正是因為物件地址對齊至8的倍數,才會多出3位bit讓我們儲存額外的地址資訊,進而將4G的定址空間提升至32G。

同樣的道理,如果我們將ObjectAlignmentInBytes的數值設定為16呢?

物件地址均對齊至16的倍數,那麼就會多出4位元bit讓我們儲存額外的地址資訊。定址空間變為2^32 * 2^4 = 64G。

通過以上規律,我們就能知道,在64位元系統中開啟壓縮指標的情況,定址範圍的計算公式:4G * ObjectAlignmentInBytes = 定址範圍

但是筆者並不建議大家貿然這樣做,因為增大了ObjectAlignmentInBytes雖然能擴大定址範圍,但是這同時也可能增加了物件之間的位元組填充,導致壓縮指標沒有達到原本節省空間的效果。

8. 陣列物件的記憶體佈局

前邊大量的篇幅我們都是在討論Java普通物件在記憶體中的佈局情況,最後這一小節我們再來說下Java中的陣列物件在記憶體中是如何佈局的。

8.1 基本型別陣列的記憶體佈局

上圖表示的是基本型別陣列在記憶體中的佈局,基本型別陣列在JVM中用typeArrayOop結構體表示,基本型別陣列型別元資訊用TypeArrayKlass 結構體表示。

陣列的記憶體佈局大體上和普通物件的記憶體佈局差不多,唯一不同的是在陣列型別物件頭中多出了4個位元組用來表示陣列長度的部分。

我們還是分別以開啟指標壓縮和關閉指標壓縮兩種情況,通過下面的例子來進行說明:

long[] longArrayLayout = new long[1];

開啟指標壓縮 -XX:+UseCompressedOops

我們看到紅框部分即為陣列型別物件頭中多出來一個4位元組大小用來表示陣列長度的部分。

因為我們範例中的long型陣列只有一個元素,所以範例資料區的大小隻有8位元組。如果我們範例中的long型陣列變為兩個元素,那麼範例資料區的大小就會變為16位元組,以此類推................。

關閉指標壓縮 -XX:-UseCompressedOops

當關閉了指標壓縮時,物件頭中的MarkWord還是佔用8個位元組,但是型別指標從4個位元組變為了8個位元組。陣列長度屬性還是不變保持4個位元組。

這裡我們發現是範例資料區與物件頭之間發生了對齊填充。大家還記得這是為什麼嗎??

我們前邊在欄位重排列小節介紹了三種欄位排列規則在這裡繼續適用:

  • 規則1:如果一個欄位佔用X個位元組,那麼這個欄位的偏移量OFFSET需要對齊至NX

  • 規則2:在開啟了壓縮指標的64位元JVM中,Java類中的第一個欄位的OFFSET需要對齊至4N,在關閉壓縮指標的情況下類中第一個欄位的OFFSET需要對齊至8N

這裡基本陣列型別的範例資料區中是long型,在關閉指標壓縮的情況下,根據規則1和規則2需要對齊至8的倍數,所以要在其與物件頭之間填充4個位元組,達到記憶體對齊的目的,起始地址變為24

8.2 參照型別陣列的記憶體佈局

上圖表示的是參照型別陣列在記憶體中的佈局,參照型別陣列在JVM中用objArrayOop結構體表示,基本型別陣列型別元資訊用ObjArrayKlass 結構體表示。

同樣在參照型別陣列的物件頭中也會有一個4位元組大小用來表示陣列長度的部分。

我們還是分別以開啟指標壓縮和關閉指標壓縮兩種情況,通過下面的例子來進行說明:

public class ReferenceArrayLayout {
    char a;
    int b;
    short c;
}

ReferenceArrayLayout[] referenceArrayLayout = new ReferenceArrayLayout[1];

開啟指標壓縮 -XX:+UseCompressedOops

參照陣列型別記憶體佈局與基礎陣列型別記憶體佈局最大的不同在於它們的範例資料區。由於開啟了壓縮指標,所以物件參照佔用記憶體大小為4個位元組,而我們範例中參照陣列只包含一個參照元素,所以這裡範例資料區中只有4個位元組。相同的到道理,如果範例中的參照陣列包含的元素變為兩個參照元素,那麼範例資料區就會變為8個位元組,以此類推......。

最後由於Java物件需要記憶體對齊至8的倍數,所以在該參照陣列的範例資料區後填充了4個位元組。

關閉指標壓縮 -XX:-UseCompressedOops

當關閉壓縮指標時,物件參照佔用記憶體大小變為了8個位元組,所以參照陣列型別的範例資料區佔用了8個位元組。

根據欄位重排列規則2,在參照陣列型別物件頭與範例資料區中間需要填充4個位元組以保證記憶體對齊的目的。


總結

本文筆者詳細介紹了Java普通物件以及陣列型別物件的記憶體佈局,以及相關物件佔用記憶體大小的計算方法。

以及在物件記憶體佈局中的範例資料區欄位重排列的三個重要規則。以及後邊由位元組的對齊填充引出來的false sharding問題,還有Java8為了解決false sharding而引入的@Contented註解的原理及使用方式。

為了講清楚記憶體對齊的底層原理,筆者還花了大量的篇幅講解了記憶體的物理結構以及CPU讀寫記憶體的完整過程。

最後又由記憶體對齊引出了壓縮指標的工作原理。由此我們知道進行記憶體對齊的四個原因:

  • CPU存取效能:當CPU存取記憶體對齊的地址時,可以通過一個read transaction讀取一個字長(word size)大小的資料出來。否則就需要兩個read transaction。

  • 原子性: CPU可以原子地操作一個對齊的word size memory。

  • 儘可能利用CPU快取:記憶體對齊可以使物件或者欄位儘可能的被分配到一個快取行中,避免跨快取行儲存,導致CPU執行效率減半。

  • 提升壓縮指標的記憶體定址空間: 物件與物件之間的記憶體對齊,可以使我們在64位元系統中利用32位元物件參照將記憶體定址空間提升至32G。既降低了物件參照的記憶體佔用,又提升了記憶體定址空間。

在本文中我們順帶還介紹了和記憶體佈局相關的幾個JVM引數:

  • -XX:+UseCompressedOops
  • -XX +CompactFields
  • -XX:-RestrictContended
  • -XX:ContendedPaddingWidth
  • -XX:ObjectAlignmentInBytes

最後感謝大家能看到這裡,我們下篇文章再見~~~

閱讀原文

歡迎關注公眾號:bin的技術小屋