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