真實位元組二面:什麼是偽共享?
這個問題來自最近一個朋友位元組面試碰到的,最後他也成功拿到了位元組offer,這個問題我想可能挺多人不太清楚,所以想拿出來單獨說一說。
好了,讓我們進入正題。
什麼是偽共享
首先大家都知道,隨著CPU和記憶體的發展速度差異的問題,導致CPU的速度遠遠快於記憶體,所以一般現在的CPU都加入了快取記憶體,就是常說的解決不同硬體之間的效能差異問題。
這樣的話,很簡單的道理,加入了快取,就必然會導致快取一致性的問題,由此,又引入了快取一致性協議。(如果你不知道,建議去百度一下,這裡不做展開)
CPU快取,顧名思義,越貼近CPU的快取速度越快,容量越小,造價成本也越高,而快取記憶體一般可以分為L1、L2、L3三級快取,按照效能的劃分:L1>L2>L3。
而事實上,資料在快取內部都是按照行來儲存的,這就叫做快取行。快取行一般都是2的整數冪個位元組,一般來說範圍在32-256個位元組之間,現在最為常見的快取行的大小在64個位元組。
所以,按照這個儲存方式,快取中的資料並不是一個個單獨的變數的儲存方式,而是多個變數會放到一行中。
我們常說的一個例子就是陣列和連結串列,陣列的記憶體地址是連續的,當我們去讀取陣列中的元素時,CPU會把陣列中後續的若干個元素也載入到快取中,以此提高效率,但是連結串列則不會,也就是說,記憶體地址連續的變數才有可能被放到一個快取行中。
在多個執行緒併發修改一個快取行中的多個變數時,由於只能同時有一個執行緒去操作快取行,將會導致效能的下降,這個問題就稱之為偽共享。
為什麼只有一個執行緒能去操作?我們舉個實際的栗子來說明這種情況:
假設快取中有x,y
兩個變數,他們同時已經在不同的三級快取之中。
這時有兩個執行緒A和B同時去修改位於Core1和Core2的變數x
和y
。
如果執行緒A去修改Core1的快取中的x
變數,由於快取一致性協議,Core2中對應的快取了x,y
變數的快取行將會失效,他會被強制從主記憶體中重新去載入變數。
這樣的話,頻繁的訪問主記憶體,快取基本都失效了,將會導致效能的下降,這就是偽共享的問題。
如何避免?
既然已經知道了什麼是偽共享,那麼怎麼避免這種情況的發生?
改變行儲存的方式?想都別想了。
剩下可行的方法就是填充,如果這一行只有我這一個資料那不就好了嗎?
確實就是這樣,解決方式通常有以下兩種。
位元組填充
在JDK8之前,可以通過填充位元組的方式來避免偽共享的問題,如下程式碼所示:
自定義填充
一般而言,快取行有64位元組,我們知道一個long是8個位元組,填充5個long之後,一共就是48個位元組。
而 Java 中物件頭在32位系統下佔用8個位元組,64位系統下佔用16個位元組,這樣填充5個long型即可填滿64位元組,也就是一個快取行。
@Contented註解
JDK8以及之後的版本 Java 提供了sun.misc.Contended
註解,通過@Contented註解就可以解決偽共享的問題。
註解方式
使用@Contented註解後會增加128位元組的padding,並且需要開啟-XX:-RestrictContended
選項後才能生效。
所以,通過以上兩種方式你會發現,物件頭大小和快取行的大小都和作業系統位數有關,JDK的註解幫你解決了這個問題,所以推薦儘量使用註解的方式來實現。
雖然解決了偽共享問題,但是這種填充的方式也浪費了快取資源,明明只有8B的大小,硬是使用了64B快取空間,造成了快取資源的浪費。
而且我們知道,快取又小又貴,時間和空間的取捨要自己酌情考慮。
實際應用
在Java中提供了多個原子變數的操作類,就是比如AtomicLong
、AtomicInteger
這些,通過CAS的方式去更新變數,但是失敗會無限自旋嘗試,導致CPU資源的浪費。
為了解決高併發下的這個缺點,JDK8中新增了LongAdder
類,他的使用就是對解決偽共享的實際應用。
LongAdder
繼承自Striped64
,內部維護了一個Cell
陣列,核心思想就是把單個變數的競爭拆分,多執行緒下如果一個Cell
競爭失敗,轉而去其他Cell
再次CAS重試。
Striped64成員變數
解決偽共享的真正的核心就在Cell
陣列,可以看到,Cell
陣列使用了Contented
註解。
在上面我們提到陣列的記憶體地址都是連續的,所以陣列內的元素經常會被放入一個快取行,這樣的話就會帶來偽共享的問題,影響效能。
這裡使用Contented
進行填充,就避免了偽共享的問題,使得陣列中的元素不再共享一個快取行。
解決偽共享
好了,今天的內容就到這裡,我是艾小仙,我的slogan還沒想好,但是我們下次見。