深入理解 Java 內存架構

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

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

本文概要. png

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

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

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

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

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

  1. Java 對象的內存佈局

Java 對象的內存佈局. png

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

1.1 對象頭(Header)

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

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

1.2 實例數據(Instance Data)

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

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

爲什麼 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 對象中定義的這些實例字段在實例數據區是如何排列布局的:

  1. 字段重排列

其實我們在編寫 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;
}

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

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

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

規則 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 開啓壓縮指針,開啓字段壓縮

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

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

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

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

這樣計算出來的 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都是開啓的

  1. 對齊填充(Padding)

在前一小節關於實例數據區字段重排列的介紹中爲了內存對齊而導致的字節填充不僅會出現在字段與字段之間,還會出現在對象與對象之間。

前邊我們介紹了字段重排列需要遵循的三個重要規則,其中規則 1,規則 2 定義了字段與字段之間的內存對齊規則。規則 3 定義的是對象字段之間的排列規則。

爲了內存對齊的需要,對象頭與字段之間,以及字段與字段之間需要填充一些不必要的字節。

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

而以上提到的四種情況都會在對象實例數據區的後邊在填充 4 字節大小的空間,原因是除了需要滿足字段與字段之間的內存對齊之外,還需要滿足對象與對象之間的內存對齊。

Java 虛擬機堆中對象之間的內存地址需要對齊至 8N(8 的倍數),如果一個對象佔用內存不到 8N 個字節,那麼就必須在對象後填充一些不必要的字節對齊至 8N 個字節。

虛擬機中內存對齊的選項爲-XX:ObjectAlignmentInBytes,默認爲 8。也就是說對象與對象之間的內存地址需要對齊至多少倍,是由這個 JVM 參數控制的。

我們還是以上邊第一種情況爲例說明:圖中對象實際佔用是 44 個字節,但是不是 8 的倍數,那麼就需要再填充 4 個字節,內存對齊至 48 個字節。

以上這些爲了內存對齊的目的而在字段與字段之間,對象與對象之間填充的不必要字節,我們就稱之爲對齊填充(Padding)

  1. 對齊填充的應用

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

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

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 緩存結構. png

一個 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 的情況:

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。

falsesharding1.png

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

我們知道聲明瞭volatile關鍵字的變量可以在多線程處理環境下,確保內存的可見性。計算機硬件層會保證對被 volatile 關鍵字修飾的共享變量進行寫操作後的內存可見性,而這種內存可見性是由Lock前綴指令以及緩存一致性協議(MESI控制協議)共同保證的。

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

falsesharding2.png

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

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

第二種影響

faslesharding3.png

當處理器 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
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: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字節,來保證分組之間的變量不能共享緩存行。

  1. 內存對齊

通過以上內容我們瞭解到 Java 對象中的實例數據區字段需要進行內存對齊而導致在 JVM 中會被重排列以及通過填充緩存行避免 false sharding 的目的所帶來的字節對齊填充。

我們也瞭解到內存對齊不僅發生在對象與對象之間,也發生在對象中的字段之間。

那麼在本小節中筆者將爲大家介紹什麼是內存對齊,在本節的內容開始之前筆者先來拋出兩個問題:

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

5.1 內存結構

我們平時所稱的內存也叫隨機訪問存儲器(random-access memory)也叫 RAM。而 RAM 分爲兩類:

內存由一個一個的存儲器模塊(memory module)組成,它們插在主板的擴展槽上。常見的存儲器模塊通常以 **64 位爲單位(8 個字節)**傳輸數據到存儲控制器上或者從存儲控制器傳出數據。

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

內存結構. png

而前邊介紹到的DRAM芯片就包裝在存儲器模塊中,每個存儲器模塊中包含8個DRAM芯片,依次編號爲0 - 7

存儲器模塊. png

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

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

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

DRAM 結構. png

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 芯片的過程。

DRAM 芯片訪問. png

  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 與內存之間的總線結構. png

其中關於 CPU 芯片的內部結構我們在介紹 false sharding 的時候已經詳細的介紹過了,這裏我們主要聚焦在 CPU 與內存之間的總線架構上。

5.3.1 總線結構

CPU 與內存之間的數據交互是通過總線(bus)完成的,而數據在總線上的傳送是通過一系列的步驟完成的,這些步驟稱爲總線事務(bus transaction)。

其中數據從內存傳送到 CPU 稱之爲讀事務(read transaction),數據從 CPU 傳送到內存稱之爲寫事務(write transaction)

總線上傳輸的信號包括:地址信號,數據信號,控制信號。其中控制總線上傳輸的控制信號可以同步事務,並能夠標識出當前正在被執行的事務信息:

還記得我們前邊講到的 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 讀取內存. png

首先 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 如何根據內存地址從主存中讀取數據

前邊介紹到,當主存中的存儲控制器感受到了存儲總線上的地址信號時,會將內存地址從存儲總線上讀取出來。

隨後會通過內存地址定位到具體的存儲器模塊。還記得內存結構中的存儲器模塊嗎??

內存結構. png

而每個存儲器模塊中包含了 8 個 DRAM 芯片,編號從0 - 7

存儲器模塊. png

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

DRAM 芯片訪問. png

我們知道一個 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)。

讀取存儲器模塊數據. png

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 芯片中。

  7. 爲什麼要內存對齊


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

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

速度

CPU 讀取數據的單位是根據word size來的,在 64 位處理器中word size = 8字節,所以 CPU 向內存讀寫數據的單位爲8字節

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

讀取存儲器模塊數據. png

由於存儲器模塊中這種由 8 個 DRAM 芯片組成的物理存儲結構的限制,內存讀取數據只能是按照地址順序 8 個字節的依次讀取 ----8 個字節 8 個字節地來讀取數據。

內存 IO 單位. png

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

從以上分析過程來看,當 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. 字段重排列》這一小節介紹的三種字段對齊規則,是保證在字段內存對齊的基礎上使得實例數據區佔用內存儘可能的小

  1. 壓縮指針

在介紹完關於內存對齊的相關內容之後,我們來介紹下前邊經常提到的壓縮指針。可以通過 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。

壓縮指針. png

這樣一來,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雖然能擴大尋址範圍,但是這同時也可能增加了對象之間的字節填充,導致壓縮指針沒有達到原本節省空間的效果。

  1. 數組對象的內存佈局

前邊大量的篇幅我們都是在討論 Java 普通對象在內存中的佈局情況,最後這一小節我們再來說下 Java 中的數組對象在內存中是如何佈局的。

8.1 基本類型數組的內存佈局

基本類型數組內存佈局. png

上圖表示的是基本類型數組在內存中的佈局,基本類型數組在 JVM 中用typeArrayOop結構體表示,基本類型數組類型元信息用TypeArrayKlass 結構體表示。

數組的內存佈局大體上和普通對象的內存佈局差不多,唯一不同的是在數組類型對象頭中多出了4個字節用來表示數組長度的部分。

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

long[] longArrayLayout = new long[1];

開啓指針壓縮 -XX:+UseCompressedOops

我們看到紅框部分即爲數組類型對象頭中多出來一個4字節大小用來表示數組長度的部分。

因爲我們示例中的 long 型數組只有一個元素,所以實例數據區的大小隻有 8 字節。如果我們示例中的 long 型數組變爲兩個元素,那麼實例數據區的大小就會變爲 16 字節,以此類推................。

關閉指針壓縮  -XX:-UseCompressedOops

當關閉了指針壓縮時,對象頭中的 MarkWord 還是佔用 8 個字節,但是類型指針從 4 個字節變爲了 8 個字節。數組長度屬性還是不變保持 4 個字節。

這裏我們發現是實例數據區與對象頭之間發生了對齊填充。大家還記得這是爲什麼嗎??

我們前邊在字段重排列小節介紹了三種字段排列規則在這裏繼續適用:

這裏基本數組類型的實例數據區中是 long 型,在關閉指針壓縮的情況下,根據規則 1 和規則 2 需要對齊至 8 的倍數,所以要在其與對象頭之間填充 4 個字節,達到內存對齊的目的,起始地址變爲24

8.2 引用類型數組的內存佈局

引用類型數組的內存佈局. png

上圖表示的是引用類型數組在內存中的佈局,引用類型數組在 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 讀寫內存的完整過程。

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

在本文中我們順帶還介紹了和內存佈局相關的幾個 JVM 參數:-XX:+UseCompressedOops, -XX +CompactFields ,-XX:-RestrictContended ,-XX:ContendedPaddingWidth, -XX:ObjectAlignmentInBytes。


本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ECPX1p6NqtKyD14vEUmHWQ