深入理解 Java 內存架構
hi,大家週末好,今天給大家帶來一篇硬核的技術文章,本文我們將從計算機組成原理的角度詳細闡述對象在 JVM 內存中是如何佈局的,以及什麼是內存對齊,如果我們頭比較鐵,就是不進行內存對齊會造成什麼樣的後果,最後引出壓縮指針的原理和應用。同時我們還介紹了在高併發場景下,False Sharing 產生的原因以及帶來的性能影響。
相信大家看完本文後,一定會收穫很多,話不多說,下面我們正式開始本文的內容~~
本文概要. png
在我們的日常工作中,有時候我們爲了防止線上應用發生OOM
,所以我們需要在開發的過程中計算一些核心對象在內存中的佔用大小,目的是爲了更好的瞭解我們的應用程序內存佔用的一個大概情況。
進而根據我們服務器的內存資源限制以及預估的對象創建數量級計算出應用程序佔用內存的高低水位線,如果內存佔用量超過高水位線
,那麼就有可能有發生OOM
的風險。
我們可以在程序中根據估算出的高低水位線
,做一些防止OOM
的處理邏輯或者發出告警。
那麼核心問題是如何計算一個 Java 對象在內存中的佔用大小呢??
在爲大家解答這個問題之前,筆者先來介紹下 Java 對象在內存中的佈局,也就是本文的主題。
- Java 對象的內存佈局
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 對象中定義的這些實例字段在實例數據區是如何排列布局的:
- 字段重排列
其實我們在編寫 Java 源代碼文件的時候定義的那些實例字段的順序會被 JVM 重新分配排列,這樣做的目的其實是爲了內存對齊,那麼什麼是內存對齊,爲什麼要進行內存對齊,筆者會隨着文章深入的解讀爲大家逐層揭曉答案~~
本小節中,筆者先來爲大家介紹一下 JVM 字段重排列的規則:
JVM 重新分配字段的排列順序受-XX:FieldsAllocationStyle
參數的影響,默認值爲1
,實例字段的重新分配策略遵循以下規則:
- 如果一個字段佔用
X
個字節,那麼這個字段的偏移量OFFSET
需要對齊至NX
偏移量是指字段的內存地址與 Java 對象的起始內存地址之間的差值。比如 long 類型的字段,它內存佔用 8 個字節,那麼它的 OFFSET 應該是 8 的倍數 8N。不足 8N 的需要填充字節。
-
在開啓了壓縮指針的 64 位 JVM 中,Java 類中的第一個字段的 OFFSET 需要對齊至 4N,在關閉壓縮指針的情況下類中第一個字段的 OFFSET 需要對齊至 8N。
-
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
都是開啓的
- 對齊填充(Padding)
在前一小節關於實例數據區字段重排列的介紹中爲了內存對齊而導致的字節填充不僅會出現在字段與字段之間,還會出現在對象與對象之間。
前邊我們介紹了字段重排列需要遵循的三個重要規則,其中規則 1,規則 2 定義了字段與字段之間的內存對齊規則。規則 3 定義的是對象字段之間的排列規則。
爲了內存對齊的需要,對象頭與字段之間,以及字段與字段之間需要填充一些不必要的字節。
比如前邊提到的字段重排列的第一種情況-XX:+UseCompressedOops -XX -CompactFields
。
而以上提到的四種情況都會在對象實例數據區的後邊在填充 4 字節大小的空間,原因是除了需要滿足字段與字段之間的內存對齊之外,還需要滿足對象與對象之間的內存對齊。
Java 虛擬機堆中對象之間的內存地址需要對齊至 8N(8 的倍數),如果一個對象佔用內存不到 8N 個字節,那麼就必須在對象後填充一些不必要的字節對齊至 8N 個字節。
虛擬機中內存對齊的選項爲
-XX:ObjectAlignmentInBytes
,默認爲 8。也就是說對象與對象之間的內存地址需要對齊至多少倍,是由這個 JVM 參數控制的。
我們還是以上邊第一種情況爲例說明:圖中對象實際佔用是 44 個字節,但是不是 8 的倍數,那麼就需要再填充 4 個字節,內存對齊至 48 個字節。
以上這些爲了內存對齊的目的而在字段與字段之間,對象與對象之間填充的不必要字節,我們就稱之爲對齊填充(Padding)
。
- 對齊填充的應用
在我們知道了對齊填充的概念之後,大家可能好奇了,爲啥我們要進行對齊填充,是要解決什麼問題嗎?
那麼就讓我們帶着這個問題,來接着聽筆者往下聊~~
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 的情況:
-
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。
falsesharding1.png
在這種場景下,會對線程 b 的讀取操作造成什麼影響呢?
我們知道聲明瞭volatile關鍵字
的變量可以在多線程處理環境下,確保內存的可見性。計算機硬件層會保證對被 volatile 關鍵字修飾的共享變量進行寫操作後的內存可見性,而這種內存可見性是由Lock前綴指令
以及緩存一致性協議(MESI控制協議)
共同保證的。
-
Lock 前綴指令可以使修改線程所在的處理器中的相應緩存行數據被修改後立馬刷新回內存中,並同時
鎖定
所有處理器核心中緩存了該修改變量的緩存行,防止多個處理器核心併發修改同一緩存行。 -
緩存一致性協議主要是用來維護多個處理器核心之間的 CPU 緩存一致性以及與內存數據的一致性。每個處理器會在總線上嗅探其他處理器準備寫入的內存地址,如果這個內存地址在自己的處理器中被緩存的話,就會將自己處理器中對應的緩存行置爲
無效
,下次需要讀取的該緩存行中的數據的時候,就需要訪問內存獲取。
基於以上 volatile 關鍵字原則,我們首先來看第一種影響:
falsesharding2.png
-
當線程 a 在處理器 core0 中對字段 a 進行修改時,
Lock前綴指令
會將所有處理器中緩存了字段 a 的對應緩存行進行鎖定
,這樣就會導致線程 b 在處理器 core1 中無法讀取和修改自己緩存行的字段 b。 -
處理器 core0 將修改後的字段 a 所在的緩存行刷新回內存中。
從圖中我們可以看到此時字段 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 還提供了分組的概念,註解中的 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 註解會採用兩倍
的緩存行大小來填充呢?
其實這裏的原因有兩個:
- 首先第一個原因,我們之前也已經提到過了,目前大部分主流的 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字節
,來保證分組之間的變量不能共享緩存行。
- 內存對齊
通過以上內容我們瞭解到 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)。多個存儲器模塊連接到存儲控制器上,就聚合成了主存。
內存結構. 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
-
首先存儲控制器將行地址
RAS = 2
通過地址引腳發送給DRAM芯片
。 -
DRAM 芯片根據
RAS = 2
將二維矩陣中的第二行的全部內容拷貝到內部行緩衝區
中。 -
接下來存儲控制器會通過地址引腳發送
CAS = 2
到 DRAM 芯片中。 -
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)
。
總線上傳輸的信號包括:地址信號,數據信號,控制信號。其中控制總線上傳輸的控制信號可以同步事務,並能夠標識出當前正在被執行的事務信息:
-
當前這個事務是到內存的?還是到磁盤的?或者是到其他 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 讀取內存. png
首先 CPU 芯片中的總線接口
會在總線上發起讀事務(read transaction
)。該讀事務分爲以下步驟進行:
-
CPU 將內存地址 A 放到系統總線上。隨後
IO bridge
將信號傳遞到存儲總線上。 -
主存感受到存儲總線上的
地址信號
並通過存儲控制器將存儲總線上的內存地址 A 讀取出來。 -
存儲控制器通過內存地址 A 定位到具體的存儲器模塊,從
DRAM芯片
中取出內存地址 A 對應的數據X
。 -
存儲控制器將讀取到的
數據X
放到存儲總線上,隨後 IO bridge 將存儲總線
上的數據信號轉換爲系統總線
上的數據信號,然後繼續沿着系統總線傳遞。 -
CPU 芯片感受到系統總線上的數據信號,將數據從系統總線上讀取出來並拷貝到寄存器中。
以上就是 CPU 讀取內存數據到寄存器中的完整過程。
但是其中還涉及到一個重要的過程,這裏我們還是需要攤開來介紹一下,那就是存儲控制器如何通過內存地址A
從主存中讀取出對應的數據X
的?
接下來我們結合前邊介紹的內存結構以及從 DRAM 芯片讀取數據的過程,來總體介紹下如何從主存中讀取數據。
5.3.3 如何根據內存地址從主存中讀取數據
前邊介紹到,當主存中的存儲控制器感受到了存儲總線上的地址信號
時,會將內存地址從存儲總線上讀取出來。
隨後會通過內存地址定位到具體的存儲器模塊。還記得內存結構中的存儲器模塊嗎??
內存結構. png
而每個存儲器模塊中包含了 8 個 DRAM 芯片,編號從0 - 7
。
存儲器模塊. png
存儲控制器會將內存地址轉換爲 DRAM 芯片中 supercell 在二維矩陣中的座標地址 (RAS
,CAS
)。並將這個座標地址發送給對應的存儲器模塊。隨後存儲器模塊會將RAS
和CAS
廣播到存儲器模塊中的所有DRAM芯片
。依次通過 (RAS
,CAS
) 從 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
)。寫事務步驟如下:
-
CPU 將要寫入的內存地址 A 放入系統總線上。
-
通過
IO bridge
的信號轉換,將內存地址 A 傳遞到存儲總線上。 -
存儲控制器感受到存儲總線上的地址信號,將內存地址 A 從存儲總線上讀取出來,並等待數據的到達。
-
CPU 將寄存器中的數據拷貝到系統總線上,通過
IO bridge
的信號轉換,將數據傳遞到存儲總線上。 -
存儲控制器感受到存儲總線上的數據信號,將數據從存儲總線上讀取出來。
-
存儲控制器通過內存地址 A 定位到具體的存儲器模塊,最後將數據寫入存儲器模塊中的 8 個 DRAM 芯片中。
-
爲什麼要內存對齊
我們在瞭解了內存結構以及 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
這段連續內存地址上的 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 訪問內存對齊的地址時,比如0x0000
和0x0008
這兩個起始地址都是對齊至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. 字段重排列》這一小節介紹的三種字段對齊規則,是保證在字段內存對齊的基礎上使得實例數據區佔用內存儘可能的小。
- 壓縮指針
在介紹完關於內存對齊的相關內容之後,我們來介紹下前邊經常提到的壓縮指針。可以通過 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
雖然能擴大尋址範圍,但是這同時也可能增加了對象之間的字節填充,導致壓縮指針沒有達到原本節省空間的效果。
- 數組對象的內存佈局
前邊大量的篇幅我們都是在討論 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 個字節。
這裏我們發現是實例數據區與對象頭之間發生了對齊填充。大家還記得這是爲什麼嗎??
我們前邊在字段重排列小節介紹了三種字段排列規則在這裏繼續適用:
-
規則1
:如果一個字段佔用X
個字節,那麼這個字段的偏移量 OFFSET 需要對齊至NX
。 -
規則2
:在開啓了壓縮指針的 64 位 JVM 中,Java 類中的第一個字段的 OFFSET 需要對齊至4N
,在關閉壓縮指針的情況下類中第一個字段的 OFFSET 需要對齊至8N
。
這裏基本數組類型的實例數據區中是 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 讀寫內存的完整過程。
最後又由內存對齊引出了壓縮指針的工作原理。由此我們知道進行內存對齊的四個原因:
-
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。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/ECPX1p6NqtKyD14vEUmHWQ