填之前的坑,偽共享
大家好,我是yes。
之前在寫 FastThreadLocal 的時候,挖了個坑。
咳咳,時間過得有點久了,但是影響不大今天就來補上。
來談談什么是偽共享,并且為什么 Netty 要在這里移除這個優化?
話不多說,發車!
什么是偽共享?
這個名詞聽著有點高級的感覺,實際上很好理解。
我們都知道 CPU 的執行速度遠大于從內存獲取數據的速度,為了減少這個差距科研人員們就不斷的研究,產出了高速緩存,但這個高速緩存由于工藝集成度問題,無法作為主存的介質,所以常見的 CPU 緩存結構如下圖所示:
L1、L2、L3則為 CPU 和主存之間的高速緩沖區,距離 CPU 越近的緩存訪問速度越快,且容量越小。
比如我筆記本的 CPU上:
訪問速度:L1>L2>L3>主存。
L1 和 L2 是單核 CPU 獨享的,當 CPU 訪問數據的時候會先去 L1 上面找,找不到再去 L2,然后是 L3,最后是主存。所以當對一個數據重復計算的時候,應該盡量保證數據在 L1 中,這樣效率才高。
從上面的結構來看,有經驗的同學肯定會發現上面的結構有共享內存多線程的問題。這里就引入了一致性協議 MESI。具體協議內容這里不作展開,這里簡單舉例理解下:
當 cpu1 和 cpu3 共同訪問主存里面的一個數據時,會分別獲取放置到自己高速緩沖區中,當 cpu1 修改了這個數據之后,cpu3 的高速緩沖區中這個數據就失效了,它會讓 cpu1 把這個改動刷新到主存中,然后自己再去主存加載這個數據,這樣數據才會正確。
圖中按序號順序來閱讀,應該不難理解。
然后重點來了,CPU 緩存的單位是緩存行,也就是說 CPU 從主存拿數據不是一個一個拿,是一行一行的拿,這一行的大小一般是 64 字節,那問題就來了。
比如,現在有個 long 數組,大小為 8 ,那剛好這個數組滿足一行的大小。現在 cpu1 頻繁更新long[0]的值,而 cpu3 頻繁更新 long[5] 的值,這就有點麻了。
由于緩存行的機制,每次 cpu1 會把整個數組都加載到緩存中,每次僅修改 long[0] 也會使得這一行都變臟,此時 cpu3 訪問的 long[5] 就失效了,因此 cpu3 需要讓 cpu1 把修改刷新到主存中,然后它從主存重新獲取 long[5] 再進行操作,假設此時 cpu1 又修改了 long[0],則上面的操作就又得來一遍!
明明修改的是不同的變量,但是卻相互影響了,這種情況,就稱之為,偽共享!
如何避免偽共享問題?
解決的方案非常簡單粗暴,填充。
把可能會沖突的數據在內存上隔開來,用什么隔?用無用的數據隔開。
在關鍵數據前后(上圖僅填充了后)填充無用的數據,讓一個緩存行中,僅會存在一個有效的數據,其它都是無效的數據,就避免了一個緩存行里面出現多個有效的數據。這樣一來不同的 CPU 核心修改不同的數據就不會造成其它數據緩存失效,避免了偽共享的問題。
所以 Netty 里 InternalThreadLocalMap 中奇怪的代碼就是起這個作用的。
但恕我直言,可能是我等級太低,我沒看出來這玩意到底是為了哪個變量而填充的。
果然,最新的版本有個大佬把它標注為廢棄。
我從 github 上看了看,大佬將其廢棄的理由如下:
簡單直白的翻譯下:
我看不出填充有什么切實的好處。
唯一保護的對象可能是 BitSet,但是它的修改并不頻繁
填充用了 long,這并不一定會阻止 JVM 在對齊間隙中匹配上述的對象引用。
簡單來講就是沒發現這填充有啥好用,所以廢棄了,將來版本要咔嚓了它。
所以拿 Netty 來展示偽共享的例子不行(我只是把之前寫 FastThreadLocal 的坑填了)。
現在填完了,我們換個好的例子。
用代碼跑跑看
我寫了個例子,咱們來看看填充和不填充的真實差距。
我用兩個線程分別循環五千萬次修改一個對象里面的兩個變量 a 和 b,這兩個變量大概率會在同一個緩存行中,這樣就制造了偽共享的現場。
在未填充的情況下,耗費的毫秒數是1400。
然后我們再用變量p1-p7填充一下,隔開 a 和 b。
可以看到,結果變成了380毫秒,這么一看,確實生效了!說明填充確實有效!
其實 Java 提供了一個注解 @Contended,可以標記到指定的字段上,減少偽共享的發生,你可以認為這個注解會讓 JVM 自動幫我們填充,而不需要我們手寫填充的變量。不過要注意一點,這個注解需要啟動時添加-XX:-RestrictContended 參數,才會生效。
我們跑一下看下結果:
果然,也提高了效率!
這個注解其實在別的地方也有應用,比如 ConcurrentHashMap 里的 CounterCell
還有 Striped64 里的 Cell
不過要注意,沒有-XX:-RestrictContended 不會生效的!
最后
至此,想必你已經明白了什么是偽共享,并且可以利用填充來避免偽共享的問題。
但填充就代表著空間的浪費,也不是什么情況下都需要填充。
只有在頻繁更新相鄰字段的情況下,才可能需要考慮偽共享的情況,別的情況不需要下操心。
好了,今天就到這了。