深入理解Java内存架构
hi,大家周末好,今天給大家?guī)硪黄埠说募夹g(shù)文章,本文我們將從計(jì)算機(jī)組成原理的角度詳細(xì)闡述對(duì)象在JVM內(nèi)存中是如何布局的,以及什么是內(nèi)存對(duì)齊,如果我們頭比較鐵,就是不進(jìn)行內(nèi)存對(duì)齊會(huì)造成什么樣的后果,最后引出壓縮指針的原理和應(yīng)用。同時(shí)我們還介紹了在高并發(fā)場景下,False Sharing產(chǎn)生的原因以及帶來的性能影響。
相信大家看完本文后,一定會(huì)收獲很多,話不多說,下面我們正式開始本文的內(nèi)容~~
本文概要.png在我們的日常工作中,有時(shí)候我們?yōu)榱朔乐咕€上應(yīng)用發(fā)生OOM,所以我們需要在開發(fā)的過程中計(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)存占用量超過高水位線,那么就有可能有發(fā)生OOM的風(fēng)險(xiǎn)。
我們可以在程序中根據(jù)估算出的高低水位線,做一些防止OOM的處理邏輯或者發(fā)出告警。
那么核心問題是如何計(jì)算一個(gè)Java對(duì)象在內(nèi)存中的占用大小呢??
在為大家解答這個(gè)問題之前,筆者先來介紹下Java對(duì)象在內(nèi)存中的布局,也就是本文的主題。
1. Java對(duì)象的內(nèi)存布局
Java對(duì)象的內(nèi)存布局.png如圖所示,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中的類型指針封裝在klassOopDesc 結(jié)構(gòu)中,類型指針指向了InstanceKclass對(duì)象,Java類在JVM中是用InstanceKclass對(duì)象封裝的,里邊包含了Java類的元信息,比如:繼承結(jié)構(gòu),方法,靜態(tài)變量,構(gòu)造函數(shù)等。
在不開啟指針壓縮的情況下(-XX:-UseCompressedOops)。在32位操作系統(tǒng)和64位操作系統(tǒng)中類型指針分別占用4B和8B大小的內(nèi)存。
在開啟指針壓縮的情況下(-XX:+UseCompressedOops)。在32位操作系統(tǒng)和64位操作系統(tǒng)中類型指針分別占用4B和4B大小的內(nèi)存。
如果Java對(duì)象是一個(gè)數(shù)組類型的話,那么在數(shù)組對(duì)象的對(duì)象頭中還會(huì)包含一個(gè)4B大小的用于記錄數(shù)組長度的屬性。
由于在對(duì)象頭中用于記錄數(shù)組長度大小的屬性只占4B的內(nèi)存,所以Java數(shù)組可以申請(qǐng)的最大長度為:2^32。
1.2 實(shí)例數(shù)據(jù)(Instance Data)
Java對(duì)象在內(nèi)存中的實(shí)例數(shù)據(jù)區(qū)用來存儲(chǔ)Java類中定義的實(shí)例字段,包括所有父類中的實(shí)例字段。也就是說,雖然子類無法訪問父類的私有實(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)存占用分為兩種情況:
不開啟指針壓縮(-XX:-UseCompressedOops):在32位操作系統(tǒng)中引用類型的內(nèi)存占用為4個(gè)字節(jié)。在64位操作系統(tǒng)中引用類型的內(nèi)存占用為8個(gè)字節(jié)。
開啟指針壓縮(-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é)來記錄內(nèi)存地址,能夠記錄的虛擬地址空間是2^32大小,也就是只能夠表示4G大小的內(nèi)存。
而在64位操作系統(tǒng)中內(nèi)存地址是由64個(gè)bit表示,因此需要8個(gè)字節(jié)來記錄內(nèi)存地址,但在 64 位系統(tǒng)里只使用了低 48 位,所以它的虛擬地址空間是 2^48大小,能夠表示256T大小的內(nèi)存,其中低 128T 的空間劃分為用戶空間,高 128T 劃分為內(nèi)核空間,可以說是非常大了。
在我們從整體上介紹完Java對(duì)象在JVM中的內(nè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é)中,筆者先來為大家介紹一下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é)。
在開啟了壓縮指針的64位JVM中,Java類中的第一個(gè)字段的OFFSET需要對(duì)齊至4N,在關(guān)閉壓縮指針的情況下類中第一個(gè)字段的OFFSET需要對(duì)齊至8N。
JVM默認(rèn)分配字段的順序?yàn)?#xff1a;long / 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)記為過期了,并在將來的版本中很可能被刪除。詳細(xì)細(xì)節(jié)可查看issue:https://bugs.openjdk.java.net/browse/JDK-8228750
上邊的三條字段重排列規(guī)則非常非常重要,但是讀起來比較繞腦,很抽象不容易理解,筆者把它們先列出來的目的是為了讓大家先有一個(gè)朦朦朧朧的感性認(rèn)識(shí),下面筆者舉一個(gè)具體的例子來為大家詳細(xì)說明下,在閱讀這個(gè)例子的過程中也方便大家深刻的理解這三條重要的字段重排列規(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開啟了-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我們知道如果開啟壓縮指針-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ī)則來看下字段排列順序在這四種情況下的表現(xiàn)。
2.1 -XX:+UseCompressedOops ?-XX -CompactFields 開啟壓縮指針,關(guān)閉字段壓縮
image.png偏移量OFFSET = 8的位置存放的是類型指針,由于開啟了壓縮指針?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:在開啟壓縮指針的情況下,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ù)無法對(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ì)象大小由開始的44字節(jié)被填充到48字節(jié)。
2.2 ?-XX:+UseCompressedOops ?-XX +CompactFields 開啟壓縮指針,開啟字段壓縮
image.png在第一種情況的分析基礎(chǔ)上,我們開啟了-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é)。
這里我們可以看到在開啟字段壓縮-XX +CompactFields的情況下,Child對(duì)象的大小由48字節(jié)變成了40字節(jié)。
2.3 -XX:-UseCompressedOops ?-XX -CompactFields 關(guān)閉壓縮指針,關(guān)閉字段壓縮
image.png首先在關(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ì)算出來的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)閉壓縮指針,開啟字段壓縮
在第三種情況的分析基礎(chǔ)上,我們來看下第四種情況的字段排列情況:
image.png由于在關(guān)閉指針壓縮的情況下類型指針的大小變?yōu)榱?個(gè)字節(jié),所以導(dǎo)致Child對(duì)象中第一個(gè)字段Parent.l前邊并沒有空隙,剛好對(duì)齊8N,并不需要int型變量的插入。所以即使開啟了字段壓縮-XX +CompactFields,字段的總體排列順序還是不變的。
默認(rèn)情況下指針壓縮-XX:+UseCompressedOops以及字段壓縮-XX +CompactFields都是開啟的
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。
image.png而以上提到的四種情況都會(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。也就是說對(duì)象與對(duì)象之間的內(nèi)存地址需要對(duì)齊至多少倍,是由這個(gè)JVM參數(shù)控制的。
我們還是以上邊第一種情況為例說明:圖中對(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ì)齊填充,是要解決什么問題嗎?
那么就讓我們帶著這個(gè)問題,來接著聽筆者往下聊~~
4.1 解決偽共享問題帶來的對(duì)齊填充
除了以上介紹的兩種對(duì)齊填充的場景(字段與字段之間,對(duì)象與對(duì)象之間),在JAVA中還有一種對(duì)齊填充的場景,那就是通過對(duì)齊填充的方式來解決False Sharing(偽共享)的問題。
在介紹False Sharing(偽共享)之前,筆者先來介紹下CPU讀取內(nèi)存中數(shù)據(jù)的方式。
4.1.1 CPU緩存
根據(jù)摩爾定律:芯片中的晶體管數(shù)量每隔18個(gè)月就會(huì)翻一番。導(dǎo)致CPU的性能和處理速度變得越來越快,而提升CPU的運(yùn)行速度比提升內(nèi)存的運(yùn)行速度要容易和便宜的多,所以就導(dǎo)致了CPU與內(nèi)存之間的速度差距越來越大。
為了彌補(bǔ)CPU與內(nèi)存之間巨大的速度差異,提高CPU的處理效率和吞吐,于是人們引入了L1,L2,L3高速緩存集成到CPU中。當(dāng)然還有L0也就是寄存器,寄存器離CPU最近,訪問速度也最快,基本沒有時(shí)延。
CPU緩存結(jié)構(gòu).png一個(gè)CPU里面包含多個(gè)核心,我們?cè)谫徺I電腦的時(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è)線程在訪問內(nèi)存的空隙,超線程芯片可以馬上切換去執(zhí)行另外一個(gè)線程。因?yàn)榍袚Q速度非常快,所以在效果上看到是8個(gè)線程在同時(shí)執(zhí)行。
圖中的CPU核心指的是物理核心。
從圖中我們可以看到L1Cache是離CPU核心最近的高速緩存,緊接著就是L2Cache,L3Cache,內(nèi)存。
離CPU核心越近的緩存訪問速度也越快,造價(jià)也就越高,當(dāng)然容量也就越小。
其中L1Cache和L2Cache是CPU物理核心私有的(注意:這里是物理核心不是邏輯核心)
而L3Cache是整個(gè)CPU所有物理核心共享的。
CPU邏輯核心共享其所屬物理核心的L1Cache和L2Cache
L1Cache
L1Cache離CPU是最近的,它的訪問速度最快,容量也最小。
從圖中我們看到L1Cache分為兩個(gè)部分,分別是:Data Cache和Instruction Cache。它們一個(gè)是存儲(chǔ)數(shù)據(jù)的,一個(gè)是存儲(chǔ)代碼指令的。
我們可以通過cd /sys/devices/system/cpu/來查看linux機(jī)器上的CPU信息。
image.png在/sys/devices/system/cpu/目錄里,我們可以看到CPU的核心數(shù),當(dāng)然這里指的是邏輯核心。
筆者機(jī)器上的處理器并沒有使用超線程技術(shù)所以這里其實(shí)是4個(gè)物理核心。
下面我們進(jìn)入其中一顆CPU核心(cpu0)中去看下L1Cache的情況:
CPU緩存的情況在/sys/devices/system/cpu/cpu0/cache目錄下查看:
image.pngindex0描述的是L1Cache中DataCache的情況:
image.pnglevel:表示該cache信息屬于哪一級(jí),1表示L1Cache。
type:表示屬于L1Cache的DataCache。
size:表示DataCache的大小為32K。
shared_cpu_list:之前我們提到L1Cache和L2Cache是CPU物理核所私有的,而由物理核模擬出來的邏輯核是共享L1Cache和L2Cache的,/sys/devices/system/cpu/目錄下描述的信息是邏輯核。shared_cpu_list描述的正是哪些邏輯核共享這個(gè)物理核。
index1描述的是L1Cache中Instruction Cache的情況:
image.png我們看到L1Cache中的Instruction Cache大小也是32K。
L2Cache
L2Cache的信息存儲(chǔ)在index2目錄下:
image.pngL2Cache的大小為256K,比L1Cache要大些。
L3Cache
L3Cache的信息存儲(chǔ)在index3目錄下:
image.png到這里我們可以看到L1Cache中的DataCache和InstructionCache大小一樣都是32K而L2Cache的大小為256K,L3Cache的大小為6M。
當(dāng)然這些數(shù)值在不同的CPU配置上會(huì)是不同的,但是總體上來說L1Cache的量級(jí)是幾十KB,L2Cache的量級(jí)是幾百KB,L3Cache的量級(jí)是幾MB。
4.1.2 CPU緩存行
前邊我們介紹了CPU的高速緩存結(jié)構(gòu),引入高速緩存的目的在于消除CPU與內(nèi)存之間的速度差距,根據(jù)程序的局部性原理我們知道,CPU的高速緩存肯定是用來存放熱點(diǎn)數(shù)據(jù)的。
程序局部性原理表現(xiàn)為:時(shí)間局部性和空間局部性。時(shí)間局部性是指如果程序中的某條指令一旦執(zhí)行,則不久之后該指令可能再次被執(zhí)行;如果某塊數(shù)據(jù)被訪問,則不久之后該數(shù)據(jù)可能再次被訪問。空間局部性是指一旦程序訪問了某個(gè)存儲(chǔ)單元,則不久之后,其附近的存儲(chǔ)單元也將被訪問。
那么在高速緩存中存取數(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é))。
image.png從圖中我們可以看到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)來。同樣的道理,CPU從高速緩存中同步數(shù)據(jù)到內(nèi)存也是按照64字節(jié)的單位來進(jìn)行。
比如你訪問一個(gè)long型數(shù)組,當(dāng)CPU去加載數(shù)組中第一個(gè)元素時(shí)也會(huì)同時(shí)將后邊的7個(gè)元素一起加載進(jìn)緩存中。這樣一來就加快了遍歷數(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)存中不是彼此相鄰的(比如:鏈表),這樣就無法利用CPU緩存的優(yōu)勢(shì)。由于數(shù)據(jù)在內(nèi)存中不是連續(xù)存放的,所以在這些數(shù)據(jù)結(jié)構(gòu)中的每一個(gè)項(xiàng)都可能會(huì)出現(xiàn)緩存行未命中(程序局部性原理)的情況。
還記得我們?cè)?《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ì)來提高IO活躍的SelectionKeys集合的遍歷性能。
4.2 False Sharing(偽共享)
我們先來看一個(gè)這樣的例子,筆者定義了一個(gè)示例類FalseSharding,類中有兩個(gè)long型的volatile字段a,b。
public?class?FalseSharding?{volatile?long?a;volatile?long?b;}字段a,b之間邏輯上是獨(dú)立的,它們之間一點(diǎn)關(guān)系也沒有,分別用來存儲(chǔ)不同的數(shù)據(jù),數(shù)據(jù)之間也沒有關(guān)聯(lián)。
FalseSharding類中字段之間的內(nèi)存布局如下:
image.pngFalseSharding類中的字段a,b在內(nèi)存中是相鄰存儲(chǔ),分別占用8個(gè)字節(jié)。
如果恰好字段a,b被CPU讀進(jìn)了同一個(gè)緩存行,而此時(shí)有兩個(gè)線程,線程a用來修改字段a,同時(shí)線程b用來讀取字段b。
falsesharding1.png在這種場景下,會(huì)對(duì)線程b的讀取操作造成什么影響呢?
我們知道聲明了volatile關(guān)鍵字的變量可以在多線程處理環(huán)境下,確保內(nèi)存的可見性。計(jì)算機(jī)硬件層會(huì)保證對(duì)被volatile關(guān)鍵字修飾的共享變量進(jìn)行寫操作后的內(nèi)存可見性,而這種內(nèi)存可見性是由Lock前綴指令以及緩存一致性協(xié)議(MESI控制協(xié)議)共同保證的。
Lock前綴指令可以使修改線程所在的處理器中的相應(yīng)緩存行數(shù)據(jù)被修改后立馬刷新回內(nèi)存中,并同時(shí)鎖定所有處理器核心中緩存了該修改變量的緩存行,防止多個(gè)處理器核心并發(fā)修改同一緩存行。
緩存一致性協(xié)議主要是用來維護(hù)多個(gè)處理器核心之間的CPU緩存一致性以及與內(nèi)存數(shù)據(jù)的一致性。每個(gè)處理器會(huì)在總線上嗅探其他處理器準(zhǔn)備寫入的內(nèi)存地址,如果這個(gè)內(nèi)存地址在自己的處理器中被緩存的話,就會(huì)將自己處理器中對(duì)應(yīng)的緩存行置為無效,下次需要讀取的該緩存行中的數(shù)據(jù)的時(shí)候,就需要訪問內(nèi)存獲取。
基于以上volatile關(guān)鍵字原則,我們首先來看第一種影響:
falsesharding2.png當(dāng)線程a在處理器core0中對(duì)字段a進(jìn)行修改時(shí),Lock前綴指令會(huì)將所有處理器中緩存了字段a的對(duì)應(yīng)緩存行進(jìn)行鎖定,這樣就會(huì)導(dǎo)致線程b在處理器core1中無法讀取和修改自己緩存行的字段b。
處理器core0將修改后的字段a所在的緩存行刷新回內(nèi)存中。
從圖中我們可以看到此時(shí)字段a的值在處理器core0的緩存行中以及在內(nèi)存中已經(jīng)發(fā)生變化了。但是處理器core1中字段a的值還沒有變化,并且core1中字段a所在的緩存行處于鎖定狀態(tài),無法讀取也無法寫入字段b。
從上述過程中我們可以看出即使字段a,b之間邏輯上是獨(dú)立的,它們之間一點(diǎn)關(guān)系也沒有,但是線程a對(duì)字段a的修改,導(dǎo)致了線程b無法讀取字段b。
第二種影響:
faslesharding3.png當(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沒有發(fā)生任何變化。
從以上兩種影響我們看到字段a與字段b實(shí)際上并不存在共享,它們之間也沒有相互關(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無法讀取字段b。
線程a所在處理器core0將字段a所在緩存行同步刷新回內(nèi)存后,導(dǎo)致字段b在core1中的緩存行被置為失效(緩存一致性協(xié)議),進(jìn)而導(dǎo)致線程b需要重新回到內(nèi)存讀取字段b的值無法利用CPU緩存的優(yōu)勢(shì)。
由于字段a和字段b在同一個(gè)緩存行中,導(dǎo)致了字段a和字段b事實(shí)上的共享(原本是不應(yīng)該被共享的)。這種現(xiàn)象就叫做False Sharing(偽共享)。
在高并發(fā)的場景下,這種偽共享的問題,會(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é)填充就派上用場了~~
在Java8之前我們通常會(huì)在字段a和字段b前后分別填充7個(gè)long型變量(緩存行大小64字節(jié)),目的是讓字段a和字段b各自獨(dú)占一個(gè)緩存行避免False Sharing。
比如我們將一開始的實(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)存中布局如下:
image.png我們看到為了解決False Sharing問題,我們將原本占用32字節(jié)的FalseSharding示例對(duì)象硬生生的填充到了200字節(jié)。這對(duì)內(nèi)存的消耗是非常可觀的。通常為了極致的性能,我們會(huì)在一些高并發(fā)框架或者JDK的源碼中看到False Sharing的解決場景。因?yàn)樵诟卟l(fā)場景中,任何微小的性能損失比如False Sharing,都會(huì)被無限放大。
但解決False Sharing的同時(shí)又會(huì)帶來巨大的內(nèi)存消耗,所以即使在高并發(fā)框架比如disrupter或者JDK中也只是針對(duì)那些在多線程場景下被頻繁寫入的共享變量。
這里筆者想強(qiáng)調(diào)的是在我們?nèi)粘9ぷ髦?/strong>,我們不能因?yàn)樽约菏掷锬弥N子,就滿眼都是釘子,看到任何釘子都想上去錘兩下。
image.png我們要清晰的分辨出一個(gè)問題會(huì)帶來哪些影響和損失,這些影響和損失在我們當(dāng)前業(yè)務(wù)階段是否可以接受?是否是瓶頸?同時(shí)我們也要清晰的了解要解決這些問題我們所要付出的代價(jià)。一定要綜合評(píng)估,講究一個(gè)投入產(chǎn)出比。某些問題雖然是問題,但是在某些階段和場景下并不需要我們投入解決。而有些問題則對(duì)于我們當(dāng)前業(yè)務(wù)發(fā)展階段是瓶頸,我們不得不去解決。我們?cè)诩軜?gòu)設(shè)計(jì)或者程序設(shè)計(jì)中,方案一定要簡單,合適。并預(yù)估一些提前量留有一定的演化空間。
4.3.1 @Contended注解
在Java8中引入了一個(gè)新注解@Contended,用于解決False Sharing的問題,同時(shí)這個(gè)注解也會(huì)影響到Java對(duì)象中的字段排列。
在上一小節(jié)的內(nèi)容介紹中,我們通過手段填充字段的方式解決了False Sharing的問題,但是這里也有一個(gè)問題,因?yàn)槲覀冊(cè)谑謩?dòng)填充字段的時(shí)候還需要考慮CPU緩存行的大小,因?yàn)殡m然現(xiàn)在所有主流的處理器緩存行大小均為64字節(jié),但是也還是有處理器的緩存行大小為32字節(jié),有的甚至是128字節(jié)。我們需要考慮很多硬件的限制因素。
Java8中通過引入@Contended注解幫我們解決了這個(gè)問題,我們不在需要去手動(dòng)填充字段了。下面我們就來看下@Contended注解是如何幫助我們來解決這個(gè)問題的~~
上小節(jié)介紹的手動(dòng)填充字節(jié)是在共享變量前后填充64字節(jié)大小的空間,這樣只能確保程序在緩存行大小為32字節(jié)或者64字節(jié)的CPU下獨(dú)占緩存行。但是如果CPU的緩存行大小為128字節(jié),這樣依然存在False Sharing的問題。
引入@Contended注解可以使我們忽略底層硬件設(shè)備的差異性,做到Java語言的初衷:平臺(tái)無關(guān)性。
@Contended注解默認(rèn)只是在JDK內(nèi)部起作用,如果我們的程序代碼中需要使用到@Contended注解,那么需要開啟JVM參數(shù)-XX:-RestrictContended才會(huì)生效。
@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.FIELD,?ElementType.TYPE}) public?@interface?Contended?{//contention?group?tagString?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ù)存放的,可以允許共享緩存行。不同分組之間不允許共享緩存行。
下面我們來分別看下@Contended注解在這三種使用場景下是怎樣影響字段之間的排列的。
@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)存布局:
image.png如圖中所示,FalseSharding示例類被標(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)了問題,我們之前不是提到緩存行的大小為64字節(jié)嗎?為什么這里會(huì)填充128字節(jié)呢?
而且之前介紹的手動(dòng)填充也是填充的64字節(jié),為什么@Contended注解會(huì)采用兩倍的緩存行大小來填充呢?
其實(shí)這里的原因有兩個(gè):
首先第一個(gè)原因,我們之前也已經(jīng)提到過了,目前大部分主流的CPU緩存行是64字節(jié),但是也有部分CPU緩存行是32字節(jié)或者128字節(jié),如果只填充64字節(jié)的話,在緩存行大小為32字節(jié)和64字節(jié)的CPU中是可以做到獨(dú)占緩存行從而避免FalseSharding的,但在緩存行大小為128字節(jié)的CPU中還是會(huì)出現(xiàn)FalseSharding問題,這里Java采用了悲觀的一種做法,默認(rèn)都是填充128字節(jié),雖然對(duì)于大部分情況下比較浪費(fèi),但是屏蔽了底層硬件的差異。
不過@Contended注解填充字節(jié)的大小我們可以通過JVM參數(shù)-XX:ContendedPaddingWidth指定,有效值范圍0 - 8192,默認(rèn)為128。
第二個(gè)原因其實(shí)是最為核心的一個(gè)原因,主要是為了防止CPU Adjacent Sector Prefetch(CPU相鄰扇區(qū)預(yù)取)特性所帶來的FalseSharding問題。
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é))來避免CPU Adjacent Sector Prefetch所帶來的的FalseSharding問題。
@Contended標(biāo)注在字段上
public?class?FalseSharding?{@Contendedvolatile?long?a;@Contendedvolatile?long?b;volatile?int?c;volatile?long?d; }image.png這次我們將 @Contended注解標(biāo)注在了FalseSharding示例類中的字段a和字段b上,這樣帶來的效果是字段a和字段b各自獨(dú)占緩存行。從內(nèi)存布局上看,字段a和字段b前后分別被填充了128個(gè)字節(jié),來確保字段a和字段b不與任何數(shù)據(jù)共享緩存行。
而沒有被@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; }image.png這次我們將字段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é),來保證分組之間的變量不能共享緩存行。
5. 內(nèi)存對(duì)齊
通過以上內(nèi)容我們了解到Java對(duì)象中的實(shí)例數(shù)據(jù)區(qū)字段需要進(jìn)行內(nèi)存對(duì)齊而導(dǎo)致在JVM中會(huì)被重排列以及通過填充緩存行避免false sharding的目的所帶來的字節(jié)對(duì)齊填充。
我們也了解到內(nèi)存對(duì)齊不僅發(fā)生在對(duì)象與對(duì)象之間,也發(fā)生在對(duì)象中的字段之間。
那么在本小節(jié)中筆者將為大家介紹什么是內(nèi)存對(duì)齊,在本節(jié)的內(nèi)容開始之前筆者先來拋出兩個(gè)問題:
為什么要進(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è)問題,下面我們正式開始本節(jié)的內(nèi)容~~~
5.1 內(nèi)存結(jié)構(gòu)
我們平時(shí)所稱的內(nèi)存也叫隨機(jī)訪問存儲(chǔ)器(random-access memory)也叫RAM。而RAM分為兩類:
一類是靜態(tài)RAM(SRAM),這類SRAM用于前邊介紹的CPU高速緩存L1Cache,L2Cache,L3Cache。其特點(diǎn)是訪問速度快,訪問速度為1 - 30個(gè)時(shí)鐘周期,但是容量小,造價(jià)高。
另一類則是動(dòng)態(tài)RAM(DRAM),這類DRAM用于我們常說的主存上,其特點(diǎn)的是訪問速度慢(相對(duì)高速緩存),訪問速度為50 - 200個(gè)時(shí)鐘周期,但是容量大,造價(jià)便宜些(相對(duì)高速緩存)。
內(nèi)存由一個(gè)一個(gè)的存儲(chǔ)器模塊(memory module)組成,它們插在主板的擴(kuò)展槽上。常見的存儲(chǔ)器模塊通常以64位為單位(8個(gè)字節(jié))傳輸數(shù)據(jù)到存儲(chǔ)控制器上或者從存儲(chǔ)控制器傳出數(shù)據(jù)。
image.png如圖所示內(nèi)存條上黑色的元器件就是存儲(chǔ)器模塊(memory module)。多個(gè)存儲(chǔ)器模塊連接到存儲(chǔ)控制器上,就聚合成了主存。
內(nèi)存結(jié)構(gòu).png而前邊介紹到的DRAM芯片就包裝在存儲(chǔ)器模塊中,每個(gè)存儲(chǔ)器模塊中包含8個(gè)DRAM芯片,依次編號(hào)為0 - 7。
存儲(chǔ)器模塊.png而每一個(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,行訪問選通脈沖)。j表示二維矩陣中的列地址,在計(jì)算機(jī)中列地址稱為CAS(column access strobe,列訪問選通脈沖)。
下圖中的supercell的RAS = 2,CAS = 2。
DRAM結(jié)構(gòu).pngDRAM芯片中的信息通過引腳流入流出DRAM芯片。每個(gè)引腳攜帶1 bit的信號(hào)。
圖中DRAM芯片包含了兩個(gè)地址引腳(addr),因?yàn)槲覀円ㄟ^RAS,CAS來定位要獲取的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芯片的訪問
我們現(xiàn)在就以讀取上圖中坐標(biāo)地址為(2,2)的supercell為例,來說明訪問DRAM芯片的過程。
DRAM芯片訪問.png首先存儲(chǔ)控制器將行地址RAS = 2通過地址引腳發(fā)送給DRAM芯片。
DRAM芯片根據(jù)RAS = 2將二維矩陣中的第二行的全部內(nèi)容拷貝到內(nèi)部行緩沖區(qū)中。
接下來存儲(chǔ)控制器會(huì)通過地址引腳發(fā)送CAS = 2到DRAM芯片中。
DRAM芯片從內(nèi)部行緩沖區(qū)中根據(jù)CAS = 2拷貝出第二列的supercell并通過數(shù)據(jù)引腳發(fā)送給存儲(chǔ)控制器。
DRAM芯片的IO單位為一個(gè)supercell,也就是一個(gè)字節(jié)(8 bit)。
5.3 CPU如何讀寫主存
前邊我們介紹了內(nèi)存的物理結(jié)構(gòu),以及如何訪問內(nèi)存中的DRAM芯片獲取supercell中存儲(chǔ)的數(shù)據(jù)(一個(gè)字節(jié))。
本小節(jié)我們來介紹下CPU是如何訪問內(nèi)存的。
CPU與內(nèi)存之間的總線結(jié)構(gòu).png其中關(guān)于CPU芯片的內(nèi)部結(jié)構(gòu)我們?cè)诮榻Bfalse sharding的時(shí)候已經(jīng)詳細(xì)的介紹過了,這里我們主要聚焦在CPU與內(nèi)存之間的總線架構(gòu)上。
5.3.1 總線結(jié)構(gòu)
CPU與內(nèi)存之間的數(shù)據(jù)交互是通過總線(bus)完成的,而數(shù)據(jù)在總線上的傳送是通過一系列的步驟完成的,這些步驟稱為總線事務(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)存地址,說明有人在修改字段a,這樣其他CPU核心就會(huì)失效自己緩存字段a所在的cache line。
如上圖所示,其中系統(tǒng)總線是連接CPU與IO bridge的,存儲(chǔ)總線是來連接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ù)過程
假設(shè)CPU現(xiàn)在要將內(nèi)存地址為A的內(nèi)容加載到寄存器中進(jìn)行運(yùn)算。
CPU讀取內(nèi)存.png首先CPU芯片中的總線接口會(huì)在總線上發(fā)起讀事務(wù)(read transaction)。該讀事務(wù)分為以下步驟進(jìn)行:
CPU將內(nèi)存地址A放到系統(tǒng)總線上。隨后IO bridge將信號(hào)傳遞到存儲(chǔ)總線上。
主存感受到存儲(chǔ)總線上的地址信號(hào)并通過存儲(chǔ)控制器將存儲(chǔ)總線上的內(nèi)存地址A讀取出來。
存儲(chǔ)控制器通過內(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)總線上讀取出來并拷貝到寄存器中。
以上就是CPU讀取內(nèi)存數(shù)據(jù)到寄存器中的完整過程。
但是其中還涉及到一個(gè)重要的過程,這里我們還是需要攤開來介紹一下,那就是存儲(chǔ)控制器如何通過內(nèi)存地址A從主存中讀取出對(duì)應(yīng)的數(shù)據(jù)X的?
接下來我們結(jié)合前邊介紹的內(nèi)存結(jié)構(gòu)以及從DRAM芯片讀取數(shù)據(jù)的過程,來總體介紹下如何從主存中讀取數(shù)據(jù)。
5.3.3 如何根據(jù)內(nèi)存地址從主存中讀取數(shù)據(jù)
前邊介紹到,當(dāng)主存中的存儲(chǔ)控制器感受到了存儲(chǔ)總線上的地址信號(hào)時(shí),會(huì)將內(nèi)存地址從存儲(chǔ)總線上讀取出來。
隨后會(huì)通過內(nèi)存地址定位到具體的存儲(chǔ)器模塊。還記得內(nèi)存結(jié)構(gòu)中的存儲(chǔ)器模塊嗎??
內(nèi)存結(jié)構(gòu).png而每個(gè)存儲(chǔ)器模塊中包含了8個(gè)DRAM芯片,編號(hào)從0 - 7。
存儲(chǔ)器模塊.png存儲(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芯片。依次通過(RAS,CAS)從DRAM0到DRAM7讀取到相應(yīng)的supercell。
DRAM芯片訪問.png我們知道一個(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)。
讀取存儲(chǔ)器模塊數(shù)據(jù).png5.3.4 CPU向內(nèi)存寫入數(shù)據(jù)過程
我們現(xiàn)在假設(shè)CPU要將寄存器中的數(shù)據(jù)X寫到內(nèi)存地址A中。同樣的道理,CPU芯片中的總線接口會(huì)向總線發(fā)起寫事務(wù)(write transaction)。寫事務(wù)步驟如下:
CPU將要寫入的內(nèi)存地址A放入系統(tǒng)總線上。
通過IO bridge的信號(hào)轉(zhuǎn)換,將內(nèi)存地址A傳遞到存儲(chǔ)總線上。
存儲(chǔ)控制器感受到存儲(chǔ)總線上的地址信號(hào),將內(nèi)存地址A從存儲(chǔ)總線上讀取出來,并等待數(shù)據(jù)的到達(dá)。
CPU將寄存器中的數(shù)據(jù)拷貝到系統(tǒng)總線上,通過IO bridge的信號(hào)轉(zhuǎn)換,將數(shù)據(jù)傳遞到存儲(chǔ)總線上。
存儲(chǔ)控制器感受到存儲(chǔ)總線上的數(shù)據(jù)信號(hào),將數(shù)據(jù)從存儲(chǔ)總線上讀取出來。
存儲(chǔ)控制器通過內(nèi)存地址A定位到具體的存儲(chǔ)器模塊,最后將數(shù)據(jù)寫入存儲(chǔ)器模塊中的8個(gè)DRAM芯片中。
6. 為什么要內(nèi)存對(duì)齊
我們?cè)诹私饬藘?nèi)存結(jié)構(gòu)以及CPU讀寫內(nèi)存的過程之后,現(xiàn)在我們回過頭來討論下本小節(jié)開頭的問題:為什么要內(nèi)存對(duì)齊?
下面筆者從三個(gè)方面來介紹下要進(jìn)行內(nèi)存對(duì)齊的原因:
速度
CPU讀取數(shù)據(jù)的單位是根據(jù)word size來的,在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ǔ)器模塊數(shù)據(jù).png由于存儲(chǔ)器模塊中這種由8個(gè)DRAM芯片組成的物理存儲(chǔ)結(jié)構(gòu)的限制,內(nèi)存讀取數(shù)據(jù)只能是按照地址順序8個(gè)字節(jié)的依次讀取----8個(gè)字節(jié)8個(gè)字節(jié)地來讀取數(shù)據(jù)。
內(nèi)存IO單位.png假設(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芯片中通過相同的(RAS,CAS)一次性讀取出來。
如果我們現(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)也是相同的,我們也可以一次性讀取出來。
注意: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é)出來先放入結(jié)果寄存器中并左移7個(gè)字節(jié)(目的是只獲取0x0007),然后CPU在從0x0008 - 0x0015讀取8個(gè)字節(jié)出來放入臨時(shí)寄存器中并右移1個(gè)字節(jié)(目的是獲取0x0008 - 0x0014)最后與結(jié)果寄存器或運(yùn)算。最終得到0x0007 - 0x0014地址段上的8個(gè)字節(jié)。
從以上分析過程來看,當(dāng)CPU訪問內(nèi)存對(duì)齊的地址時(shí),比如0x0000和0x0008這兩個(gè)起始地址都是對(duì)齊至8的倍數(shù)。CPU可以通過一次read transaction讀取出來。
但是當(dāng)CPU訪問內(nèi)存沒有對(duì)齊的地址時(shí),比如0x0007這個(gè)起始地址就沒有對(duì)齊至8的倍數(shù)。CPU就需要兩次read transaction才能將數(shù)據(jù)讀取出來。
還記得筆者在小節(jié)開頭提出的問題嗎 ??"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ì)象的起始地址通過內(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)跨緩存行的字段。也就是說,該字段的讀取可能需要替換兩個(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)容之后,我們來介紹下前邊經(jīng)常提到的壓縮指針。可以通過JVM參數(shù)XX:+UseCompressedOops開啟,當(dāng)然默認(rèn)是開啟的。
在本小節(jié)內(nèi)容開啟之前,我們先來討論一個(gè)問題,那就是為什么要使用壓縮指針??
假設(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ì)象引用。在前邊我們也提到過,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)存的訪問。綜合以上幾點(diǎn)從而導(dǎo)致了系統(tǒng)性能的下降。
從另一方面來說,在64位系統(tǒng)中內(nèi)存的尋址空間為2^48 = 256T,在現(xiàn)實(shí)情況中我們真的需要這么大的尋址空間嗎??好像也沒必要吧~~
于是我們就有了新的想法:那么我們是否應(yīng)該切換回32位系統(tǒng)呢?
如果我們切換回32位系統(tǒng),我們?cè)趺唇鉀Q在32位系統(tǒng)中擁有超過4G的內(nèi)存尋址空間呢?因?yàn)楝F(xiàn)在4G的內(nèi)存大小對(duì)于現(xiàn)在的應(yīng)用來說明顯是不夠的。
我想以上的這些問題,也是當(dāng)初JVM的開發(fā)者需要面對(duì)和解決的,當(dāng)然他們也交出了非常完美的答卷,那就是使用壓縮指針可以在64位系統(tǒng)中利用32位的對(duì)象引用獲得超過4G的內(nèi)存尋址空間。
7.1 壓縮指針是如何做到的呢?
還記得之前我們?cè)诮榻B對(duì)齊填充和內(nèi)存對(duì)齊小節(jié)中提到的,在Java虛擬機(jī)堆中對(duì)象的起始地址必須對(duì)齊至8的倍數(shù)嗎?
由于堆中對(duì)象的起始地址均是對(duì)齊至8的倍數(shù),所以對(duì)象引用在開啟壓縮指針情況下的32位二進(jìn)制的后三位始終是0(因?yàn)樗鼈兪冀K可以被8整除)。
既然JVM已經(jīng)知道了這些對(duì)象的內(nèi)存地址后三位始終是0,那么這些無意義的0就沒必要在堆中繼續(xù)存儲(chǔ)。相反,我們可以利用存儲(chǔ)0的這3位bit存儲(chǔ)一些有意義的信息,這樣我們就多出3位bit的尋址空間。
這樣在存儲(chǔ)的時(shí)候,JVM還是按照32位來存儲(chǔ),只不過后三位原本用來存儲(chǔ)0的bit現(xiàn)在被我們用來存放有意義的地址空間信息。
當(dāng)尋址的時(shí)候,JVM將這32位的對(duì)象引用左移3位(后三位補(bǔ)0)。這就導(dǎo)致了在開啟壓縮指針的情況下,我們?cè)?2位的內(nèi)存尋址空間一下變成了35位。可尋址的內(nèi)存空間變?yōu)?^32 * 2^3 = 32G。
壓縮指針.png這樣一來,JVM雖然額外的執(zhí)行了一些位運(yùn)算但是極大的提高了尋址空間,并且將對(duì)象引用占用內(nèi)存大小降低了一半,節(jié)省了大量空間。況且這些位運(yùn)算對(duì)于CPU來說是非常容易且輕量的操作
通過壓縮指針的原理我挖掘到了內(nèi)存對(duì)齊的另一個(gè)重要原因就是通過內(nèi)存對(duì)齊至8的倍數(shù),我們可以在64位系統(tǒng)中使用壓縮指針通過32位的對(duì)象引用將尋址空間提升至32G.
從Java7開始,當(dāng)maximum heap size小于32G的時(shí)候,壓縮指針是默認(rèn)開啟的。但是當(dāng)maximum heap size大于32G的時(shí)候,壓縮指針就會(huì)關(guān)閉。
那么我們?nèi)绾卧趬嚎s指針開啟的情況下進(jìn)一步擴(kuò)大尋址空間呢???
7.2 如何進(jìn)一步擴(kuò)大尋址空間
前邊提到我們?cè)贘ava虛擬機(jī)堆中對(duì)象起始地址均需要對(duì)其至8的倍數(shù),不過這個(gè)數(shù)值我們可以通過JVM參數(shù)-XX:ObjectAlignmentInBytes 來改變(默認(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ī)律,我們就能知道,在64位系統(tǒng)中開啟壓縮指針的情況,尋址范圍的計(jì)算公式:4G * ObjectAlignmentInBytes = 尋址范圍。
但是筆者并不建議大家貿(mào)然這樣做,因?yàn)樵龃罅薕bjectAlignmentInBytes雖然能擴(kuò)大尋址范圍,但是這同時(shí)也可能增加了對(duì)象之間的字節(jié)填充,導(dǎo)致壓縮指針沒有達(dá)到原本節(jié)省空間的效果。
8. 數(shù)組對(duì)象的內(nèi)存布局
前邊大量的篇幅我們都是在討論Java普通對(duì)象在內(nèi)存中的布局情況,最后這一小節(jié)我們?cè)賮碚f下Java中的數(shù)組對(duì)象在內(nèi)存中是如何布局的。
8.1 基本類型數(shù)組的內(nèi)存布局
基本類型數(shù)組內(nèi)存布局.png上圖表示的是基本類型數(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é)用來表示數(shù)組長度的部分。
我們還是分別以開啟指針壓縮和關(guān)閉指針壓縮兩種情況,通過下面的例子來進(jìn)行說明:
long[]?longArrayLayout?=?new?long[1];開啟指針壓縮 -XX:+UseCompressedOops
image.png我們看到紅框部分即為數(shù)組類型對(duì)象頭中多出來一個(gè)4字節(jié)大小用來表示數(shù)組長度的部分。
因?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
image.png當(dāng)關(guān)閉了指針壓縮時(shí),對(duì)象頭中的MarkWord還是占用8個(gè)字節(jié),但是類型指針從4個(gè)字節(jié)變?yōu)榱?個(gè)字節(jié)。數(shù)組長度屬性還是不變保持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:在開啟了壓縮指針的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)?4。
8.2 引用類型數(shù)組的內(nèi)存布局
引用類型數(shù)組的內(nèi)存布局.png上圖表示的是引用類型數(shù)組在內(nèi)存中的布局,引用類型數(shù)組在JVM中用objArrayOop結(jié)構(gòu)體表示,基本類型數(shù)組類型元信息用ObjArrayKlass 結(jié)構(gòu)體表示。
同樣在引用類型數(shù)組的對(duì)象頭中也會(huì)有一個(gè)4字節(jié)大小用來表示數(shù)組長度的部分。
我們還是分別以開啟指針壓縮和關(guān)閉指針壓縮兩種情況,通過下面的例子來進(jìn)行說明:
public?class?ReferenceArrayLayout?{char?a;int?b;short?c; }ReferenceArrayLayout[]?referenceArrayLayout?=?new?ReferenceArrayLayout[1];開啟指針壓縮 -XX:+UseCompressedOops
image.png引用數(shù)組類型內(nèi)存布局與基礎(chǔ)數(shù)組類型內(nèi)存布局最大的不同在于它們的實(shí)例數(shù)據(jù)區(qū)。由于開啟了壓縮指針,所以對(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
image.png當(dāng)關(guān)閉壓縮指針時(shí),對(duì)象引用占用內(nèi)存大小變?yōu)榱?個(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ì)齊填充引出來的false sharding問題,還有Java8為了解決false sharding而引入的@Contented注解的原理及使用方式。
為了講清楚內(nèi)存對(duì)齊的底層原理,筆者還花了大量的篇幅講解了內(nèi)存的物理結(jié)構(gòu)以及CPU讀寫內(nèi)存的完整過程。
最后又由內(nèi)存對(duì)齊引出了壓縮指針的工作原理。由此我們知道進(jìn)行內(nèi)存對(duì)齊的四個(gè)原因:
CPU訪問性能:當(dāng)CPU訪問內(nèi)存對(duì)齊的地址時(shí),可以通過一個(gè)read transaction讀取一個(gè)字長(word size)大小的數(shù)據(jù)出來。否則就需要兩個(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。
最后感謝大家能看到這里,我們下篇文章再見~~~
- END -
看完一鍵三連在看,轉(zhuǎn)發(fā),點(diǎn)贊
是對(duì)文章最大的贊賞,極客重生感謝你
推薦閱讀
定個(gè)目標(biāo)|建立自己的技術(shù)知識(shí)體系
大廠后臺(tái)開發(fā)基本功修煉路線和經(jīng)典資料
一文搞懂JAVA與GO垃圾回收
JVM底層原理解析
你好,這里是極客重生,我是阿榮,大家都叫我榮哥,從華為->外企->到互聯(lián)網(wǎng)大廠,目前是大廠資深工程師,多次獲得五星員工,多年職場經(jīng)驗(yàn),技術(shù)扎實(shí),專業(yè)后端開發(fā)和后臺(tái)架構(gòu)設(shè)計(jì),熱愛底層技術(shù),豐富的實(shí)戰(zhàn)經(jīng)驗(yàn),分享技術(shù)的本質(zhì)原理,希望幫助更多人蛻變重生,拿BAT大廠offer,培養(yǎng)高級(jí)工程師能力,成為技術(shù)專家,實(shí)現(xiàn)高薪夢(mèng)想,期待你的關(guān)注!點(diǎn)擊藍(lán)字查看我的成長之路。
校招/社招/簡歷/面試技巧/大廠技術(shù)棧分析/后端開發(fā)進(jìn)階/優(yōu)秀開源項(xiàng)目/直播分享/技術(shù)視野/實(shí)戰(zhàn)高手等,?極客星球希望成為最有技術(shù)價(jià)值星球,盡最大努力為星球的同學(xué)提供技術(shù)和成長幫助!詳情查看->極客星球
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? 求點(diǎn)贊,在看,分享三連
總結(jié)
以上是生活随笔為你收集整理的深入理解Java内存架构的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 深入理解Linux调度子系统
- 下一篇: 高薪Java工程师必看的书籍