真實位元組二面:什麼是偽共享?

這個問題來自最近一個朋友位元組面試碰到的,最後他也成功拿到了位元組offer,這個問題我想可能挺多人不太清楚,所以想拿出來單獨說一說。

好了,讓我們進入正題。

什麼是偽共享

首先大家都知道,隨著CPU和記憶體的發展速度差異的問題,導致CPU的速度遠遠快於記憶體,所以一般現在的CPU都加入了高速快取,就是常說的解決不同硬體之間的性能差異問題。

這樣的話,很簡單的道理,加入了快取,就必然會導致快取一致性的問題,由此,又引入了快取一致性協議。(如果你不知道,建議去百度一下,這裡不做展開)

CPU快取,顧名思義,越貼近CPU的快取速度越快,容量越小,造價成本也越高,而高速快取一般可以分為L1、L2、L3三級快取,按照性能的劃分:L1>L2>L3。

而事實上,數據在快取內部都是按照來存儲的,這就叫做快取行。快取行一般都是2的整數冪個位元組,一般來說範圍在32-256個位元組之間,現在最為常見的快取行的大小在64個位元組。

所以,按照這個存儲方式,快取中的數據並不是一個個單獨的變數的存儲方式,而是多個變數會放到一行中。

我們常說的一個例子就是數組和鏈表,數組的記憶體地址是連續的,當我們去讀取數組中的元素時,CPU會把數組中後續的若干個元素也載入到快取中,以此提高效率,但是鏈表則不會,也就是說,記憶體地址連續的變數才有可能被放到一個快取行中

在多個執行緒並發修改一個快取行中的多個變數時,由於只能同時有一個執行緒去操作快取行,將會導致性能的下降,這個問題就稱之為偽共享

為什麼只有一個執行緒能去操作?我們舉個實際的栗子來說明這種情況:

假設快取中有x,y兩個變數,他們同時已經在不同的三級快取之中。

這時有兩個執行緒A和B同時去修改位於Core1和Core2的變數xy

如果執行緒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中提供了多個原子變數的操作類,就是比如AtomicLongAtomicInteger這些,通過CAS的方式去更新變數,但是失敗會無限自旋嘗試,導致CPU資源的浪費。

為了解決高並發下的這個缺點,JDK8中新增了LongAdder類,他的使用就是對解決偽共享的實際應用。

LongAdder繼承自Striped64,內部維護了一個Cell數組,核心思想就是把單個變數的競爭拆分,多執行緒下如果一個Cell競爭失敗,轉而去其他Cell再次CAS重試。

Striped64成員變數Striped64成員變數

解決偽共享的真正的核心就在Cell數組,可以看到,Cell數組使用了Contented註解。

在上面我們提到數組的記憶體地址都是連續的,所以數組內的元素經常會被放入一個快取行,這樣的話就會帶來偽共享的問題,影響性能。

這裡使用Contented進行填充,就避免了偽共享的問題,使得數組中的元素不再共享一個快取行。

解決偽共享解決偽共享

好了,今天的內容就到這裡,我是艾小仙,我的slogan還沒想好,但是我們下次見。