大家好,我是bin岩灭,又到了每周我們見(jiàn)面的時(shí)刻了,我的公眾號(hào)在1月10號(hào)那天發(fā)布了第一篇文章《從內(nèi)核角度看IO模型的演變》赂鲤,在這篇文章中我們通過(guò)圖解的方式以一個(gè)C10k的問(wèn)題為主線噪径,從內(nèi)核角度詳細(xì)闡述了5種IO模型的演變過(guò)程窘拯,以及兩種IO線程模型的介紹苦丁,最后引出了Netty的網(wǎng)絡(luò)IO線程模型葫掉。讀者朋友們后臺(tái)留言都覺(jué)得非常的硬核款违,在大家的支持下這篇文章的目前閱讀量為2038舆乔,點(diǎn)贊量為80汁雷,在看為32跳座。這對(duì)于剛剛誕生一個(gè)多月的小號(hào)來(lái)說(shuō)杯巨,是一種莫大的鼓勵(lì)。在這里bin再次感謝大家的認(rèn)可和鼓勵(lì)支持~~
今天bin將再來(lái)為大家?guī)?lái)一篇硬核的技術(shù)文章练般,本文我們將從計(jì)算機(jī)組成原理的角度詳細(xì)闡述對(duì)象在JVM內(nèi)存中是如何布局的矗漾,以及什么是內(nèi)存對(duì)齊,如果我們頭比較鐵薄料,就是不進(jìn)行內(nèi)存對(duì)齊會(huì)造成什么樣的后果敞贡,最后引出壓縮指針的原理和應(yīng)用。同時(shí)我們還介紹了在高并發(fā)場(chǎng)景下摄职,F(xiàn)alse Sharing產(chǎn)生的原因以及帶來(lái)的性能影響誊役。
相信大家看完本文后,一定會(huì)收獲很多谷市,話不多說(shuō)蛔垢,下面我們正式開(kāi)始本文的內(nèi)容~~
在我們的日常工作中,有時(shí)候我們?yōu)榱朔乐咕€上應(yīng)用發(fā)生OOM
迫悠,所以我們需要在開(kāi)發(fā)的過(guò)程中計(jì)算一些核心對(duì)象在內(nèi)存中的占用大小鹏漆,目的是為了更好的了解我們的應(yīng)用程序內(nèi)存占用的一個(gè)大概情況。
進(jìn)而根據(jù)我們服務(wù)器的內(nèi)存資源限制以及預(yù)估的對(duì)象創(chuàng)建數(shù)量級(jí)計(jì)算出應(yīng)用程序占用內(nèi)存的高低水位線创泄,如果內(nèi)存占用量超過(guò)高水位線
艺玲,那么就有可能有發(fā)生OOM
的風(fēng)險(xiǎn)。
我們可以在程序中根據(jù)估算出的高低水位線
鞠抑,做一些防止OOM
的處理邏輯或者發(fā)出告警饭聚。
那么核心問(wèn)題是如何計(jì)算一個(gè)Java對(duì)象在內(nèi)存中的占用大小呢?搁拙?
在為大家解答這個(gè)問(wèn)題之前秒梳,筆者先來(lái)介紹下Java對(duì)象在內(nèi)存中的布局,也就是本文的主題箕速。
1. Java對(duì)象的內(nèi)存布局
如圖所示酪碘,Java對(duì)象在JVM中是用instanceOopDesc
結(jié)構(gòu)表示而Java對(duì)象在JVM堆中的內(nèi)存布局可以分為三部分:
1.1 對(duì)象頭(Header)
每個(gè)Java對(duì)象都包含一個(gè)對(duì)象頭,對(duì)象頭中包含了兩類信息:
MarkWord
:在JVM中用markOopDesc
結(jié)構(gòu)表示用于存儲(chǔ)對(duì)象自身運(yùn)行時(shí)的數(shù)據(jù)弧满。比如:hashcode婆跑,GC分代年齡此熬,鎖狀態(tài)標(biāo)志庭呜,線程持有的鎖,偏向線程Id犀忱,偏向時(shí)間戳等募谎。在32位操作系統(tǒng)和64位操作系統(tǒng)中MarkWord
分別占用4B和8B大小的內(nèi)存。-
類型指針
:JVM中的類型指針?lè)庋b在klassOopDesc
結(jié)構(gòu)中阴汇,類型指針指向了InstanceKclass對(duì)象
数冬,Java類在JVM中是用InstanceKclass對(duì)象封裝的,里邊包含了Java類的元信息,比如:繼承結(jié)構(gòu)拐纱,方法铜异,靜態(tài)變量,構(gòu)造函數(shù)等秸架。- 在不開(kāi)啟指針壓縮的情況下(-XX:-UseCompressedOops)揍庄。在32位操作系統(tǒng)和64位操作系統(tǒng)中類型指針?lè)謩e占用4B和8B大小的內(nèi)存。
- 在開(kāi)啟指針壓縮的情況下(-XX:+UseCompressedOops)东抹。在32位操作系統(tǒng)和64位操作系統(tǒng)中類型指針?lè)謩e占用4B和4B大小的內(nèi)存蚂子。
如果Java對(duì)象是一個(gè)數(shù)組類型的話,那么在數(shù)組對(duì)象的對(duì)象頭中還會(huì)包含一個(gè)4B大小的用于記錄數(shù)組長(zhǎng)度的屬性缭黔。
由于在對(duì)象頭中用于記錄數(shù)組長(zhǎng)度大小的屬性只占4B的內(nèi)存食茎,所以Java數(shù)組可以申請(qǐng)的最大長(zhǎng)度為:
2^32
。
1.2 實(shí)例數(shù)據(jù)(Instance Data)
Java對(duì)象在內(nèi)存中的實(shí)例數(shù)據(jù)區(qū)用來(lái)存儲(chǔ)Java類中定義的實(shí)例字段馏谨,包括所有父類中的實(shí)例字段别渔。也就是說(shuō),雖然子類無(wú)法訪問(wèn)父類的私有實(shí)例字段惧互,或者子類的實(shí)例字段隱藏了父類的同名實(shí)例字段钠糊,但是子類的實(shí)例還是會(huì)為這些父類實(shí)例字段分配內(nèi)存。
Java對(duì)象中的字段類型分為兩大類:
-
基礎(chǔ)類型:Java類中實(shí)例字段定義的基礎(chǔ)類型在實(shí)例數(shù)據(jù)區(qū)的內(nèi)存占用如下:
- long | double占用8個(gè)字節(jié)壹哺。
- int | float占用4個(gè)字節(jié)抄伍。
- short | char占用2個(gè)字節(jié)。
- byte | boolean占用1個(gè)字節(jié)管宵。
-
引用類型:Java類中實(shí)例字段的引用類型在實(shí)例數(shù)據(jù)區(qū)內(nèi)存占用分為兩種情況:
- 不開(kāi)啟指針壓縮(-XX:-UseCompressedOops):在32位操作系統(tǒng)中引用類型的內(nèi)存占用為4個(gè)字節(jié)截珍。在64位操作系統(tǒng)中引用類型的內(nèi)存占用為8個(gè)字節(jié)。
- 開(kāi)啟指針壓縮(-XX:+UseCompressedOops):在64為操作系統(tǒng)下箩朴,引用類型內(nèi)存占用則變?yōu)闉?個(gè)字節(jié)岗喉,32位操作系統(tǒng)中引用類型的內(nèi)存占用繼續(xù)為4個(gè)字節(jié)。
為什么32位操作系統(tǒng)的引用類型占4個(gè)字節(jié)炸庞,而64位操作系統(tǒng)引用類型占8字節(jié)钱床?
在Java中,引用類型所保存的是被引用對(duì)象的內(nèi)存地址埠居。在32位操作系統(tǒng)中內(nèi)存地址是由32個(gè)bit表示查牌,因此需要4個(gè)字節(jié)來(lái)記錄內(nèi)存地址,能夠記錄的虛擬地址空間是2^32大小滥壕,也就是只能夠表示4G大小的內(nèi)存纸颜。
而在64位操作系統(tǒng)中內(nèi)存地址是由64個(gè)bit表示,因此需要8個(gè)字節(jié)來(lái)記錄內(nèi)存地址绎橘,但在 64 位系統(tǒng)里只使用了低 48 位胁孙,所以它的虛擬地址空間是 2^48大小,能夠表示256T大小的內(nèi)存,其中低 128T 的空間劃分為用戶空間涮较,高 128T 劃分為內(nèi)核空間稠鼻,可以說(shuō)是非常大了。
在我們從整體上介紹完Java對(duì)象在JVM中的內(nèi)存布局之后狂票,下面我們來(lái)看下Java對(duì)象中定義的這些實(shí)例字段在實(shí)例數(shù)據(jù)區(qū)是如何排列布局的:
2. 字段重排列
其實(shí)我們?cè)诰帉慗ava源代碼文件的時(shí)候定義的那些實(shí)例字段的順序會(huì)被JVM重新分配排列枷餐,這樣做的目的其實(shí)是為了內(nèi)存對(duì)齊,那么什么是內(nèi)存對(duì)齊苫亦,為什么要進(jìn)行內(nèi)存對(duì)齊毛肋,筆者會(huì)隨著文章深入的解讀為大家逐層揭曉答案~~
本小節(jié)中,筆者先來(lái)為大家介紹一下JVM字段重排列的規(guī)則:
JVM重新分配字段的排列順序受-XX:FieldsAllocationStyle
參數(shù)的影響屋剑,默認(rèn)值為1
润匙,實(shí)例字段的重新分配策略遵循以下規(guī)則:
- 如果一個(gè)字段占用
X
個(gè)字節(jié),那么這個(gè)字段的偏移量OFFSET
需要對(duì)齊至NX
偏移量是指字段的內(nèi)存地址與Java對(duì)象的起始內(nèi)存地址之間的差值唉匾。比如long類型的字段孕讳,它內(nèi)存占用8個(gè)字節(jié),那么它的OFFSET應(yīng)該是8的倍數(shù)8N巍膘。不足8N的需要填充字節(jié)厂财。
在開(kāi)啟了壓縮指針的64位JVM中,Java類中的第一個(gè)字段的OFFSET需要對(duì)齊至4N峡懈,在關(guān)閉壓縮指針的情況下類中第一個(gè)字段的OFFSET需要對(duì)齊至8N璃饱。
JVM默認(rèn)分配字段的順序?yàn)椋簂ong / double,int / float肪康,short / char荚恶,byte / boolean,oops(Ordianry Object Point 引用類型指針)磷支,并且父類中定義的實(shí)例變量會(huì)出現(xiàn)在子類實(shí)例變量之前谒撼。當(dāng)設(shè)置JVM參數(shù)
-XX +CompactFields
時(shí)(默認(rèn)),占用內(nèi)存小于long / double 的字段會(huì)允許被插入到對(duì)象中第一個(gè) long / double字段之前的間隙中雾狈,以避免不必要的內(nèi)存填充廓潜。
CompactFields選項(xiàng)參數(shù)在JDK14中以被標(biāo)記為過(guò)期了,并在將來(lái)的版本中很可能被刪除善榛。詳細(xì)細(xì)節(jié)可查看issue:https://bugs.openjdk.java.net/browse/JDK-8228750
上邊的三條字段重排列規(guī)則非常非常重要辩蛋,但是讀起來(lái)比較繞腦,很抽象不容易理解锭弊,筆者把它們先列出來(lái)的目的是為了讓大家先有一個(gè)朦朦朧朧的感性認(rèn)識(shí)堪澎,下面筆者舉一個(gè)具體的例子來(lái)為大家詳細(xì)說(shuō)明下擂错,在閱讀這個(gè)例子的過(guò)程中也方便大家深刻的理解這三條重要的字段重排列規(guī)則味滞。
假設(shè)現(xiàn)在我們有這樣一個(gè)類定義
public class Parent {
long l;
int i;
}
public class Child extends Parent {
long l;
int i;
}
- 根據(jù)上面介紹的
規(guī)則3
我們知道父類中的變量是出現(xiàn)在子類變量之前的,并且字段分配順序應(yīng)該是long型字段l,應(yīng)該在int型字段i之前剑鞍。
如果JVM開(kāi)啟了
-XX +CompactFields
時(shí)昨凡,int型字段是可以插入對(duì)象中的第一個(gè)long型字段(也就是Parent.l字段)之前的空隙中的。如果JVM設(shè)置了-XX -CompactFields
則int型字段的這種插入行為是不被允許的蚁署。
根據(jù)
規(guī)則1
我們知道long型字段在實(shí)例數(shù)據(jù)區(qū)的OFFSET需要對(duì)齊至8N便脊,而int型字段的OFFSET需要對(duì)齊至4N。根據(jù)
規(guī)則2
我們知道如果開(kāi)啟壓縮指針-XX:+UseCompressedOops
光戈,Child對(duì)象的第一個(gè)字段的OFFSET需要對(duì)齊至4N哪痰,關(guān)閉壓縮指針時(shí)-XX:-UseCompressedOops
,Child對(duì)象的第一個(gè)字段的OFFSET需要對(duì)齊至8N久妆。
由于JVM參數(shù)UseCompressedOops
和CompactFields
的存在晌杰,導(dǎo)致Child對(duì)象在實(shí)例數(shù)據(jù)區(qū)字段的排列順序分為四種情況,下面我們結(jié)合前邊提煉出的這三點(diǎn)規(guī)則來(lái)看下字段排列順序在這四種情況下的表現(xiàn)筷弦。
2.1 -XX:+UseCompressedOops -XX -CompactFields 開(kāi)啟壓縮指針肋演,關(guān)閉字段壓縮
偏移量OFFSET = 8的位置存放的是類型指針,由于開(kāi)啟了壓縮指針?biāo)哉加?個(gè)字節(jié)烂琴。對(duì)象頭總共占用12個(gè)字節(jié):MarkWord(8字節(jié)) + 類型指針(4字節(jié))爹殊。
根據(jù)
規(guī)則3:
父類Parent中的字段是要出現(xiàn)在子類Child的字段之前的并且long型字段在int型字段之前。根據(jù)規(guī)則2:
在開(kāi)啟壓縮指針的情況下奸绷,Child對(duì)象中的第一個(gè)字段需要對(duì)齊至4N梗夸。這里Parent.l字段的OFFSET可以是12也可以是16。根據(jù)規(guī)則1:
long型字段在實(shí)例數(shù)據(jù)區(qū)的OFFSET需要對(duì)齊至8N号醉,所以這里Parent.l字段的OFFSET只能是16绒瘦,因此OFFSET = 12的位置就需要被填充。Child.l字段只能在OFFSET = 32處存儲(chǔ)扣癣,不能夠使用OFFSET = 28位置惰帽,因?yàn)?8的位置不是8的倍數(shù)無(wú)法對(duì)齊8N,因此OFFSET = 28的位置被填充了4個(gè)字節(jié)父虑。
規(guī)則1也規(guī)定了int型字段的OFFSET需要對(duì)齊至4N该酗,所以Parent.i與Child.i分別存儲(chǔ)以O(shè)FFSET = 24和OFFSET = 40的位置。
因?yàn)镴VM中的內(nèi)存對(duì)齊除了存在于字段與字段之間還存在于對(duì)象與對(duì)象之間士嚎,Java對(duì)象之間的內(nèi)存地址需要對(duì)齊至8N呜魄。
所以Child對(duì)象的末尾處被填充了4個(gè)字節(jié),對(duì)象大小由開(kāi)始的44字節(jié)被填充到48字節(jié)莱衩。
2.2 -XX:+UseCompressedOops -XX +CompactFields 開(kāi)啟壓縮指針爵嗅,開(kāi)啟字段壓縮
在第一種情況的分析基礎(chǔ)上,我們開(kāi)啟了
-XX +CompactFields
壓縮字段笨蚁,所以導(dǎo)致int型的Parent.i字段可以插入到OFFSET = 12的位置處睹晒,以避免不必要的字節(jié)填充趟庄。根據(jù)
規(guī)則2:
Child對(duì)象的第一個(gè)字段需要對(duì)齊至4N,這里我們看到int型
的Parent.i字段是符合這個(gè)規(guī)則的伪很。根據(jù)
規(guī)則1:
Child對(duì)象的所有l(wèi)ong型字段都對(duì)齊至8N戚啥,所有的int型字段都對(duì)齊至4N。
最終得到Child對(duì)象大小為36字節(jié)锉试,由于Java對(duì)象與對(duì)象之間的內(nèi)存地址需要對(duì)齊至8N猫十,所以最后Child對(duì)象的末尾又被填充了4個(gè)字節(jié)最終變?yōu)?0字節(jié)。
這里我們可以看到在開(kāi)啟字段壓縮
-XX +CompactFields
的情況下呆盖,Child對(duì)象的大小由48字節(jié)變成了40字節(jié)拖云。
2.3 -XX:-UseCompressedOops -XX -CompactFields 關(guān)閉壓縮指針,關(guān)閉字段壓縮
首先在關(guān)閉壓縮指針-UseCompressedOops
的情況下应又,對(duì)象頭中的類型指針占用字節(jié)變成了8字節(jié)江兢。導(dǎo)致對(duì)象頭的大小在這種情況下變?yōu)榱?6字節(jié)。
根據(jù)
規(guī)則1:
long型的變量OFFSET需要對(duì)齊至8N丁频。根據(jù)規(guī)則2:
在關(guān)閉壓縮指針的情況下杉允,Child對(duì)象的第一個(gè)字段Parent.l需要對(duì)齊至8N。所以這里的Parent.l字段的OFFSET = 16席里。由于long型的變量OFFSET需要對(duì)齊至8N叔磷,所以Child.l字段的OFFSET
需要是32,因此OFFSET = 28的位置被填充了4個(gè)字節(jié)奖磁。
這樣計(jì)算出來(lái)的Child對(duì)象大小為44字節(jié)改基,但是考慮到Java對(duì)象與對(duì)象的內(nèi)存地址需要對(duì)齊至8N,于是又在對(duì)象末尾處填充了4個(gè)字節(jié)咖为,最終Child對(duì)象的內(nèi)存占用為48字節(jié)秕狰。
2.4 -XX:-UseCompressedOops -XX +CompactFields 關(guān)閉壓縮指針,開(kāi)啟字段壓縮
在第三種情況的分析基礎(chǔ)上躁染,我們來(lái)看下第四種情況的字段排列情況:
由于在關(guān)閉指針壓縮的情況下類型指針的大小變?yōu)榱?個(gè)字節(jié)鸣哀,所以導(dǎo)致Child對(duì)象中第一個(gè)字段Parent.l前邊并沒(méi)有空隙,剛好對(duì)齊8N吞彤,并不需要int型變量的插入我衬。所以即使開(kāi)啟了字段壓縮-XX +CompactFields
,字段的總體排列順序還是不變的饰恕。
默認(rèn)情況下指針壓縮
-XX:+UseCompressedOops
以及字段壓縮-XX +CompactFields
都是開(kāi)啟的
3. 對(duì)齊填充(Padding)
在前一小節(jié)關(guān)于實(shí)例數(shù)據(jù)區(qū)字段重排列的介紹中為了內(nèi)存對(duì)齊而導(dǎo)致的字節(jié)填充不僅會(huì)出現(xiàn)在字段與字段之間挠羔,還會(huì)出現(xiàn)在對(duì)象與對(duì)象之間。
前邊我們介紹了字段重排列需要遵循的三個(gè)重要規(guī)則埋嵌,其中規(guī)則1破加,規(guī)則2定義了字段與字段之間的內(nèi)存對(duì)齊規(guī)則。 規(guī)則3定義的是對(duì)象字段之間的排列規(guī)則雹嗦。
為了內(nèi)存對(duì)齊的需要范舀,對(duì)象頭與字段之間合是,以及字段與字段之間需要填充一些不必要的字節(jié)。
比如前邊提到的字段重排列的第一種情況-XX:+UseCompressedOops -XX -CompactFields
尿背。
而以上提到的四種情況都會(huì)在對(duì)象實(shí)例數(shù)據(jù)區(qū)的后邊在填充4字節(jié)大小的空間端仰,原因是除了需要滿足字段與字段之間的內(nèi)存對(duì)齊之外捶惜,還需要滿足對(duì)象與對(duì)象之間的內(nèi)存對(duì)齊田藐。
Java 虛擬機(jī)堆中對(duì)象之間的內(nèi)存地址需要對(duì)齊至8N(8的倍數(shù)),如果一個(gè)對(duì)象占用內(nèi)存不到8N個(gè)字節(jié)吱七,那么就必須在對(duì)象后填充一些不必要的字節(jié)對(duì)齊至8N個(gè)字節(jié)汽久。
虛擬機(jī)中內(nèi)存對(duì)齊的選項(xiàng)為
-XX:ObjectAlignmentInBytes
,默認(rèn)為8踊餐。也就是說(shuō)對(duì)象與對(duì)象之間的內(nèi)存地址需要對(duì)齊至多少倍景醇,是由這個(gè)JVM參數(shù)控制的。
我們還是以上邊第一種情況為例說(shuō)明:圖中對(duì)象實(shí)際占用是44個(gè)字節(jié)吝岭,但是不是8的倍數(shù)三痰,那么就需要再填充4個(gè)字節(jié),內(nèi)存對(duì)齊至48個(gè)字節(jié)窜管。
以上這些為了內(nèi)存對(duì)齊的目的而在字段與字段之間散劫,對(duì)象與對(duì)象之間填充的不必要字節(jié),我們就稱之為對(duì)齊填充(Padding)
幕帆。
4. 對(duì)齊填充的應(yīng)用
在我們知道了對(duì)齊填充的概念之后获搏,大家可能好奇了,為啥我們要進(jìn)行對(duì)齊填充失乾,是要解決什么問(wèn)題嗎常熙?
那么就讓我們帶著這個(gè)問(wèn)題,來(lái)接著聽(tīng)筆者往下聊~~
4.1 解決偽共享問(wèn)題帶來(lái)的對(duì)齊填充
除了以上介紹的兩種對(duì)齊填充的場(chǎng)景(字段與字段之間碱茁,對(duì)象與對(duì)象之間)裸卫,在JAVA中還有一種對(duì)齊填充的場(chǎng)景,那就是通過(guò)對(duì)齊填充的方式來(lái)解決False Sharing(偽共享)
的問(wèn)題纽竣。
在介紹False Sharing(偽共享)之前彼城,筆者先來(lái)介紹下CPU讀取內(nèi)存中數(shù)據(jù)的方式。
4.1.1 CPU緩存
根據(jù)摩爾定律:芯片中的晶體管數(shù)量每隔18
個(gè)月就會(huì)翻一番退个。導(dǎo)致CPU的性能和處理速度變得越來(lái)越快募壕,而提升CPU的運(yùn)行速度比提升內(nèi)存的運(yùn)行速度要容易和便宜的多,所以就導(dǎo)致了CPU與內(nèi)存之間的速度差距越來(lái)越大语盈。
為了彌補(bǔ)CPU與內(nèi)存之間巨大的速度差異舱馅,提高CPU的處理效率和吞吐,于是人們引入了L1,L2,L3
高速緩存集成到CPU中刀荒。當(dāng)然還有L0
也就是寄存器代嗤,寄存器離CPU最近棘钞,訪問(wèn)速度也最快,基本沒(méi)有時(shí)延干毅。
一個(gè)CPU里面包含多個(gè)核心宜猜,我們?cè)谫?gòu)買電腦的時(shí)候經(jīng)常會(huì)看到這樣的處理器配置,比如4核8線程
硝逢。意思是這個(gè)CPU包含4個(gè)物理核心8個(gè)邏輯核心姨拥。4個(gè)物理核心表示在同一時(shí)間可以允許4個(gè)線程并行執(zhí)行,8個(gè)邏輯核心表示處理器利用超線程的技術(shù)
將一個(gè)物理核心模擬出了兩個(gè)邏輯核心渠鸽,一個(gè)物理核心在同一時(shí)間只會(huì)執(zhí)行一個(gè)線程叫乌,而超線程芯片
可以做到線程之間快速切換,當(dāng)一個(gè)線程在訪問(wèn)內(nèi)存的空隙徽缚,超線程芯片可以馬上切換去執(zhí)行另外一個(gè)線程憨奸。因?yàn)榍袚Q速度非常快凿试,所以在效果上看到是8個(gè)線程在同時(shí)執(zhí)行排宰。
圖中的CPU核心指的是物理核心亚茬。
從圖中我們可以看到L1Cache是離CPU核心最近的高速緩存刻像,緊接著就是L2Cache,L3Cache酒贬,內(nèi)存吧恃。
離CPU核心越近的緩存訪問(wèn)速度也越快虾啦,造價(jià)也就越高,當(dāng)然容量也就越小痕寓。
其中L1Cache和L2Cache是CPU物理核心私有的(注意:這里是物理核心不是邏輯核心)
而L3Cache是整個(gè)CPU所有物理核心共享的傲醉。
CPU邏輯核心共享其所屬物理核心的L1Cache和L2Cache
L1Cache
L1Cache離CPU是最近的,它的訪問(wèn)速度最快呻率,容量也最小硬毕。
從圖中我們看到L1Cache分為兩個(gè)部分,分別是:Data Cache和Instruction Cache礼仗。它們一個(gè)是存儲(chǔ)數(shù)據(jù)的吐咳,一個(gè)是存儲(chǔ)代碼指令的。
我們可以通過(guò)cd /sys/devices/system/cpu/
來(lái)查看linux機(jī)器上的CPU信息元践。
在/sys/devices/system/cpu/
目錄里韭脊,我們可以看到CPU的核心數(shù),當(dāng)然這里指的是邏輯核心单旁。
筆者機(jī)器上的處理器并沒(méi)有使用超線程技術(shù)所以這里其實(shí)是4個(gè)物理核心沪羔。
下面我們進(jìn)入其中一顆CPU核心(cpu0)中去看下L1Cache的情況:
CPU緩存的情況在/sys/devices/system/cpu/cpu0/cache
目錄下查看:
index0
描述的是L1Cache中DataCache的情況:
-
level
:表示該cache信息屬于哪一級(jí),1表示L1Cache象浑。 -
type
:表示屬于L1Cache的DataCache蔫饰。 -
size
:表示DataCache的大小為32K琅豆。 -
shared_cpu_list
:之前我們提到L1Cache和L2Cache是CPU物理核所私有的,而由物理核模擬出來(lái)的邏輯核是共享L1Cache和L2Cache的篓吁,/sys/devices/system/cpu/
目錄下描述的信息是邏輯核茫因。shared_cpu_list描述的正是哪些邏輯核共享這個(gè)物理核。
index1
描述的是L1Cache中Instruction Cache的情況:
我們看到L1Cache中的Instruction Cache大小也是32K杖剪。
L2Cache
L2Cache的信息存儲(chǔ)在index2
目錄下:
L2Cache的大小為256K冻押,比L1Cache要大些。
L3Cache
L3Cache的信息存儲(chǔ)在index3
目錄下:
到這里我們可以看到L1Cache中的DataCache和InstructionCache大小一樣都是32K而L2Cache的大小為256K摘盆,L3Cache的大小為6M翼雀。
當(dāng)然這些數(shù)值在不同的CPU配置上會(huì)是不同的饱苟,但是總體上來(lái)說(shuō)L1Cache的量級(jí)是幾十KB孩擂,L2Cache的量級(jí)是幾百KB,L3Cache的量級(jí)是幾MB箱熬。
4.1.2 CPU緩存行
前邊我們介紹了CPU的高速緩存結(jié)構(gòu)类垦,引入高速緩存的目的在于消除CPU與內(nèi)存之間的速度差距,根據(jù)程序的局部性原理
我們知道城须,CPU的高速緩存肯定是用來(lái)存放熱點(diǎn)數(shù)據(jù)的蚤认。
程序局部性原理表現(xiàn)為:時(shí)間局部性和空間局部性。時(shí)間局部性是指如果程序中的某條指令一旦執(zhí)行糕伐,則不久之后該指令可能再次被執(zhí)行砰琢;如果某塊數(shù)據(jù)被訪問(wèn),則不久之后該數(shù)據(jù)可能再次被訪問(wèn)良瞧∨闫空間局部性是指一旦程序訪問(wèn)了某個(gè)存儲(chǔ)單元,則不久之后褥蚯,其附近的存儲(chǔ)單元也將被訪問(wèn)挚冤。
那么在高速緩存中存取數(shù)據(jù)的基本單位又是什么呢?赞庶?
事實(shí)上熱點(diǎn)數(shù)據(jù)在CPU高速緩存中的存取并不是我們想象中的以單獨(dú)的變量或者單獨(dú)的指針為單位存取的训挡。
CPU高速緩存中存取數(shù)據(jù)的基本單位叫做緩存行cache line
。緩存行存取字節(jié)的大小為2的倍數(shù)歧强,在不同的機(jī)器上澜薄,緩存行的大小范圍在32字節(jié)到128字節(jié)之間。目前所有主流的處理器中緩存行的大小均為64字節(jié)
(注意:這里的單位是字節(jié))摊册。
從圖中我們可以看到L1Cache,L2Cache,L3Cache中緩存行的大小都是64字節(jié)
肤京。
這也就意味著每次CPU從內(nèi)存中獲取數(shù)據(jù)或者寫入數(shù)據(jù)的大小為64個(gè)字節(jié),即使你只讀一個(gè)bit丧靡,CPU也會(huì)從內(nèi)存中加載64字節(jié)數(shù)據(jù)進(jìn)來(lái)蟆沫。同樣的道理籽暇,CPU從高速緩存中同步數(shù)據(jù)到內(nèi)存也是按照64字節(jié)的單位來(lái)進(jìn)行。
比如你訪問(wèn)一個(gè)long型數(shù)組饭庞,當(dāng)CPU去加載數(shù)組中第一個(gè)元素時(shí)也會(huì)同時(shí)將后邊的7個(gè)元素一起加載進(jìn)緩存中戒悠。這樣一來(lái)就加快了遍歷數(shù)組的效率。
long類型在Java中占用8個(gè)字節(jié)舟山,一個(gè)緩存行可以存放8個(gè)long型變量绸狐。
事實(shí)上,你可以非忱鄣粒快速的遍歷在連續(xù)的內(nèi)存塊中分配的任意數(shù)據(jù)結(jié)構(gòu)寒矿,如果你的數(shù)據(jù)結(jié)構(gòu)中的項(xiàng)在內(nèi)存中不是彼此相鄰的(比如:鏈表),這樣就無(wú)法利用CPU緩存的優(yōu)勢(shì)若债。由于數(shù)據(jù)在內(nèi)存中不是連續(xù)存放的符相,所以在這些數(shù)據(jù)結(jié)構(gòu)中的每一個(gè)項(xiàng)都可能會(huì)出現(xiàn)緩存行未命中(程序局部性原理
)的情況。
還記得我們?cè)?a target="_blank">《Reactor在Netty中的實(shí)現(xiàn)(創(chuàng)建篇)》中介紹Selector的創(chuàng)建時(shí)提到蠢琳,Netty利用數(shù)組實(shí)現(xiàn)的自定義SelectedSelectionKeySet類型替換掉了JDK利用HashSet類型實(shí)現(xiàn)的
sun.nio.ch.SelectorImpl#selectedKeys
啊终。目的就是利用CPU緩存的優(yōu)勢(shì)來(lái)提高IO活躍的SelectionKeys集合的遍歷性能。
4.2 False Sharing(偽共享)
我們先來(lái)看一個(gè)這樣的例子傲须,筆者定義了一個(gè)示例類FalseSharding蓝牲,類中有兩個(gè)long型的volatile字段a,b泰讽。
public class FalseSharding {
volatile long a;
volatile long b;
}
字段a例衍,b之間邏輯上是獨(dú)立的,它們之間一點(diǎn)關(guān)系也沒(méi)有已卸,分別用來(lái)存儲(chǔ)不同的數(shù)據(jù)佛玄,數(shù)據(jù)之間也沒(méi)有關(guān)聯(lián)。
FalseSharding類中字段之間的內(nèi)存布局如下:
FalseSharding類中的字段a,b在內(nèi)存中是相鄰存儲(chǔ)咬最,分別占用8個(gè)字節(jié)翎嫡。
如果恰好字段a,b被CPU讀進(jìn)了同一個(gè)緩存行永乌,而此時(shí)有兩個(gè)線程惑申,線程a用來(lái)修改字段a,同時(shí)線程b用來(lái)讀取字段b翅雏。
在這種場(chǎng)景下圈驼,會(huì)對(duì)線程b的讀取操作造成什么影響呢?
我們知道聲明了volatile關(guān)鍵字
的變量可以在多線程處理環(huán)境下望几,確保內(nèi)存的可見(jiàn)性绩脆。計(jì)算機(jī)硬件層會(huì)保證對(duì)被volatile關(guān)鍵字修飾的共享變量進(jìn)行寫操作后的內(nèi)存可見(jiàn)性,而這種內(nèi)存可見(jiàn)性是由Lock前綴指令
以及緩存一致性協(xié)議(MESI控制協(xié)議)
共同保證的。
Lock前綴指令可以使修改線程所在的處理器中的相應(yīng)緩存行數(shù)據(jù)被修改后立馬刷新回內(nèi)存中靴迫,并同時(shí)
鎖定
所有處理器核心中緩存了該修改變量的緩存行惕味,防止多個(gè)處理器核心并發(fā)修改同一緩存行。緩存一致性協(xié)議主要是用來(lái)維護(hù)多個(gè)處理器核心之間的CPU緩存一致性以及與內(nèi)存數(shù)據(jù)的一致性玉锌。每個(gè)處理器會(huì)在總線上嗅探其他處理器準(zhǔn)備寫入的內(nèi)存地址名挥,如果這個(gè)內(nèi)存地址在自己的處理器中被緩存的話,就會(huì)將自己處理器中對(duì)應(yīng)的緩存行置為
無(wú)效
主守,下次需要讀取的該緩存行中的數(shù)據(jù)的時(shí)候禀倔,就需要訪問(wèn)內(nèi)存獲取。
基于以上volatile關(guān)鍵字原則参淫,我們首先來(lái)看第一種影響:
當(dāng)線程a在處理器core0中對(duì)字段a進(jìn)行修改時(shí)救湖,
Lock前綴指令
會(huì)將所有處理器中緩存了字段a的對(duì)應(yīng)緩存行進(jìn)行鎖定
,這樣就會(huì)導(dǎo)致線程b在處理器core1中無(wú)法讀取和修改自己緩存行的字段b涎才。處理器core0將修改后的字段a所在的緩存行刷新回內(nèi)存中鞋既。
從圖中我們可以看到此時(shí)字段a的值在處理器core0的緩存行中以及在內(nèi)存中已經(jīng)發(fā)生變化了。但是處理器core1中字段a的值還沒(méi)有變化憔维,并且core1中字段a所在的緩存行處于鎖定狀態(tài)
涛救,無(wú)法讀取也無(wú)法寫入字段b畏邢。
從上述過(guò)程中我們可以看出即使字段a业扒,b之間邏輯上是獨(dú)立的,它們之間一點(diǎn)關(guān)系也沒(méi)有舒萎,但是線程a對(duì)字段a的修改程储,導(dǎo)致了線程b無(wú)法讀取字段b。
第二種影響:
當(dāng)處理器core0將字段a所在的緩存行刷新回內(nèi)存的時(shí)候臂寝,處理器core1會(huì)在總線上嗅探到字段a的內(nèi)存地址正在被其他處理器修改章鲤,所以將自己的緩存行置為失效
。當(dāng)線程b在處理器core1中讀取字段b的值時(shí)咆贬,發(fā)現(xiàn)緩存行已被置為失效
败徊,core1需要重新從內(nèi)存中讀取字段b的值即使字段b沒(méi)有發(fā)生任何變化。
從以上兩種影響我們看到字段a與字段b實(shí)際上并不存在共享掏缎,它們之間也沒(méi)有相互關(guān)聯(lián)關(guān)系皱蹦,理論上線程a對(duì)字段a的任何操作,都不應(yīng)該影響線程b對(duì)字段b的讀取或者寫入眷蜈。
但事實(shí)上線程a對(duì)字段a的修改導(dǎo)致了字段b在core1中的緩存行被鎖定(Lock前綴指令)沪哺,進(jìn)而使得線程b無(wú)法讀取字段b。
線程a所在處理器core0將字段a所在緩存行同步刷新回內(nèi)存后酌儒,導(dǎo)致字段b在core1中的緩存行被置為失效
(緩存一致性協(xié)議)辜妓,進(jìn)而導(dǎo)致線程b需要重新回到內(nèi)存讀取字段b的值無(wú)法利用CPU緩存的優(yōu)勢(shì)。
由于字段a和字段b在同一個(gè)緩存行中,導(dǎo)致了字段a和字段b事實(shí)上的共享(原本是不應(yīng)該被共享的)籍滴。這種現(xiàn)象就叫做False Sharing(偽共享)
酪夷。
在高并發(fā)的場(chǎng)景下,這種偽共享的問(wèn)題孽惰,會(huì)對(duì)程序性能造成非常大的影響捶索。
如果線程a對(duì)字段a進(jìn)行修改,與此同時(shí)線程b對(duì)字段b也進(jìn)行修改灰瞻,這種情況對(duì)性能的影響更大腥例,因?yàn)檫@會(huì)導(dǎo)致core0和core1中相應(yīng)的緩存行相互失效。
4.3 False Sharing的解決方案
既然導(dǎo)致False Sharing出現(xiàn)的原因是字段a和字段b在同一個(gè)緩存行導(dǎo)致的酝润,那么我們就要想辦法讓字段a和字段b不在一個(gè)緩存行中窟她。
那么我們?cè)趺醋霾拍軌蚴沟米侄蝍和字段b一定不會(huì)被分配到同一個(gè)緩存行中呢蹭秋?
這時(shí)候,本小節(jié)的主題字節(jié)填充就派上用場(chǎng)了~~
在Java8之前我們通常會(huì)在字段a和字段b前后分別填充7個(gè)long型變量(緩存行大小64字節(jié)),目的是讓字段a和字段b各自獨(dú)占一個(gè)緩存行避免False Sharing
晤愧。
比如我們將一開(kāi)始的實(shí)例代碼修改成這個(gè)這樣子,就可以保證字段a和字段b各自獨(dú)占一個(gè)緩存行了禀挫。
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;
}
修改后的對(duì)象在內(nèi)存中布局如下:
我們看到為了解決False Sharing問(wèn)題阐肤,我們將原本占用32字節(jié)的FalseSharding示例對(duì)象硬生生的填充到了200字節(jié)。這對(duì)內(nèi)存的消耗是非郴肴可觀的借跪。通常為了極致的性能,我們會(huì)在一些高并發(fā)框架或者JDK的源碼中看到False Sharing的解決場(chǎng)景酌壕。因?yàn)樵诟卟l(fā)場(chǎng)景中掏愁,任何微小的性能損失比如False Sharing,都會(huì)被無(wú)限放大卵牍。
但解決False Sharing的同時(shí)又會(huì)帶來(lái)巨大的內(nèi)存消耗果港,所以即使在高并發(fā)框架比如disrupter或者JDK中也只是針對(duì)那些在多線程場(chǎng)景下被頻繁寫入的共享變量。
這里筆者想強(qiáng)調(diào)的是在我們?nèi)粘9ぷ髦?/strong>糊昙,我們不能因?yàn)樽约菏掷锬弥N子辛掠,就滿眼都是釘子,看到任何釘子都想上去錘兩下释牺。
我們要清晰的分辨出一個(gè)問(wèn)題會(huì)帶來(lái)哪些影響和損失萝衩,這些影響和損失在我們當(dāng)前業(yè)務(wù)階段是否可以接受?是否是瓶頸船侧?同時(shí)我們也要清晰的了解要解決這些問(wèn)題我們所要付出的代價(jià)欠气。一定要綜合評(píng)估,講究一個(gè)投入產(chǎn)出比镜撩。某些問(wèn)題雖然是問(wèn)題预柒,但是在某些階段和場(chǎng)景下并不需要我們投入解決队塘。而有些問(wèn)題則對(duì)于我們當(dāng)前業(yè)務(wù)發(fā)展階段是瓶頸,我們不得不去解決宜鸯。我們?cè)诩軜?gòu)設(shè)計(jì)或者程序設(shè)計(jì)中憔古,方案一定要簡(jiǎn)單
,合適
淋袖。并預(yù)估一些提前量留有一定的演化空間
鸿市。
4.3.1 @Contended注解
在Java8中引入了一個(gè)新注解@Contended
,用于解決False Sharing的問(wèn)題即碗,同時(shí)這個(gè)注解也會(huì)影響到Java對(duì)象中的字段排列焰情。
在上一小節(jié)的內(nèi)容介紹中,我們通過(guò)手段填充字段的方式解決了False Sharing的問(wèn)題剥懒,但是這里也有一個(gè)問(wèn)題内舟,因?yàn)槲覀冊(cè)谑謩?dòng)填充字段的時(shí)候還需要考慮CPU緩存行的大小,因?yàn)殡m然現(xiàn)在所有主流的處理器緩存行大小均為64字節(jié)初橘,但是也還是有處理器的緩存行大小為32字節(jié)验游,有的甚至是128字節(jié)。我們需要考慮很多硬件的限制因素保檐。
Java8中通過(guò)引入@Contended注解幫我們解決了這個(gè)問(wèn)題耕蝉,我們不在需要去手動(dòng)填充字段了。下面我們就來(lái)看下@Contended注解是如何幫助我們來(lái)解決這個(gè)問(wèn)題的~~
上小節(jié)介紹的手動(dòng)填充字節(jié)是在共享變量前后填充64字節(jié)大小的空間夜只,這樣只能確保程序在緩存行大小為32字節(jié)或者64字節(jié)的CPU下獨(dú)占緩存行垒在。但是如果CPU的緩存行大小為128字節(jié),這樣依然存在False Sharing的問(wèn)題盐肃。
引入@Contended注解可以使我們忽略底層硬件設(shè)備的差異性爪膊,做到Java語(yǔ)言的初衷:平臺(tái)無(wú)關(guān)性。
@Contended注解默認(rèn)只是在JDK內(nèi)部起作用砸王,如果我們的程序代碼中需要使用到@Contended注解,那么需要開(kāi)啟JVM參數(shù)
-XX:-RestrictContended
才會(huì)生效峦阁。
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface Contended {
//contention group tag
String value() default "";
}
@Contended注解可以標(biāo)注在類上也可以標(biāo)注在類中的字段上谦铃,被@Contended標(biāo)注的對(duì)象會(huì)獨(dú)占緩存行,不會(huì)和任何變量或者對(duì)象共享緩存行榔昔。
@Contended標(biāo)注在類上表示該類對(duì)象中的
實(shí)例數(shù)據(jù)整體
需要獨(dú)占緩存行驹闰。不能與其他實(shí)例數(shù)據(jù)共享緩存行。@Contended標(biāo)注在類中的字段上表示該字段需要獨(dú)占緩存行撒会。
除此之外@Contended還提供了分組的概念嘹朗,注解中的value屬性表示
contention group
。屬于統(tǒng)一分組下的變量诵肛,它們?cè)趦?nèi)存中是連續(xù)存放的屹培,可以允許共享緩存行。不同分組之間不允許共享緩存行。
下面我們來(lái)分別看下@Contended注解在這三種使用場(chǎng)景下是怎樣影響字段之間的排列的褪秀。
@Contended標(biāo)注在類上
@Contended
public class FalseSharding {
volatile long a;
volatile long b;
volatile int c;
volatile int d;
}
當(dāng)@Contended標(biāo)注在FalseSharding示例類上時(shí)蓄诽,表示FalseSharding示例對(duì)象中的整個(gè)實(shí)例數(shù)據(jù)區(qū)
需要獨(dú)占緩存行,不能與其他對(duì)象或者變量共享緩存行媒吗。
這種情況下的內(nèi)存布局:
如圖中所示仑氛,F(xiàn)alseSharding示例類被標(biāo)注了@Contended之后,JVM會(huì)在FalseSharding示例對(duì)象的實(shí)例數(shù)據(jù)區(qū)前后填充128個(gè)字節(jié)
闸英,保證實(shí)例數(shù)據(jù)區(qū)內(nèi)的字段之間內(nèi)存是連續(xù)的锯岖,并且保證整個(gè)實(shí)例數(shù)據(jù)區(qū)獨(dú)占緩存行,不會(huì)與實(shí)例數(shù)據(jù)區(qū)之外的數(shù)據(jù)共享緩存行甫何。
細(xì)心的朋友可能已經(jīng)發(fā)現(xiàn)了問(wèn)題嚎莉,我們之前不是提到緩存行的大小為64字節(jié)嗎?為什么這里會(huì)填充128字節(jié)呢沛豌?
而且之前介紹的手動(dòng)填充也是填充的64字節(jié)
趋箩,為什么@Contended注解會(huì)采用兩倍
的緩存行大小來(lái)填充呢?
其實(shí)這里的原因有兩個(gè):
- 首先第一個(gè)原因加派,我們之前也已經(jīng)提到過(guò)了叫确,目前大部分主流的CPU緩存行是64字節(jié),但是也有部分CPU緩存行是32字節(jié)或者128字節(jié)芍锦,如果只填充64字節(jié)的話竹勉,在緩存行大小為32字節(jié)和64字節(jié)的CPU中是可以做到獨(dú)占緩存行從而避免FalseSharding的,但在緩存行大小為
128字節(jié)
的CPU中還是會(huì)出現(xiàn)FalseSharding問(wèn)題娄琉,這里Java采用了悲觀的一種做法次乓,默認(rèn)都是填充128字節(jié)
,雖然對(duì)于大部分情況下比較浪費(fèi)孽水,但是屏蔽了底層硬件的差異票腰。
不過(guò)@Contended注解填充字節(jié)的大小我們可以通過(guò)JVM參數(shù)
-XX:ContendedPaddingWidth
指定,有效值范圍0 - 8192
女气,默認(rèn)為128
杏慰。
- 第二個(gè)原因其實(shí)是最為核心的一個(gè)原因,主要是為了防止CPU Adjacent Sector Prefetch(CPU相鄰扇區(qū)預(yù)攘毒稀)特性所帶來(lái)的FalseSharding問(wèn)題缘滥。
CPU Adjacent Sector Prefetch:https://www.techarp.com/bios-guide/cpu-adjacent-sector-prefetch/
CPU Adjacent Sector Prefetch是Intel處理器特有的BIOS功能特性,默認(rèn)是enabled谒主。主要作用就是利用程序局部性原理
朝扼,當(dāng)CPU從內(nèi)存中請(qǐng)求數(shù)據(jù),并讀取當(dāng)前請(qǐng)求數(shù)據(jù)所在緩存行時(shí)霎肯,會(huì)進(jìn)一步預(yù)取
與當(dāng)前緩存行相鄰的下一個(gè)緩存行擎颖,這樣當(dāng)我們的程序在順序處理數(shù)據(jù)時(shí)榛斯,會(huì)提高CPU處理效率。這一點(diǎn)也體現(xiàn)了程序局部性原理中的空間局部性特征肠仪。
當(dāng)CPU Adjacent Sector Prefetch特性被disabled禁用時(shí)肖抱,CPU就只會(huì)獲取當(dāng)前請(qǐng)求數(shù)據(jù)所在的緩存行,不會(huì)預(yù)取下一個(gè)緩存行异旧。
所以在當(dāng)CPU Adjacent Sector Prefetch
啟用(enabled)的時(shí)候意述,CPU其實(shí)同時(shí)處理的是兩個(gè)緩存行,在這種情況下吮蛹,就需要填充兩倍緩存行大谢绯纭(128字節(jié))來(lái)避免CPU Adjacent Sector Prefetch所帶來(lái)的的FalseSharding問(wèn)題。
@Contended標(biāo)注在字段上
public class FalseSharding {
@Contended
volatile long a;
@Contended
volatile long b;
volatile int c;
volatile long d;
}
這次我們將 @Contended注解標(biāo)注在了FalseSharding示例類中的字段a和字段b上潮针,這樣帶來(lái)的效果是字段a和字段b各自獨(dú)占緩存行术荤。從內(nèi)存布局上看,字段a和字段b前后分別被填充了128個(gè)字節(jié)每篷,來(lái)確保字段a和字段b不與任何數(shù)據(jù)共享緩存行瓣戚。
而沒(méi)有被@Contended注解標(biāo)注字段c和字段d則在內(nèi)存中連續(xù)存儲(chǔ),可以共享緩存行焦读。
@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放在另一個(gè)content group下。
這樣處在同一分組group1
下的字段a與字段b在內(nèi)存中是連續(xù)存儲(chǔ)的矗晃,可以共享緩存行仑嗅。
同理處在同一分組group2下的字段c與字段d在內(nèi)存中也是連續(xù)存儲(chǔ)的,也允許共享緩存行张症。
但是分組之間是不能共享緩存行的仓技,所以在字段分組的前后各填充128字節(jié)
,來(lái)保證分組之間的變量不能共享緩存行俗他。
5. 內(nèi)存對(duì)齊
通過(guò)以上內(nèi)容我們了解到Java對(duì)象中的實(shí)例數(shù)據(jù)區(qū)字段需要進(jìn)行內(nèi)存對(duì)齊而導(dǎo)致在JVM中會(huì)被重排列以及通過(guò)填充緩存行避免false sharding的目的所帶來(lái)的字節(jié)對(duì)齊填充脖捻。
我們也了解到內(nèi)存對(duì)齊不僅發(fā)生在對(duì)象與對(duì)象之間,也發(fā)生在對(duì)象中的字段之間拯辙。
那么在本小節(jié)中筆者將為大家介紹什么是內(nèi)存對(duì)齊郭变,在本節(jié)的內(nèi)容開(kāi)始之前筆者先來(lái)拋出兩個(gè)問(wèn)題:
為什么要進(jìn)行內(nèi)存對(duì)齊?如果就是頭比較鐵涯保,就是不內(nèi)存對(duì)齊,會(huì)產(chǎn)生什么樣的后果周伦?
Java 虛擬機(jī)堆中對(duì)象的起始地址為什么需要對(duì)齊至
8
的倍數(shù)夕春?為什么不對(duì)齊至4的倍數(shù)或16的倍數(shù)或32的倍數(shù)呢?
帶著這兩個(gè)問(wèn)題专挪,下面我們正式開(kāi)始本節(jié)的內(nèi)容~~~
5.1 內(nèi)存結(jié)構(gòu)
我們平時(shí)所稱的內(nèi)存也叫隨機(jī)訪問(wèn)存儲(chǔ)器(random-access memory)也叫RAM及志。而RAM分為兩類:
一類是靜態(tài)RAM(
SRAM
)片排,這類SRAM用于前邊介紹的CPU高速緩存L1Cache,L2Cache速侈,L3Cache率寡。其特點(diǎn)是訪問(wèn)速度快,訪問(wèn)速度為1 - 30個(gè)
時(shí)鐘周期倚搬,但是容量小冶共,造價(jià)高。另一類則是動(dòng)態(tài)RAM(
DRAM
)每界,這類DRAM用于我們常說(shuō)的主存上捅僵,其特點(diǎn)的是訪問(wèn)速度慢(相對(duì)高速緩存),訪問(wèn)速度為50 - 200個(gè)
時(shí)鐘周期眨层,但是容量大庙楚,造價(jià)便宜些(相對(duì)高速緩存)。
內(nèi)存由一個(gè)一個(gè)的存儲(chǔ)器模塊(memory module)組成趴樱,它們插在主板的擴(kuò)展槽上仁讨。常見(jiàn)的存儲(chǔ)器模塊通常以64位為單位(8個(gè)字節(jié))傳輸數(shù)據(jù)到存儲(chǔ)控制器上或者從存儲(chǔ)控制器傳出數(shù)據(jù)。
如圖所示內(nèi)存條上黑色的元器件就是存儲(chǔ)器模塊(memory module)法挨。多個(gè)存儲(chǔ)器模塊連接到存儲(chǔ)控制器上焕毫,就聚合成了主存。
而前邊介紹到的DRAM芯片
就包裝在存儲(chǔ)器模塊中航揉,每個(gè)存儲(chǔ)器模塊中包含8個(gè)DRAM芯片
塞祈,依次編號(hào)為0 - 7
。
而每一個(gè)DRAM芯片
的存儲(chǔ)結(jié)構(gòu)是一個(gè)二維矩陣帅涂,二維矩陣中存儲(chǔ)的元素我們稱為超單元(supercell
)议薪,每個(gè)supercell大小為一個(gè)字節(jié)(8 bit
)。每個(gè)supercell都由一個(gè)坐標(biāo)地址(i媳友,j)斯议。
i表示二維矩陣中的行地址,在計(jì)算機(jī)中行地址稱為RAS(row access strobe醇锚,行訪問(wèn)選通脈沖)哼御。
j表示二維矩陣中的列地址,在計(jì)算機(jī)中列地址稱為CAS(column access strobe,列訪問(wèn)選通脈沖)焊唬。
下圖中的supercell的RAS = 2恋昼,CAS = 2。
DRAM芯片
中的信息通過(guò)引腳流入流出DRAM芯片赶促。每個(gè)引腳攜帶1 bit
的信號(hào)液肌。
圖中DRAM芯片包含了兩個(gè)地址引腳(addr
),因?yàn)槲覀円ㄟ^(guò)RAS鸥滨,CAS來(lái)定位要獲取的supercell
嗦哆。還有8個(gè)數(shù)據(jù)引腳(data
),因?yàn)镈RAM芯片的IO單位為一個(gè)字節(jié)(8 bit),所以需要8個(gè)data引腳從DRAM芯片傳入傳出數(shù)據(jù)谤祖。
注意這里只是為了解釋地址引腳和數(shù)據(jù)引腳的概念,實(shí)際硬件中的引腳數(shù)量是不一定的老速。
5.2 DRAM芯片的訪問(wèn)
我們現(xiàn)在就以讀取上圖中坐標(biāo)地址為(2粥喜,2)的supercell為例,來(lái)說(shuō)明訪問(wèn)DRAM芯片的過(guò)程橘券。
首先存儲(chǔ)控制器將行地址
RAS = 2
通過(guò)地址引腳發(fā)送給DRAM芯片
额湘。DRAM芯片根據(jù)
RAS = 2
將二維矩陣中的第二行的全部?jī)?nèi)容拷貝到內(nèi)部行緩沖區(qū)
中。接下來(lái)存儲(chǔ)控制器會(huì)通過(guò)地址引腳發(fā)送
CAS = 2
到DRAM芯片中约郁。DRAM芯片從內(nèi)部行緩沖區(qū)中根據(jù)
CAS = 2
拷貝出第二列的supercell并通過(guò)數(shù)據(jù)引腳發(fā)送給存儲(chǔ)控制器缩挑。
DRAM芯片的IO單位為一個(gè)supercell,也就是一個(gè)字節(jié)(8 bit)鬓梅。
5.3 CPU如何讀寫主存
前邊我們介紹了內(nèi)存的物理結(jié)構(gòu)供置,以及如何訪問(wèn)內(nèi)存中的DRAM芯片獲取supercell中存儲(chǔ)的數(shù)據(jù)(一個(gè)字節(jié)
)。
本小節(jié)我們來(lái)介紹下CPU是如何訪問(wèn)內(nèi)存的绽快。
其中關(guān)于CPU芯片的內(nèi)部結(jié)構(gòu)我們?cè)诮榻Bfalse sharding的時(shí)候已經(jīng)詳細(xì)的介紹過(guò)了芥丧,這里我們主要聚焦在CPU與內(nèi)存之間的總線架構(gòu)上。
5.3.1 總線結(jié)構(gòu)
CPU與內(nèi)存之間的數(shù)據(jù)交互是通過(guò)總線(bus)完成的坊罢,而數(shù)據(jù)在總線上的傳送是通過(guò)一系列的步驟完成的续担,這些步驟稱為總線事務(wù)(bus transaction)。
其中數(shù)據(jù)從內(nèi)存?zhèn)魉偷紺PU稱之為讀事務(wù)(read transaction)
活孩,數(shù)據(jù)從CPU傳送到內(nèi)存稱之為寫事務(wù)(write transaction)
物遇。
總線上傳輸?shù)男盘?hào)包括:地址信號(hào),數(shù)據(jù)信號(hào)憾儒,控制信號(hào)询兴。其中控制總線上傳輸?shù)目刂菩盘?hào)可以同步事務(wù),并能夠標(biāo)識(shí)出當(dāng)前正在被執(zhí)行的事務(wù)信息:
- 當(dāng)前這個(gè)事務(wù)是到內(nèi)存的起趾?還是到磁盤的诗舰?或者是到其他IO設(shè)備的?
- 這個(gè)事務(wù)是讀還是寫训裆?
- 總線上傳輸?shù)牡刂沸盘?hào)(
內(nèi)存地址
)眶根,還是數(shù)據(jù)信號(hào)(數(shù)據(jù)
)?边琉。
還記得我們前邊講到的MESI緩存一致性協(xié)議嗎属百?當(dāng)core0修改字段a的值時(shí),其他CPU核心會(huì)在總線上
嗅探
字段a的內(nèi)存地址变姨,如果嗅探到總線上出現(xiàn)字段a的內(nèi)存地址诸老,說(shuō)明有人在修改字段a,這樣其他CPU核心就會(huì)失效
自己緩存字段a所在的cache line
钳恕。
如上圖所示别伏,其中系統(tǒng)總線是連接CPU與IO bridge的,存儲(chǔ)總線是來(lái)連接IO bridge和主存的忧额。
IO bridge
負(fù)責(zé)將系統(tǒng)總線上的電子信號(hào)轉(zhuǎn)換成存儲(chǔ)總線上的電子信號(hào)厘肮。IO bridge也會(huì)將系統(tǒng)總線和存儲(chǔ)總線連接到IO總線(磁盤等IO設(shè)備)上。這里我們看到IO bridge其實(shí)起的作用就是轉(zhuǎn)換不同總線上的電子信號(hào)睦番。
5.3.2 CPU從內(nèi)存讀取數(shù)據(jù)過(guò)程
假設(shè)CPU現(xiàn)在要將內(nèi)存地址為A
的內(nèi)容加載到寄存器中進(jìn)行運(yùn)算类茂。
首先CPU芯片中的總線接口
會(huì)在總線上發(fā)起讀事務(wù)(read transaction
)。 該讀事務(wù)分為以下步驟進(jìn)行:
CPU將內(nèi)存地址A放到系統(tǒng)總線上托嚣。隨后
IO bridge
將信號(hào)傳遞到存儲(chǔ)總線上巩检。主存感受到存儲(chǔ)總線上的
地址信號(hào)
并通過(guò)存儲(chǔ)控制器將存儲(chǔ)總線上的內(nèi)存地址A讀取出來(lái)。存儲(chǔ)控制器通過(guò)內(nèi)存地址A定位到具體的存儲(chǔ)器模塊示启,從
DRAM芯片
中取出內(nèi)存地址A對(duì)應(yīng)的數(shù)據(jù)X
兢哭。存儲(chǔ)控制器將讀取到的
數(shù)據(jù)X
放到存儲(chǔ)總線上,隨后IO bridge將存儲(chǔ)總線
上的數(shù)據(jù)信號(hào)轉(zhuǎn)換為系統(tǒng)總線
上的數(shù)據(jù)信號(hào)夫嗓,然后繼續(xù)沿著系統(tǒng)總線傳遞迟螺。CPU芯片感受到系統(tǒng)總線上的數(shù)據(jù)信號(hào),將數(shù)據(jù)從系統(tǒng)總線上讀取出來(lái)并拷貝到寄存器中舍咖。
以上就是CPU讀取內(nèi)存數(shù)據(jù)到寄存器中的完整過(guò)程矩父。
但是其中還涉及到一個(gè)重要的過(guò)程,這里我們還是需要攤開(kāi)來(lái)介紹一下排霉,那就是存儲(chǔ)控制器如何通過(guò)內(nèi)存地址A
從主存中讀取出對(duì)應(yīng)的數(shù)據(jù)X
的窍株?
接下來(lái)我們結(jié)合前邊介紹的內(nèi)存結(jié)構(gòu)以及從DRAM芯片讀取數(shù)據(jù)的過(guò)程,來(lái)總體介紹下如何從主存中讀取數(shù)據(jù)攻柠。
5.3.3 如何根據(jù)內(nèi)存地址從主存中讀取數(shù)據(jù)
前邊介紹到球订,當(dāng)主存中的存儲(chǔ)控制器感受到了存儲(chǔ)總線上的地址信號(hào)
時(shí),會(huì)將內(nèi)存地址從存儲(chǔ)總線上讀取出來(lái)辙诞。
隨后會(huì)通過(guò)內(nèi)存地址定位到具體的存儲(chǔ)器模塊辙售。還記得內(nèi)存結(jié)構(gòu)中的存儲(chǔ)器模塊嗎?飞涂?
而每個(gè)存儲(chǔ)器模塊中包含了8個(gè)DRAM芯片旦部,編號(hào)從0 - 7
。
存儲(chǔ)控制器會(huì)將內(nèi)存地址轉(zhuǎn)換為DRAM芯片中supercell在二維矩陣中的坐標(biāo)地址(RAS
较店,CAS
)士八。并將這個(gè)坐標(biāo)地址發(fā)送給對(duì)應(yīng)的存儲(chǔ)器模塊。隨后存儲(chǔ)器模塊會(huì)將RAS
和CAS
廣播到存儲(chǔ)器模塊中的所有DRAM芯片
梁呈。依次通過(guò)(RAS
婚度,CAS
)從DRAM0到DRAM7讀取到相應(yīng)的supercell。
我們知道一個(gè)supercell存儲(chǔ)了8 bit
數(shù)據(jù)官卡,這里我們從DRAM0到DRAM7
依次讀取到了8個(gè)supercell也就是8個(gè)字節(jié)
蝗茁,然后將這8個(gè)字節(jié)返回給存儲(chǔ)控制器醋虏,由存儲(chǔ)控制器將數(shù)據(jù)放到存儲(chǔ)總線上。
CPU總是以word size為單位從內(nèi)存中讀取數(shù)據(jù)哮翘,在64位處理器中的word size為8個(gè)字節(jié)颈嚼。64位的內(nèi)存也只能每次吞吐8個(gè)字節(jié)。
CPU每次會(huì)向內(nèi)存讀寫一個(gè)
cache line
大小的數(shù)據(jù)(64個(gè)字節(jié)
)饭寺,但是內(nèi)存一次只能吞吐8個(gè)字節(jié)
阻课。
所以在內(nèi)存地址對(duì)應(yīng)的存儲(chǔ)器模塊中,DRAM0芯片
存儲(chǔ)第一個(gè)低位字節(jié)(supercell)艰匙,DRAM1芯片
存儲(chǔ)第二個(gè)字節(jié)限煞,......依次類推DRAM7芯片
存儲(chǔ)最后一個(gè)高位字節(jié)。
內(nèi)存一次讀取和寫入的單位是8個(gè)字節(jié)员凝。而且在程序員眼里連續(xù)的內(nèi)存地址實(shí)際上在物理上是不連續(xù)的署驻。因?yàn)檫@連續(xù)的
8個(gè)字節(jié)
其實(shí)是存儲(chǔ)于不同的DRAM芯片
上的。每個(gè)DRAM芯片存儲(chǔ)一個(gè)字節(jié)(supercell)绊序。
5.3.4 CPU向內(nèi)存寫入數(shù)據(jù)過(guò)程
我們現(xiàn)在假設(shè)CPU要將寄存器中的數(shù)據(jù)X寫到內(nèi)存地址A中硕舆。同樣的道理,CPU芯片中的總線接口會(huì)向總線發(fā)起寫事務(wù)(write transaction
)骤公。寫事務(wù)步驟如下:
CPU將要寫入的內(nèi)存地址A放入系統(tǒng)總線上抚官。
通過(guò)
IO bridge
的信號(hào)轉(zhuǎn)換,將內(nèi)存地址A傳遞到存儲(chǔ)總線上阶捆。存儲(chǔ)控制器感受到存儲(chǔ)總線上的地址信號(hào)凌节,將內(nèi)存地址A從存儲(chǔ)總線上讀取出來(lái),并等待數(shù)據(jù)的到達(dá)洒试。
CPU將寄存器中的數(shù)據(jù)拷貝到系統(tǒng)總線上倍奢,通過(guò)
IO bridge
的信號(hào)轉(zhuǎn)換,將數(shù)據(jù)傳遞到存儲(chǔ)總線上垒棋。存儲(chǔ)控制器感受到存儲(chǔ)總線上的數(shù)據(jù)信號(hào)卒煞,將數(shù)據(jù)從存儲(chǔ)總線上讀取出來(lái)。
存儲(chǔ)控制器通過(guò)內(nèi)存地址A定位到具體的存儲(chǔ)器模塊叼架,最后將數(shù)據(jù)寫入存儲(chǔ)器模塊中的8個(gè)DRAM芯片中畔裕。
6. 為什么要內(nèi)存對(duì)齊
我們?cè)诹私饬藘?nèi)存結(jié)構(gòu)以及CPU讀寫內(nèi)存的過(guò)程之后,現(xiàn)在我們回過(guò)頭來(lái)討論下本小節(jié)開(kāi)頭的問(wèn)題:為什么要內(nèi)存對(duì)齊乖订?
下面筆者從三個(gè)方面來(lái)介紹下要進(jìn)行內(nèi)存對(duì)齊的原因:
速度
CPU讀取數(shù)據(jù)的單位是根據(jù)word size
來(lái)的扮饶,在64位處理器中word size = 8字節(jié)
,所以CPU向內(nèi)存讀寫數(shù)據(jù)的單位為8字節(jié)
乍构。
在64位內(nèi)存中甜无,內(nèi)存IO單位為8個(gè)字節(jié)
,我們前邊也提到內(nèi)存結(jié)構(gòu)中的存儲(chǔ)器模塊通常以64位為單位(8個(gè)字節(jié))傳輸數(shù)據(jù)到存儲(chǔ)控制器上或者從存儲(chǔ)控制器傳出數(shù)據(jù)。因?yàn)槊看蝺?nèi)存IO讀取數(shù)據(jù)都是從數(shù)據(jù)所在具體的存儲(chǔ)器模塊中包含的這8個(gè)DRAM芯片中以相同的(RAM
,CAS
)依次讀取一個(gè)字節(jié)岂丘,然后在存儲(chǔ)控制器中聚合成8個(gè)字節(jié)
返回給CPU陵究。
由于存儲(chǔ)器模塊中這種由8個(gè)DRAM芯片組成的物理存儲(chǔ)結(jié)構(gòu)的限制,內(nèi)存讀取數(shù)據(jù)只能是按照地址順序8個(gè)字節(jié)的依次讀取----8個(gè)字節(jié)8個(gè)字節(jié)地來(lái)讀取數(shù)據(jù)元潘。
假設(shè)我們現(xiàn)在讀取
0x0000 - 0x0007
這段連續(xù)內(nèi)存地址上的8個(gè)字節(jié)畔乙。由于內(nèi)存讀取是按照8個(gè)字節(jié)
為單位依次順序讀取的,而我們要讀取的這段內(nèi)存地址的起始地址是0(8的倍數(shù))翩概,所以0x0000 - 0x0007中每個(gè)地址的坐標(biāo)都是相同的(RAS
,CAS
)。所以他可以在8個(gè)DRAM芯片中通過(guò)相同的(RAS
,CAS
)一次性讀取出來(lái)返咱。如果我們現(xiàn)在讀取
0x0008 - 0x0015
這段連續(xù)內(nèi)存上的8個(gè)字節(jié)也是一樣的钥庇,因?yàn)閮?nèi)存段起始地址為8(8的倍數(shù)),所以這段內(nèi)存上的每個(gè)內(nèi)存地址在DREAM芯片中的坐標(biāo)地址(RAS
,CAS
)也是相同的咖摹,我們也可以一次性讀取出來(lái)评姨。
注意:
0x0000 - 0x0007
內(nèi)存段中的坐標(biāo)地址(RAS,CAS)與0x0008 - 0x0015
內(nèi)存段中的坐標(biāo)地址(RAS,CAS)是不相同的。
- 但如果我們現(xiàn)在讀取
0x0007 - 0x0014
這段連續(xù)內(nèi)存上的8個(gè)字節(jié)情況就不一樣了萤晴,由于起始地址0x0007
在DRAM芯片中的(RAS,CAS)與后邊地址0x0008 - 0x0014
的(RAS,CAS)不相同吐句,所以CPU只能先從0x0000 - 0x0007
讀取8個(gè)字節(jié)出來(lái)先放入結(jié)果寄存器
中并左移7個(gè)字節(jié)(目的是只獲取0x0007
),然后CPU在從0x0008 - 0x0015
讀取8個(gè)字節(jié)出來(lái)放入臨時(shí)寄存器中并右移1個(gè)字節(jié)(目的是獲取0x0008 - 0x0014
)最后與結(jié)果寄存器或運(yùn)算
店读。最終得到0x0007 - 0x0014
地址段上的8個(gè)字節(jié)嗦枢。
從以上分析過(guò)程來(lái)看,當(dāng)CPU訪問(wèn)內(nèi)存對(duì)齊的地址時(shí)屯断,比如0x0000
和0x0008
這兩個(gè)起始地址都是對(duì)齊至8的倍數(shù)
文虏。CPU可以通過(guò)一次read transaction讀取出來(lái)。
但是當(dāng)CPU訪問(wèn)內(nèi)存沒(méi)有對(duì)齊的地址時(shí)殖演,比如0x0007
這個(gè)起始地址就沒(méi)有對(duì)齊至8的倍數(shù)
氧秘。CPU就需要兩次read transaction才能將數(shù)據(jù)讀取出來(lái)。
還記得筆者在小節(jié)開(kāi)頭提出的問(wèn)題嗎 趴久?
"Java 虛擬機(jī)堆中對(duì)象的起始地址
為什么需要對(duì)齊至8的倍數(shù)
丸相?為什么不對(duì)齊至4的倍數(shù)或16的倍數(shù)或32的倍數(shù)呢?"
現(xiàn)在你能回答了嗎彼棍?亭饵??
原子性
CPU可以原子地操作一個(gè)對(duì)齊的word size memory萨惑。64位處理器中word size = 8字節(jié)
劈猿。
盡量分配在一個(gè)緩存行中
前邊在介紹false sharding
的時(shí)候我們提到目前主流處理器中的cache line
大小為64字節(jié)
,堆中對(duì)象的起始地址通過(guò)內(nèi)存對(duì)齊至8的倍數(shù)
坎吻,可以讓對(duì)象盡可能的分配到一個(gè)緩存行中缆蝉。一個(gè)內(nèi)存起始地址未對(duì)齊的對(duì)象可能會(huì)跨緩存行存儲(chǔ),這樣會(huì)導(dǎo)致CPU的執(zhí)行效率慢2倍。
其中對(duì)象中字段內(nèi)存對(duì)齊的其中一個(gè)重要原因也是讓字段只出現(xiàn)在同一 CPU 的緩存行中刊头。如果字段不是對(duì)齊的黍瞧,那么就有可能出現(xiàn)跨緩存行的字段。也就是說(shuō)原杂,該字段的讀取可能需要替換兩個(gè)緩存行印颤,而該字段的存儲(chǔ)也會(huì)同時(shí)污染兩個(gè)緩存行。這兩種情況對(duì)程序的執(zhí)行效率而言都是不利的穿肄。
另外在《2. 字段重排列》這一小節(jié)介紹的三種字段對(duì)齊規(guī)則年局,是保證在字段內(nèi)存對(duì)齊的基礎(chǔ)上使得實(shí)例數(shù)據(jù)區(qū)占用內(nèi)存盡可能的小。
7. 壓縮指針
在介紹完關(guān)于內(nèi)存對(duì)齊的相關(guān)內(nèi)容之后咸产,我們來(lái)介紹下前邊經(jīng)常提到的壓縮指針矢否。可以通過(guò)JVM參數(shù)XX:+UseCompressedOops
開(kāi)啟脑溢,當(dāng)然默認(rèn)是開(kāi)啟的僵朗。
在本小節(jié)內(nèi)容開(kāi)啟之前,我們先來(lái)討論一個(gè)問(wèn)題屑彻,那就是為什么要使用壓縮指針验庙??
假設(shè)我們現(xiàn)在正在準(zhǔn)備將32位系統(tǒng)切換到64位系統(tǒng)社牲,起初我們可能會(huì)期望系統(tǒng)性能會(huì)立馬得到提升粪薛,但現(xiàn)實(shí)情況可能并不是這樣的。
在JVM中導(dǎo)致性能下降的最主要原因就是64位系統(tǒng)中的對(duì)象引用
膳沽。在前邊我們也提到過(guò)汗菜,64位系統(tǒng)中對(duì)象的引用以及類型指針占用64 bit
也就是8個(gè)字節(jié)。
這就導(dǎo)致了在64位系統(tǒng)中的對(duì)象引用占用的內(nèi)存空間是32位系統(tǒng)中的兩倍大小挑社,因此間接的導(dǎo)致了在64位系統(tǒng)中更多的內(nèi)存消耗以及更頻繁的GC發(fā)生陨界,GC占用的CPU時(shí)間越多,那么我們的應(yīng)用程序占用CPU的時(shí)間就越少痛阻。
另外一個(gè)就是對(duì)象的引用變大了菌瘪,那么CPU可緩存的對(duì)象相對(duì)就少了,增加了對(duì)內(nèi)存的訪問(wèn)阱当。綜合以上幾點(diǎn)從而導(dǎo)致了系統(tǒng)性能的下降俏扩。
從另一方面來(lái)說(shuō),在64位系統(tǒng)中內(nèi)存的尋址空間為2^48 = 256T
弊添,在現(xiàn)實(shí)情況中我們真的需要這么大的尋址空間嗎录淡??好像也沒(méi)必要吧~~
于是我們就有了新的想法:那么我們是否應(yīng)該切換回32位系統(tǒng)呢油坝?
如果我們切換回32位系統(tǒng)嫉戚,我們?cè)趺唇鉀Q在32位系統(tǒng)中擁有超過(guò)4G
的內(nèi)存尋址空間呢刨裆?因?yàn)楝F(xiàn)在4G的內(nèi)存大小對(duì)于現(xiàn)在的應(yīng)用來(lái)說(shuō)明顯是不夠的。
我想以上的這些問(wèn)題彬檀,也是當(dāng)初JVM的開(kāi)發(fā)者需要面對(duì)和解決的帆啃,當(dāng)然他們也交出了非常完美的答卷,那就是使用壓縮指針可以在64位系統(tǒng)中利用32位的對(duì)象引用獲得超過(guò)4G的內(nèi)存尋址空間窍帝。
7.1 壓縮指針是如何做到的呢努潘?
還記得之前我們?cè)诮榻B對(duì)齊填充和內(nèi)存對(duì)齊小節(jié)中提到的,在Java虛擬機(jī)堆中對(duì)象的起始地址必須對(duì)齊至8的倍數(shù)
嗎坤学?
由于堆中對(duì)象的起始地址均是對(duì)齊至8的倍數(shù)疯坤,所以對(duì)象引用在開(kāi)啟壓縮指針情況下的32位二進(jìn)制的后三位始終是0
(因?yàn)樗鼈兪冀K可以被8整除)。
既然JVM已經(jīng)知道了這些對(duì)象的內(nèi)存地址后三位始終是0拥峦,那么這些無(wú)意義的0就沒(méi)必要在堆中繼續(xù)存儲(chǔ)贴膘。相反,我們可以利用存儲(chǔ)0的這3位bit存儲(chǔ)一些有意義的信息略号,這樣我們就多出3位bit
的尋址空間。
這樣在存儲(chǔ)的時(shí)候洋闽,JVM還是按照32位來(lái)存儲(chǔ)玄柠,只不過(guò)后三位原本用來(lái)存儲(chǔ)0的bit現(xiàn)在被我們用來(lái)存放有意義的地址空間信息。
當(dāng)尋址的時(shí)候诫舅,JVM將這32位的對(duì)象引用左移3位
(后三位補(bǔ)0)羽利。這就導(dǎo)致了在開(kāi)啟壓縮指針的情況下,我們?cè)?2位的內(nèi)存尋址空間一下變成了35位
刊懈≌饣。可尋址的內(nèi)存空間變?yōu)?^32 * 2^3 = 32G。
這樣一來(lái)虚汛,JVM雖然額外的執(zhí)行了一些位運(yùn)算但是極大的提高了尋址空間匾浪,并且將對(duì)象引用占用內(nèi)存大小降低了一半,節(jié)省了大量空間卷哩。況且這些位運(yùn)算對(duì)于CPU來(lái)說(shuō)是非常容易且輕量的操作
通過(guò)壓縮指針的原理我挖掘到了內(nèi)存對(duì)齊的另一個(gè)重要原因就是通過(guò)內(nèi)存對(duì)齊至8的倍數(shù)
蛋辈,我們可以在64位系統(tǒng)中使用壓縮指針通過(guò)32位的對(duì)象引用將尋址空間提升至32G
.
從Java7開(kāi)始,當(dāng)maximum heap size小于32G的時(shí)候将谊,壓縮指針是默認(rèn)開(kāi)啟的冷溶。但是當(dāng)maximum heap size大于32G的時(shí)候,壓縮指針就會(huì)關(guān)閉尊浓。
那么我們?nèi)绾卧趬嚎s指針開(kāi)啟的情況下進(jìn)一步擴(kuò)大尋址空間呢逞频??栋齿?
7.2 如何進(jìn)一步擴(kuò)大尋址空間
前邊提到我們?cè)贘ava虛擬機(jī)堆中對(duì)象起始地址均需要對(duì)其至8的倍數(shù)
苗胀,不過(guò)這個(gè)數(shù)值我們可以通過(guò)JVM參數(shù)-XX:ObjectAlignmentInBytes
來(lái)改變(默認(rèn)值為8)襟诸。當(dāng)然這個(gè)數(shù)值的必須是2的次冪,數(shù)值范圍需要在8 - 256之間
柒巫。
正是因?yàn)閷?duì)象地址對(duì)齊至8的倍數(shù)励堡,才會(huì)多出3位bit讓我們存儲(chǔ)額外的地址信息,進(jìn)而將4G的尋址空間提升至32G堡掏。
同樣的道理应结,如果我們將ObjectAlignmentInBytes
的數(shù)值設(shè)置為16呢?
對(duì)象地址均對(duì)齊至16的倍數(shù)泉唁,那么就會(huì)多出4位bit讓我們存儲(chǔ)額外的地址信息鹅龄。尋址空間變?yōu)?^32 * 2^4 = 64G。
通過(guò)以上規(guī)律亭畜,我們就能知道扮休,在64位系統(tǒng)中開(kāi)啟壓縮指針的情況,尋址范圍的計(jì)算公式:4G * ObjectAlignmentInBytes = 尋址范圍
拴鸵。
但是筆者并不建議大家貿(mào)然這樣做玷坠,因?yàn)樵龃罅?code>ObjectAlignmentInBytes雖然能擴(kuò)大尋址范圍,但是這同時(shí)也可能增加了對(duì)象之間的字節(jié)填充劲藐,導(dǎo)致壓縮指針沒(méi)有達(dá)到原本節(jié)省空間的效果八堡。
8. 數(shù)組對(duì)象的內(nèi)存布局
前邊大量的篇幅我們都是在討論Java普通對(duì)象在內(nèi)存中的布局情況,最后這一小節(jié)我們?cè)賮?lái)說(shuō)下Java中的數(shù)組對(duì)象在內(nèi)存中是如何布局的聘芜。
8.1 基本類型數(shù)組的內(nèi)存布局
上圖表示的是基本類型數(shù)組在內(nèi)存中的布局兄渺,基本類型數(shù)組在JVM中用typeArrayOop
結(jié)構(gòu)體表示,基本類型數(shù)組類型元信息用TypeArrayKlass
結(jié)構(gòu)體表示汰现。
數(shù)組的內(nèi)存布局大體上和普通對(duì)象的內(nèi)存布局差不多挂谍,唯一不同的是在數(shù)組類型對(duì)象頭中多出了4個(gè)字節(jié)
用來(lái)表示數(shù)組長(zhǎng)度的部分。
我們還是分別以開(kāi)啟指針壓縮和關(guān)閉指針壓縮兩種情況瞎饲,通過(guò)下面的例子來(lái)進(jìn)行說(shuō)明:
long[] longArrayLayout = new long[1];
開(kāi)啟指針壓縮 -XX:+UseCompressedOops
我們看到紅框部分即為數(shù)組類型對(duì)象頭中多出來(lái)一個(gè)4字節(jié)
大小用來(lái)表示數(shù)組長(zhǎng)度的部分口叙。
因?yàn)槲覀兪纠械膌ong型數(shù)組只有一個(gè)元素,所以實(shí)例數(shù)據(jù)區(qū)的大小只有8字節(jié)企软。如果我們示例中的long型數(shù)組變?yōu)閮蓚€(gè)元素庐扫,那么實(shí)例數(shù)據(jù)區(qū)的大小就會(huì)變?yōu)?6字節(jié),以此類推................仗哨。
關(guān)閉指針壓縮 -XX:-UseCompressedOops
當(dāng)關(guān)閉了指針壓縮時(shí)形庭,對(duì)象頭中的MarkWord還是占用8個(gè)字節(jié),但是類型指針從4個(gè)字節(jié)變?yōu)榱?個(gè)字節(jié)厌漂。數(shù)組長(zhǎng)度屬性還是不變保持4個(gè)字節(jié)萨醒。
這里我們發(fā)現(xiàn)是實(shí)例數(shù)據(jù)區(qū)與對(duì)象頭之間發(fā)生了對(duì)齊填充。大家還記得這是為什么嗎苇倡?富纸?
我們前邊在字段重排列小節(jié)介紹了三種字段排列規(guī)則在這里繼續(xù)適用:
規(guī)則1
:如果一個(gè)字段占用X
個(gè)字節(jié)囤踩,那么這個(gè)字段的偏移量OFFSET需要對(duì)齊至NX
。規(guī)則2
:在開(kāi)啟了壓縮指針的64位JVM中晓褪,Java類中的第一個(gè)字段的OFFSET需要對(duì)齊至4N
堵漱,在關(guān)閉壓縮指針的情況下類中第一個(gè)字段的OFFSET需要對(duì)齊至8N
。
這里基本數(shù)組類型的實(shí)例數(shù)據(jù)區(qū)中是long型涣仿,在關(guān)閉指針壓縮的情況下勤庐,根據(jù)規(guī)則1和規(guī)則2需要對(duì)齊至8的倍數(shù),所以要在其與對(duì)象頭之間填充4個(gè)字節(jié)好港,達(dá)到內(nèi)存對(duì)齊的目的愉镰,起始地址變?yōu)?code>24。
8.2 引用類型數(shù)組的內(nèi)存布局
上圖表示的是引用類型數(shù)組在內(nèi)存中的布局钧汹,引用類型數(shù)組在JVM中用objArrayOop
結(jié)構(gòu)體表示丈探,基本類型數(shù)組類型元信息用ObjArrayKlass
結(jié)構(gòu)體表示。
同樣在引用類型數(shù)組的對(duì)象頭中也會(huì)有一個(gè)4字節(jié)
大小用來(lái)表示數(shù)組長(zhǎng)度的部分拔莱。
我們還是分別以開(kāi)啟指針壓縮和關(guān)閉指針壓縮兩種情況碗降,通過(guò)下面的例子來(lái)進(jìn)行說(shuō)明:
public class ReferenceArrayLayout {
char a;
int b;
short c;
}
ReferenceArrayLayout[] referenceArrayLayout = new ReferenceArrayLayout[1];
開(kāi)啟指針壓縮 -XX:+UseCompressedOops
引用數(shù)組類型內(nèi)存布局與基礎(chǔ)數(shù)組類型內(nèi)存布局最大的不同在于它們的實(shí)例數(shù)據(jù)區(qū)。由于開(kāi)啟了壓縮指針塘秦,所以對(duì)象引用占用內(nèi)存大小為4個(gè)字節(jié)
遗锣,而我們示例中引用數(shù)組只包含一個(gè)引用元素,所以這里實(shí)例數(shù)據(jù)區(qū)中只有4個(gè)字節(jié)嗤形。相同的到道理,如果示例中的引用數(shù)組包含的元素變?yōu)閮蓚€(gè)引用元素弧圆,那么實(shí)例數(shù)據(jù)區(qū)就會(huì)變?yōu)?個(gè)字節(jié)赋兵,以此類推......。
最后由于Java對(duì)象需要內(nèi)存對(duì)齊至8的倍數(shù)
搔预,所以在該引用數(shù)組的實(shí)例數(shù)據(jù)區(qū)后填充了4個(gè)字節(jié)霹期。
關(guān)閉指針壓縮 -XX:-UseCompressedOops
當(dāng)關(guān)閉壓縮指針時(shí),對(duì)象引用占用內(nèi)存大小變?yōu)榱?code>8個(gè)字節(jié)拯田,所以引用數(shù)組類型的實(shí)例數(shù)據(jù)區(qū)占用了8個(gè)字節(jié)历造。
根據(jù)字段重排列規(guī)則2,在引用數(shù)組類型對(duì)象頭與實(shí)例數(shù)據(jù)區(qū)中間需要填充4個(gè)字節(jié)
以保證內(nèi)存對(duì)齊的目的船庇。
總結(jié)
本文筆者詳細(xì)介紹了Java普通對(duì)象以及數(shù)組類型對(duì)象的內(nèi)存布局吭产,以及相關(guān)對(duì)象占用內(nèi)存大小的計(jì)算方法。
以及在對(duì)象內(nèi)存布局中的實(shí)例數(shù)據(jù)區(qū)字段重排列的三個(gè)重要規(guī)則鸭轮。以及后邊由字節(jié)的對(duì)齊填充引出來(lái)的false sharding問(wèn)題臣淤,還有Java8為了解決false sharding而引入的@Contented注解的原理及使用方式。
為了講清楚內(nèi)存對(duì)齊的底層原理窃爷,筆者還花了大量的篇幅講解了內(nèi)存的物理結(jié)構(gòu)以及CPU讀寫內(nèi)存的完整過(guò)程邑蒋。
最后又由內(nèi)存對(duì)齊引出了壓縮指針的工作原理姓蜂。由此我們知道進(jìn)行內(nèi)存對(duì)齊的四個(gè)原因:
CPU訪問(wèn)性能
:當(dāng)CPU訪問(wèn)內(nèi)存對(duì)齊的地址時(shí),可以通過(guò)一個(gè)read transaction讀取一個(gè)字長(zhǎng)(word size)大小的數(shù)據(jù)出來(lái)医吊。否則就需要兩個(gè)read transaction钱慢。原子性
: CPU可以原子地操作一個(gè)對(duì)齊的word size memory。盡可能利用CPU緩存
:內(nèi)存對(duì)齊可以使對(duì)象或者字段盡可能的被分配到一個(gè)緩存行中卿堂,避免跨緩存行存儲(chǔ)束莫,導(dǎo)致CPU執(zhí)行效率減半。提升壓縮指針的內(nèi)存尋址空間:
對(duì)象與對(duì)象之間的內(nèi)存對(duì)齊御吞,可以使我們?cè)?4位系統(tǒng)中利用32位對(duì)象引用將內(nèi)存尋址空間提升至32G麦箍。既降低了對(duì)象引用的內(nèi)存占用,又提升了內(nèi)存尋址空間陶珠。
在本文中我們順帶還介紹了和內(nèi)存布局相關(guān)的幾個(gè)JVM參數(shù):-XX:+UseCompressedOops,
-XX +CompactFields ,
-XX:-RestrictContended ,
-XX:ContendedPaddingWidth,
-XX:ObjectAlignmentInBytes挟裂。
最后感謝大家能看到這里,我們下篇文章再見(jiàn)~~~