CPU Cache 基礎解析

文末含分享內容視頻連接

CPU Cache 基礎

最近看了一些 CPU 緩存相關的東西,在這裏作一下記錄。html

Wiki 詞條:CPU cache - Wikipediajava

一些基本概念

CPU 緩存出現的緣由

  1. 主存通常是 DRAM,CPU 速度比主存快不少倍,沒有緩存存在時 CPU 性能很大程度取決於讀取存儲數據的能力
  2. 比 DRAM 快的存儲介質是存在的,好比做爲 CPU 高速緩存的 SRAM,只是很貴,作很大的 SRAM 不經濟
  3. CPU 訪問數據存在時間局部性和空間局部性,因此能夠將 CPU 須要頻繁訪問的少許熱數據放在速度快但很貴的 SRAM 中,既能大幅度改善 CPU 性能也不會讓成本提高太多

Cache Line

CPU 每次訪問數據時先在緩存中查找一次,找不到則去主存找,訪問完數據後會將數據存入緩存,以備後用。這就產生了一個問題,CPU 在訪問某個地址的時候如何知道目標數據是在緩存中存在?如何知道緩存的數據是否還有效沒被修改?不能爲每一個存入緩存的字節都打標記,因此 CPU 緩存會劃分爲固定大小的 Block 稱爲 Cache Line,做爲存取數據的最小單位。大小都爲 2 的整數冪,好比 16 字節,256 字節等。這樣一個 cache line 這一整塊內存能經過一個標記來記錄是否在內存中,是否還有效,是否被修改等。一次存取一塊數據也能充分利用總線帶寬以及 CPU 訪問的空間局部性。linux

Cache Write Policy

Cache 不光是在讀取數據時有用,目前大部分 CPU 在寫入數據時也會先寫 Cache。一方面是由於新存數據極可能會被再次使用,新寫數據先寫 Cache 能提升緩存命中率;另外一方面 CPU 寫 Cache 速度更快,從而寫完以後 CPU 能夠去幹別的事情,能提升性能。git

CPU 寫數據若是 Cache 命中了,則爲了保持 Cache 和主存一致有兩種策略。若是 CPU 寫 Cache 每次都要更新主存,則稱爲 Write-Through ,由於每次寫 Cache 都伴隨主存更新因此性能差,實際使用的也少;寫 Cache 以後並不當即寫主存而是等待一段時間能積累一些改動後再更新主存的策略稱爲 Write-Back ,性能更好但爲了保證寫入的數據不丟使機制更加複雜。採用 Write Back 方式被修改的內存在從 Cache 移出(好比 Cache 不夠須要騰點空間)時,若是被修改的 Cache Line 還未寫入主存須要在被移出 Cache 時更新主存,爲了能分辨出哪些 Cache 是被修改過哪些沒有,又須要增長一個新的標誌位在 Cache Line 中去標識。程序員

CPU 寫數據若是 Cache 未命中,則只能直接去更新主存。但更新完主存後又有兩個選擇,將剛修改的數據存入 Cache 仍是不存。每次直接修改完主存都將主存被修改數據所在 Cache Line 存入 Cache 叫作 Write-Allocate 。須要注意的是 Cache 存取的最小單位是 Cache Line。即便 CPU 只寫一個字節,也須要將被修改字節所在附近 Cache Line 大小的一塊內存完整的讀入 Cache。若是 CPU 寫主存的數據超過一個 Cache Line 大小,則不用再讀主存原來內容,直接將新修改數據寫入 Cache。至關於徹底覆蓋主存以前的數據。github

寫數據時除了須要考慮上述寫 Cache 策略外,還須要保持各 CPU Cache 之間的一致性。好比一個 CPU 要向某個內存地址寫數據,它須要通知其它全部 CPU 本身要寫這個地址,若是其它 CPU 的 Cache Line 內有緩存這個地址的話,須要將這個 Cache Line 設置爲 Invalidate。這樣寫數據的那個 CPU 就能安全的寫數據了。下一次其它 CPU 要讀這個內存地址時,發現這個 Cache Line 是 Invalidate 狀態,因此須要從新從內存作加載,即發生一次 Communication Miss。這種 Miss 不是 Cache 不夠大,也不是 Cache 衝突,而是由於其它 CPU 寫同一個 Cache Line 裏數據致使了 Cache Miss。緩存一致性維護須要專門文章來寫。算法

SMP 和 NUMA

SMP 詞條:Symmetric multiprocessing - Wikipedia
NUMA 詞條:Non-uniform memory access - Wikipediashell

簡單說 SMP 就是一組 CPU 會經過一條總線共享機器內的內存、IO 等資源。由於全部東西都是共享的,因此擴展性受限。segmentfault

NUMA 則是將機器內 CPU 分爲若干組,每一個組內都有獨立的內存,IO資源,組與組之間不相互共享內存和 IO 等資源,組之間經過專門的互聯模塊鏈接。整體上擴展性更強。緩存

本文主要以 SMP 系統爲例。CPU 以及 Cache,Memory 的關係以下:

CPU 緩存結構

Direct Mapped Cache

最簡單的緩存結構就是 Direct Mapped 結構。以下圖所示,每一個 Cache Line 由基本的 Valid 標誌位,Tag 以及 Data 組成。當訪問一個內存地址時,根據內存地址用 Hash 函數處理獲得目標地址所在 Cache Line 的 Index。根據 Index 在 Cache 中找到對應 Cache Line 的 Data 數據,再從 Data 中根據內存地址的偏移量讀取所需數據。由於 Hash 函數是固定的,因此每個內存地址在緩存上對應固定的一塊 Cache Line。因此是 Direct Mapped。

實際中爲了性能 hash 函數都很是簡單,就是從內存地址讀取固定的幾個 bit 數據做爲 Cache Line 的 Index。拿下圖爲例,Cache Line 大小爲 4 字節,一共 32 bit 是圖中的 Data 字段。4 字節一共須要 2 bit 用於尋址,因此看到 32 bit 的地址中,0 1 兩個 bit 做爲 Offset。2 ~ 11 bit 做爲 Cache Line 的 Index 一共 1024 個,12 ~ 31 bit 做爲 tag 用於區分映射到相同 Cache Line 的不一樣內存 block。好比如今要讀取的地址是 0x1124200F,先從地址中取 2 ~ 11 bit 獲得 0x03 表示目標 Cache Line 的 Index 是 3,以後從地址中讀 12 ~ 31 bit 做爲 tag 是 0x11242。拿這個 Tag 跟 Index 爲 3 的 Cache Line 的 Tag 作比較看是否一致,一致則表示當前 Cache Line 中包含目標地址,不一致則表示當前 Cache Line 中沒有目標地址。由於 Cache 比內存小不少,因此可能出現多個不在同一 Cache Line 的內存地址被映射到同一個 Cache Line 的狀況,因此須要用 Tag 作區分。最後,若是目標地址確實在 Cache Line,且 Cache Line 的 Valid 爲 true,則讀取 0x1124200F 地址的 0 ~ 1 bit,即找目標數據在 Cache Line 內的 Offset,獲得 0x03 表示讀取當前 Cache Line 中最後一個字節的數據。

上圖的 Cache 是 1024 X 4 字節 一共 4 KB。但因爲 Tag 和 Valid 的存在,緩存實際佔用的空間還會更大。

替換策略

由於 Direct Mapped 方式下,每一個內存在 Cache 中有固定的映射位置,因此新訪問的數據要被存入 Cache 時,根據數據所在內存地址計算出 Index 發現該 Index 下已經存在有效的 Cache Line,須要將這個已存在的有效 Cache Line 從 Cache 中移出。若是採用 Write-Back 策略,移出時須要判斷這個數據是否有被修改,被修改了須要更新主存。

Write-Back 策略在前面有介紹,即寫數據時只寫緩存就當即返回,但標記緩存爲 Dirty,以後在某個時間再將 Dirty 的緩存寫入主存。

Two-way Set Associative Cache

咱們但願緩存越大越好,越大的緩存常常意味着更快的執行速度。對於 Direct Mapped Cache 結構,增大緩存就是增長 Index 數量,至關因而對上面表進行縱向擴展。但除了縱向擴展以外,還能夠橫向擴展來增長 Cache 大小,這就是 Two-way Set Associative Cache。

基本就是以下圖所示,圖上省略了 Tag 和 Valid 等標識每一個 cell 就是一個 Cache Line,與 Direct Mapped Cache 不一樣點在於,Two-way Set Associative Cache 裏每一個 Index 對應了兩個 Cache Line,每一個 Cache Line 有本身的 Tag。同一個 index 下的兩個 cache line 組成 Set。在訪問內存時,先根據內存地址找到目標地址所在 Set 的 index,以後併發的去驗證 Set 下的兩個 Cache Line 的 Tag,驗證目標地址是否在 Cache Line 內,在的話就讀取數據,不在則去讀主存。

這裏併發的驗證兩個 Cache Line 的 Tag 是由硬件來保證,硬件電路結構會更加複雜但查詢效率與 Direct Mapped Cache 一致。

Set 內的兩個 Cache Line 是具備相同 Index 的兩個不一樣 Cache Line。上圖來自 Is Parallel Programming Hard, And, If So, What Can You Do About It?,圖 C.2。以這個圖爲例,假設 Cache Line 大小是 256 字節,因此圖上全部地址最右側都是 00,即有 8 bit 的 Offset,從 0 到 7。由於 Cache Line 只有 16 個,因此 index 是 4 bit,從 8 ~ 11。圖中看到 0x12345E00 和 0x43210E00 在 8 ~ 11 bit 位置上是相同的,都是 0xE 因此這兩個內存地址被映射到 Cache 中同一個 Index 下。這兩個 Cache Line 就會放在同一個 Set 內,在訪問時能同時被比較 Tag。

替換策略

新數據要存入 Cache 時,根據數據所在內存地址計算出 Index 後發現該 Index 下兩個 Way 的 Cache Line 都已被佔用且處在有效狀態。須要有辦法從這兩個 Cache Line 裏選一個出來移除。Direct Mapped 由於一個 Index 下只有一個 Cache Line 就沒這個問題。

若是是這裏說的 Two-way Set Associative Cache 還比較好弄,給每一個 Way 增長一個最近訪問過的標識。每次一個 Way 被訪問就將最近訪問位置位,並清理另外一個 Way 的最近訪問位。從而在執行替換時,將不是最近訪問過的那個 Way 移除。不過下面會看到 N-way Set Associative Cache 當有 N 個 Way 的時候替換策略更加複雜,通常是儘量使用最少的狀態信息實現近似的 LRU。

N-way Set Associative Cache

顧名思義,就是在 Two-way Set Associative Cache 基礎上繼續橫向擴展,在一個 Set 內加入更多更多的 Way 也即 Cache Line。這些 Cache Line 能被併發的同時驗證 Tag。若是 Cache 內全部的 Cache Line 都在同一個 Set 內,即全部 Cache Line 都能同時被驗證 Tag,則這種 Cache 叫作 Fully Associative Cache 。能夠看出 Fully Associative Cache 性能是最強的,能省去從地址中讀取 Index 查找 Set 的過程。但橫向擴展的 Way 越多,結構越複雜,成本越高,越難實現大的 Cache。因此 Fully Associative Cache 雖然存在,但都很小,通常用在 TLB 上。

Cache 結構爲何發展出橫向擴展?

這個是我本身提出來的問題。對 Direct Mapped Cache 擴展 Cache 時就是增長更多的 index,讓 cache 表變得更長。但爲何會發展出 Two-way Set Associative Cache 呢?好比若是一共 16 個 Cache Line,是 16 行 Cache Line 仍是 8 行 Set 每一個 Set 兩個 Cache Line 在容量和命中率上彷佛並無差異。

後來看到了 這個問題 ,明白了其中的緣由。主要是須要區分出來 Conflict Miss 和 Capacity Miss (還有一個 Communication Miss,前面說過)。當 Cache 容量足夠,但因爲兩塊不一樣的內存映射到了同一個 Cache Line,致使必須將老的內存塊剔除產生的 Miss 叫作 Conflict Miss,即便整個 Cache 都是空的,只有這一個 Cache Line 有效時也會出現 Miss。而因爲容量不足致使的 Miss 就是 Capacity Miss。好比 cache 只有 32k,訪問的數據有 200k,那訪問時候必定會出現後訪問的數據不斷的把先訪問數據從 Cache 中頂出去,致使 Cache 一直處在 Miss 狀態的問題。

在 Capacity Miss 方面橫向擴展和縱向擴展沒有什麼區別,主要區別就是 Conflict Miss。倘若輪番訪問 A B 兩個內存,這兩個內存映射到同一個 Cache Line 上,那對於 Direct Mapped Cache,由於每塊內存只有固定的一個 Cache Line 能存放,則會出現持續的 Conflict Miss,稱爲 cache thrashing。而 Two-way Set Associative Cache 就能將 A B 兩塊內存放入同一個 Set 下,就都能 Cache 住,不會出現 Conflict Miss。這就是橫向擴展的好處,也是爲何橫向擴展即便困難,各個 CPU 都在向這個方向發展。而且橫向擴展和縱向擴展並不衝突,Two-way Set Associative Cache 也能加多 Set 來進行擴展。

Cache Prefetching

詞條:Cache prefetching - Wikipedia

Cache 運做時並不必定每次只加載一條 Cache Line,而是可能根據程序運行情況,發現有一些固定模式好比 for loop 的時候在加載 Cache Line 時會多加載一點,相似於經過 Batch 來作優化同樣。

爲何緩存存取速度比主存快

Why is SRAM better than DRAM? - Quora

False Sharing

Wiki 詞條:False sharing - Wikipedia

好比像下面圖這個樣子:

A B 兩個對象在內存上被連續的建立在一塊兒,倘若這兩個對象都很小,小於一個 Cache Line 大小,那他們可能會共用同一個 Cache Line。若是再有兩個線程 Thread 1 和 Thread 2 會去操做這兩個對象,咱們從代碼角度保證 Thread 1 只會操做 A,而 Thread2 只會操做 B。那按道理這兩個 Thread 訪問 A B 不應有相互影響,都能並行操做。但如今由於它倆恰好在同一個 Cache Line 內,就會出現 A B 對象所在 Cache Line 在兩個 CPU 上來回搬遷的問題。

好比 Thread 1 要修改對象 A,那 Thread 1 所在 CPU 1 會先獲取 A 所在 Cache Line 的 Exclusive 權限,會發送 Invalidate 給其它 CPU 讓其它 CPU 設置該 Cache Line 爲無效。以後 Thread 2 要修改對象 B,Thread 2 所在 CPU 2 又會嘗試獲取 B 所在 Cache Line 的 Exclusive 權限,會發 Invalidate 給其它 CPU,包括 CPU 1。CPU 1 要是已經寫完了 A,那就要把數據刷寫內存,以後設置 Cache Line 無效並響應 Invalidate。沒寫完就得等待 CPU 1 寫完 A 後再處理 Cache Line 的 Invalidate 問題。以後 CPU 2 再去操做 Cache Line 更新 B 對象。再後來 Thread 1 要去更新 A 對象的話又要去把 A B 所在 Cache Line 在 CPU 2 上設置無效。也就是說這塊 Cache Line 失去了 Cache 功能,會在兩個 CPU 上來回搬遷,會常常性的執行刷寫內存,讀取內存操做,致使兩個原本看上去沒有關係的操做實際上有相互干擾。

想觀測到這個現象最簡單的是讓 A B 是同一個類的不一樣 Field,而不是兩個獨立對象,好比:

class SomeClass{
    volatile long valueA;
    volatile long valueB;
}

這個對象在內存上佈局以下:

看到這個 Object 只有 32 字節,在我機器上一個 Cache Line 是 64 字節 (mac 上執行 sysctl machdep.cpu.cache.linesize,Linux 上執行 getconf LEVEL1_DCACHE_LINESIZE 來查看),因此 A B 都能放在同一個 Cache Line 上,以後能夠建立出來兩個 Thread 去分別操做同一個 SomeClass 對象的 valueA 和 valueB Field,記錄一下時間,再跟下面解決方案裏說的方式作對比,看看 False Sharing 的現象。

解決辦法

解決這個問題的辦法也很容易,若是是上面例子的話,就是讓被操做的 valueA 和 valueB 隔得遠一點。好比能夠這麼聲明:

class SomeClassPadding {
    volatile long valueA;
    public long p01, p02, p03, p04, p05, p06, p07, p08;
    volatile long valueB;
}

對象內存佈局就變成了:

由於我確認我機器的 Cache Line 是 64 字節,因此加了 8 個 long。若是 Padding 少一些,好比 6 個,那 valueA 在 Offset 16,第六個 Padding 在 Offset 64,valueB 剛好是 Offset 64,彷佛已經足夠將 valueB 放到下一個 Cache Line 了但實際仍是有問題。由於對象不必定恰好分配在 Cache Line 開頭,好比 Cache Line 剛好從 valueA 所在 Offset 16 開始,到 Offset 80 結束,若是隻有 6 個 long 作 Padding,那 valueA 和 valueB 仍是在同一個 Cache Line 上。因此 Padding 至少須要和 Cache Line 同樣長。

還有要注意看到 Padding 得聲明爲 public,否則 JVM 發現這一堆 Padding 不可能被訪問到可能就直接優化掉了。

在我機器上測試,Padding 以後性能提高了大概 4 5 倍的樣子。若是上面 SomeClasPadding 去掉 volatile 聲明,則提高大概 1.5 倍的樣子,之因此有這個差距是由於沒有 volatile 的話線程操做 valueA 和 valueB 若是 Cache Line 不在當前 CPU Cache 中,它並不要求等待 Cache Line 加載進來後再作寫入,而是能夠把寫入操做放在一個叫 Store Buffer 的地方以提升性能,具體能夠關注咱們的下一篇分享內容。等 Cache Line 加載後再對它作修改,至關因而將一段時間的寫入操做積累了一下一口氣寫入。而有了 volatile 後則要求每次寫入真的得等 Cache Line 加載後再寫,從而放大了等待 Cache Line 加載的時間,更容易觀察到 False Sharing 問題。

另外,Padding 固然是有代價的。一個是讓對象變得更大,佔用內存,再有是 Padding 了一堆無用數據還得加載到 Cache 裏,白白佔用了 Cache 空間。

須要注意的是本身手工 Padding 方法可能被虛擬機作重排,即 Padding 原本想加到 valueA 和 valueB 之間,但可能被重排到 valueB 以後,致使實際沒有什麼用。好比:

class SomeClassPadding {
    volatile long valueA;
    public int p01, p02, p03, p04, p05, p06, p07, p08;
    volatile long valueB;
}

實際的內存佈局是下圖這樣,即 Padding 都跑到 valueB 後面去了。另外按說 Cache Line 是 64 字節的話用 int 作 Padding 至少要 16 個,我這裏只是爲了說明手工 Padding 的問題,因此少寫了一些。

內存佈局實際會依賴虛擬機而不一樣,因此上面這種 Padding 方式是不可靠的。即便變量真聲明爲 long 也不能保證全部虛擬機都按照相同方式作排列。最靠譜的手工 Padding 方式是用 Class 的層級結構作 Padding,由於 JVM 要求父類的成員必定要排在子類成員以前,因此級聯的 Class 結構能保證 Padding 的可靠性。好比:

class SomeClassValueA {
    volatile long valueA;
}
class SomeClassPaddings extends SomeClassValueA{
    public int p01, p02, p03, p04, p05, p06, p07, p08, p09, p10, p11;
}
class SomeClassValueB extends SomeClassPaddings{
    volatile long valueB;
}
class SomeClass extends SomeClassValueB{   
}

內存結構就變成:

這裏爲了演示用 int 作 Padding,又不想讓圖片太長,因此只寫了 11 個 int,但實際至少須要 16 個 int 即湊夠 64 字節才行。通常 Padding 都用 long 作,不會用 int,能夠少寫不少變量。

這個順序是 JVM 規範保證的,因此全部虛擬機都會按照這個方式排列,因此是可靠的。

另外一個方法是用 @Contentded 註解,java 8 後開始支持,java 11 後 Contendedsun.misc 搬到了 jdk.internal.misc。它做用就是自動幫你作 Padding,它保證在任意 JVM 上都能有 Padding 效果,就不用咱們再去構造 Class 級聯結構了。好比上面例子中用 @Contended 就是:

class SomeClassContended {
    volatile long valueA;
    @Contended
    volatile long valueB;
}

它的內存結構在 HotSpot 64 下是:

它是按 128 作 padding 的,它並沒管我機器的 Cache Line 是多少,另外它是在 valueB 先後都作 Padding。這也是更推薦的方式。通常來講都盡力用 @Contended 註解了,除非爲了兼容 Java 8 如下 JVM 或者爲了性能,爲了 Object 大小等緣由,纔可能會去手工作 Padding。

上面內存結構是經過 JOL 插件 來查看的,它裏面用的OpenJDK: jol 工具。

False Sharing 測試的話能夠參考 JMH 的例子 寫本身的測試。

實際 Padding 例子,能夠看看 Netty 4.1.48 下的 InternalThreadLocalMap

JVM 上這個問題常見嗎

上面 False Sharing 的例子是咱們故意構造而獲得的,因此很容易復現,很容易觀察到。但實際開發中讓同一個對象裏不一樣 Field 被多個線程同時訪問的狀況並很少。卻是有這種例子好比 ConcurrentHashMap 裏用於計算元素總量的 CounterCell 類。不過這種場景並非不少,而通常狀況下,拿上面的 SomeClass 來舉例,它更可能被聲明爲:

class SomeClass{
    volatile long value;
}

SomeClass a = new SomeClass();
SomeClass b = new SomeClass();

以後 Thread 1 和 Thread 2 分別去操做 ab 兩個對象。這種場景下,按說確實有 False Sharing 問題,但由於 a, b 對象都分配在 JVM 堆上,它倆得剛巧在堆上被連續建立出來,且在後續一系列 GC 中都一直能剛好挨在一塊兒,才能持續的存在 False Sharing 問題。這麼看來 False Sharing 彷佛很難出現。好比咱們測試時,讓每一個 Thread 都像上面這樣 new SomeClass() 以後都操做本身 new 出來的 SomeClass 對象,咱們會發現不管怎麼測試,性能都和沒有 False Sharing 時的性能一致,也即沒有 False Sharing 問題。下面會說爲何這裏沒有 False Sharing。

更進一步,即便是同一個 Class 內的不一樣 Field,若是不是普通變量而是引用,好比這樣:

class SomeClass {
    ObjectA valueA;
    ObjectB valueB;
}

兩個 Thread 分別操做這兩個引用,False Sharing 問題要求這兩個引用剛好指向 JVM 堆上兩個相鄰對象,且兩個對象得足夠小,保證兩個對象內被操做的值離得足夠近,能放在同一個 Cache Line 上。想一想每一個對象都有 Object Header 也即天生就有至少 16 字節的 Padding 在,這也讓被操做的值更不容易剛好在同一個 Cache Line 上。

因此 False Sharing 問題在 JVM 上並不會特別常見。

TLAB,PLAB 可能會加劇 False Sharing 問題

按說 False Sharing 問題不會很常見,不過 TLAB 和 PLAB 機制可能會增大它出現的概率。TLAB 全稱 Thread Local Allocation Buffers,我並無找到一個特別好的介紹,這個 Blog 馬馬虎虎能看看: Introduction to Thread Local Allocation Buffers (TLAB) - DZone Java

如下圖爲例,Java 分配內存一般一開始在 Eden 區分配,一個指針用來區分分配過的區域和還未分配的區域。每次分配內存都須要去移動這個指針來分配。若是全部線程分配內存時候都去操做這個指針,勢必會產生不少競爭,各個線程都想去移動這個指針,而 TLAB 的存在便是說每次線程分配內存的時候不是申請多少就分配多少,而是每次分配稍大的區域,以下圖虛線,以後內存分配盡力在線程本身的這塊內存區域上進行,從而減小對 ptr 指針的競爭。若是線程的 TLAB 用完了,或者分配的對象太大,纔會去爭搶 ptr。

與 TLAB 相似的還一個叫作 PLAB 的,Promotion Local Allocation Buffers,用在一組 GC 線程併發的將新生代對象晉升到老代時使用。也是每一個 GC 線程會提早分配一塊區域,每次晉升對象的時候將對象拷貝至線程本身的這塊分配好的區域上,從而減少競爭。

若是是標記-整理算法,按說對象併發的 Sweep 到新位置時也會是用上面這種方法進行。不過這個我沒有找到說明的地方。

這麼一來以前說兩個線程分別 new SomeClass(),每一個線程只操做本身 new 出來的 SomeClass 對象不會引發 False Sharing 的緣由就清楚了,由於每一個線程會把 SomeClass 分配在本身的 TLAB 上,通常 TLAB 大於 Cache Line 因此不會引發 False Sharing 問題。JVM 上容易引發 False Sharing 問題的點也清楚了,即一個線程連續分配了兩個對象,這兩個對象後來被分配給不一樣的線程,而且被它們頻繁更新,這兩個對象在兩個不一樣線程上就容易出現 False Sharing 問題,即便經歷數輪 GC,它倆可能在內存上仍是可能在一塊兒,因此說 TLAB 和 PLAB 會增大 False Sharing 出現的機率。

怎麼證明這一點呢?不太容易,但指導思想就是讓同一個線程連續 new 對象,再讓其它線程來訪問這些對象。只要對象不會很大,由於 TLAB 的關係,這些對象中有兩個在同一個 Cache Line 上的概率會很大。好比我在 JMH 測試時這麼寫:

public static class SomeClassValue {
    volatile int value;
}
@State(Scope.Group)
public static class SomeClass {
    SomeClassValue[] val = new SomeClassValue[2];
    public SomeClass() {
        this.val[0] = new SomeClassValue();
        this.val[1] = new SomeClassValue();
    }
}
@Benchmark
@Group("share")
public void testA(SomeClass someClass) {
    someClass.c[0].value++;
}

@Benchmark
@Group("share")
public void testB(SomeClass someClass) {
    someClass.c[1].value++;
}

看到線程會共享 SomeClass 對象,但會分別訪問 SomeClass 中不一樣的 SomeClassValue。這兩個 value 可能會被放在同一個 Cache Line 上而被觀測到執行性能降低。

JVM 上 False Sharing 嚴重嗎

正常來講 False Sharing 並不常見,想測出它也不容易,可能根據機器型號不一樣,JVM 版本不一樣,運行情況甚至運行時機不一樣而不必定何時出現,可是一旦出如今系統的 Hot Spot 上,數倍的性能損失是很嚴重的。False Sharing 可能產生嚴重問題的場景是:

  • 某個 Class 的對象被連續的建立;
  • 建立的對象被分發給多個不一樣的線程去讀取、寫入,每一個線程原本能夠獨享一個對象;
  • 對象內被線程操做的 Field 被聲明爲 volatile;

好比能夠拿 Netty 4.1.48 下的 InternalThreadLocalMap 做爲例子感覺一下。這個 Thread Local Map 原本是每一個 EventLoop 一個的,各個 EventLoop 不相互干擾。可是訪問 Thread Local 對象自己是 Hot Spot,訪問的不少,若是一旦出現 False Sharing 就會致使性能大幅度降低。EventLoop 是 Netty 啓動時在一個 for loop 中一口氣被建立出來的,因此一組 InternalThreadLocalMap 中有幾個剛巧緊挨着被建立出來是徹底可能的。因此 InternalThreadLocalMap 用 Padding 作了一下保護。固然我理解即便 EventLoop 不是連續被建立出來也該去保護一下 InternalThreadLocalMap 以防剛好多個 Map 對象被放到同一個 Cache Line 上去。

參考

相關文章
相關標籤/搜索