日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 编程语言 > java >内容正文

java

深入理解Java内存架构

發布時間:2024/4/11 java 31 豆豆
生活随笔 收集整理的這篇文章主要介紹了 深入理解Java内存架构 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.

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

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

本文概要.png

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

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

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

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

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

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

2. 字段重排列

其實我們在編寫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 開啟壓縮指針,關閉字段壓縮

    image.png
    • 偏移量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 開啟壓縮指針,開啟字段壓縮

    image.png
    • 在第一種情況的分析基礎上,我們開啟了-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 關閉壓縮指針,關閉字段壓縮

    image.png

    首先在關閉壓縮指針-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 關閉壓縮指針,開啟字段壓縮

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

    image.png

    由于在關閉指針壓縮的情況下類型指針的大小變為了8個字節,所以導致Child對象中第一個字段Parent.l前邊并沒有空隙,剛好對齊8N,并不需要int型變量的插入。所以即使開啟了字段壓縮-XX +CompactFields,字段的總體排列順序還是不變的。

    默認情況下指針壓縮-XX:+UseCompressedOops以及字段壓縮-XX +CompactFields都是開啟的

    3. 對齊填充(Padding)

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

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

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

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

    image.png

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

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

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

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

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

    4. 對齊填充的應用

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

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

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

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

    在介紹False Sharing(偽共享)之前,筆者先來介紹下CPU讀取內存中數據的方式。

    4.1.1 CPU緩存

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

    為了彌補CPU與內存之間巨大的速度差異,提高CPU的處理效率和吞吐,于是人們引入了L1,L2,L3高速緩存集成到CPU中。當然還有L0也就是寄存器,寄存器離CPU最近,訪問速度也最快,基本沒有時延。

    CPU緩存結構.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信息。

    image.png

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

    筆者機器上的處理器并沒有使用超線程技術所以這里其實是4個物理核心。

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

    CPU緩存的情況在/sys/devices/system/cpu/cpu0/cache目錄下查看:

    image.png

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

    image.png
    • 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的情況:

    image.png

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

    L2Cache

    L2Cache的信息存儲在index2目錄下:

    image.png

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

    L3Cache

    L3Cache的信息存儲在index3目錄下:

    image.png

    到這里我們可以看到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字節(注意:這里的單位是字節)。

    image.png

    從圖中我們可以看到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類中字段之間的內存布局如下:

    image.png

    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;}

    修改后的對象在內存中布局如下:

    image.png

    我們看到為了解決False Sharing問題,我們將原本占用32字節的FalseSharding示例對象硬生生的填充到了200字節。這對內存的消耗是非常可觀的。通常為了極致的性能,我們會在一些高并發框架或者JDK的源碼中看到False Sharing的解決場景。因為在高并發場景中,任何微小的性能損失比如False Sharing,都會被無限放大。

    但解決False Sharing的同時又會帶來巨大的內存消耗,所以即使在高并發框架比如disrupter或者JDK中也只是針對那些在多線程場景下被頻繁寫入的共享變量

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

    image.png

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

    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?tagString?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示例對象中的整個實例數據區需要獨占緩存行,不能與其他對象或者變量共享緩存行。

    這種情況下的內存布局:

    image.png

    如圖中所示,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?{@Contendedvolatile?long?a;@Contendedvolatile?long?b;volatile?int?c;volatile?long?d; }image.png

    這次我們將 @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; }image.png

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

    這樣處在同一分組group1下的字段a與字段b在內存中是連續存儲的,可以共享緩存行。

    同理處在同一分組group2下的字段c與字段d在內存中也是連續存儲的,也允許共享緩存行。

    但是分組之間是不能共享緩存行的,所以在字段分組的前后各填充128字節,來保證分組之間的變量不能共享緩存行。

    5. 內存對齊

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

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

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

    • 為什么要進行內存對齊?如果就是頭比較鐵,就是不內存對齊,會產生什么樣的后果?

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

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

    5.1 內存結構

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

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

    • 另一類則是動態RAM(DRAM),這類DRAM用于我們常說的主存上,其特點的是訪問速度慢(相對高速緩存),訪問速度為50 - 200個時鐘周期,但是容量大,造價便宜些(相對高速緩存)。

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

    image.png

    如圖所示內存條上黑色的元器件就是存儲器模塊(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芯片中。

  • 6. 為什么要內存對齊

    我們在了解了內存結構以及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. 字段重排列》這一小節介紹的三種字段對齊規則,是保證在字段內存對齊的基礎上使得實例數據區占用內存盡可能的小

    7. 壓縮指針

    在介紹完關于內存對齊的相關內容之后,我們來介紹下前邊經常提到的壓縮指針。可以通過JVM參數XX:+UseCompressedOops開啟,當然默認是開啟的。

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

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

    在JVM中導致性能下降的最主要原因就是64位系統中的對象引用。在前邊我們也提到過,64位系統中對象的引用以及類型指針占用64 bit也就是8個字節。

    這就導致了在64位系統中的對象引用占用的內存空間是32位系統中的兩倍大小,因此間接的導致了在64位系統中更多的內存消耗以及更頻繁的GC發生,GC占用的CPU時間越多,那么我們的應用程序占用CPU的時間就越少。

    另外一個就是對象的引用變大了,那么CPU可緩存的對象相對就少了,增加了對內存的訪問。綜合以上幾點從而導致了系統性能的下降。

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

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

    如果我們切換回32位系統,我們怎么解決在32位系統中擁有超過4G的內存尋址空間呢?因為現在4G的內存大小對于現在的應用來說明顯是不夠的。

    我想以上的這些問題,也是當初JVM的開發者需要面對和解決的,當然他們也交出了非常完美的答卷,那就是使用壓縮指針可以在64位系統中利用32位的對象引用獲得超過4G的內存尋址空間

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

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

    由于堆中對象的起始地址均是對齊至8的倍數,所以對象引用在開啟壓縮指針情況下的32位二進制的后三位始終是0(因為它們始終可以被8整除)。

    既然JVM已經知道了這些對象的內存地址后三位始終是0,那么這些無意義的0就沒必要在堆中繼續存儲。相反,我們可以利用存儲0的這3位bit存儲一些有意義的信息,這樣我們就多出3位bit的尋址空間。

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

    當尋址的時候,JVM將這32位的對象引用左移3位(后三位補0)。這就導致了在開啟壓縮指針的情況下,我們原本32位的內存尋址空間一下變成了35位。可尋址的內存空間變為2^32 * 2^3 = 32G。

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

    8. 數組對象的內存布局

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

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

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

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

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

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

    long[]?longArrayLayout?=?new?long[1];

    開啟指針壓縮 -XX:+UseCompressedOops

    image.png

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

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

    關閉指針壓縮 ?-XX:-UseCompressedOops

    image.png

    當關閉了指針壓縮時,對象頭中的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

    image.png

    引用數組類型內存布局與基礎數組類型內存布局最大的不同在于它們的實例數據區。由于開啟了壓縮指針,所以對象引用占用內存大小為4個字節,而我們示例中引用數組只包含一個引用元素,所以這里實例數據區中只有4個字節。相同的到道理,如果示例中的引用數組包含的元素變為兩個引用元素,那么實例數據區就會變為8個字節,以此類推......。

    最后由于Java對象需要內存對齊至8的倍數,所以在該引用數組的實例數據區后填充了4個字節。

    關閉指針壓縮 -XX:-UseCompressedOops

    image.png

    當關閉壓縮指針時,對象引用占用內存大小變為了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。

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

    - END -


    看完一鍵三連在看轉發點贊

    是對文章最大的贊賞,極客重生感謝你

    推薦閱讀

    定個目標|建立自己的技術知識體系


    大廠后臺開發基本功修煉路線和經典資料


    一文搞懂JAVA與GO垃圾回收

    JVM底層原理解析


    你好,這里是極客重生,我是阿榮,大家都叫我榮哥,從華為->外企->到互聯網大廠,目前是大廠資深工程師,多次獲得五星員工,多年職場經驗,技術扎實,專業后端開發和后臺架構設計,熱愛底層技術,豐富的實戰經驗,分享技術的本質原理,希望幫助更多人蛻變重生,拿BAT大廠offer,培養高級工程師能力,成為技術專家,實現高薪夢想,期待你的關注!點擊藍字查看我的成長之路

    校招/社招/簡歷/面試技巧/大廠技術棧分析/后端開發進階/優秀開源項目/直播分享/技術視野/實戰高手等,?極客星球希望成為最有技術價值星球,盡最大努力為星球的同學提供技術和成長幫助!詳情查看->極客星球

    ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? 求點贊,在看,分享三連

    總結

    以上是生活随笔為你收集整理的深入理解Java内存架构的全部內容,希望文章能夠幫你解決所遇到的問題。

    如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。