01 可見性惠窄,原子性和有序性問(wèn)題

本文基于:java并發(fā)編程實(shí)戰(zhàn) 極客專欄 王寶令

如果你細(xì)心觀察的話蒸眠,你會(huì)發(fā)現(xiàn),不管是哪一門編程語(yǔ)言杆融,并發(fā)類的知識(shí)都是在高級(jí)篇

里楞卡。換句話說(shuō),這塊知識(shí)點(diǎn)其實(shí)對(duì)于程序員來(lái)說(shuō),是比較進(jìn)階的知識(shí)蒋腮。我自己這么多年學(xué)

習(xí)過(guò)來(lái)淘捡,也確實(shí)覺得并發(fā)是比較難的,因?yàn)樗鼤?huì)涉及到很多的底層知識(shí)池摧,比如若你對(duì)操作

系統(tǒng)相關(guān)的知識(shí)一無(wú)所知的話焦除,那去理解一些原理就會(huì)費(fèi)些力氣。這是我們整個(gè)專欄的第

一篇文章作彤,我說(shuō)這些話的意思是如果你在中間遇到自己沒想通的問(wèn)題膘魄,可以去查閱資料,

也可以在評(píng)論區(qū)找我竭讳,以保證你能夠跟上學(xué)習(xí)進(jìn)度瓣距。

你我都知道,編寫正確的并發(fā)程序是一件極困難的事情代咸,并發(fā)程序的 Bug 往往會(huì)詭異地出

現(xiàn)蹈丸,然后又詭異地消失,很難重現(xiàn)呐芥,也很難追蹤逻杖,很多時(shí)候都讓人很抓狂。但要快速而又

精準(zhǔn)地解決“并發(fā)”類的疑難雜癥思瘟,你就要理解這件事情的本質(zhì)荸百,追本溯源,深入分析這

些 Bug 的源頭在哪里滨攻。

那為什么并發(fā)編程容易出問(wèn)題呢够话?它是怎么出問(wèn)題的?今天我們就重點(diǎn)聊聊這些 Bug 的源

頭光绕。

并發(fā)程序幕后的故事

這些年女嘲,我們的 CPU、內(nèi)存诞帐、I/O 設(shè)備都在不斷迭代欣尼,不斷朝著更快的方向努力。但是停蕉,

在這個(gè)快速發(fā)展的過(guò)程中愕鼓,有一個(gè)核心矛盾一直存在,就是這三者的速度差異慧起。CPU 和內(nèi)

存的速度差異可以形象地描述為:CPU 是天上一天菇晃,內(nèi)存是地上一年(假設(shè) CPU 執(zhí)行一

條普通指令需要一天,那么 CPU 讀寫內(nèi)存得等待一年的時(shí)間)蚓挤。內(nèi)存和 I/O 設(shè)備的速度

差異就更大了磺送,內(nèi)存是天上一天剩失,I/O 設(shè)備是地上十年。

程序里大部分語(yǔ)句都要訪問(wèn)內(nèi)存册着,有些還要訪問(wèn) I/O拴孤,根據(jù)木桶理論(一只水桶能裝多少

水取決于它最短的那塊木板),程序整體的性能取決于最慢的操作——讀寫 I/O 設(shè)備甲捏,也

就是說(shuō)單方面提高 CPU 性能是無(wú)效的演熟。

為了合理利用 CPU 的高性能,平衡這三者的速度差異司顿,計(jì)算機(jī)體系機(jī)構(gòu)芒粹、操作系統(tǒng)、編譯

程序都做出了貢獻(xiàn)大溜,主要體現(xiàn)為:

1. CPU 增加了緩存化漆,以均衡與內(nèi)存的速度差異;

2. 操作系統(tǒng)增加了進(jìn)程钦奋、線程座云,以分時(shí)復(fù)用 CPU,進(jìn)而均衡 CPU 與 I/O 設(shè)備的速度差

異付材;

3. 編譯程序優(yōu)化指令執(zhí)行次序朦拖,使得緩存能夠得到更加合理地利用。

現(xiàn)在我們幾乎所有的程序都默默地享受著這些成果厌衔,但是天下沒有免費(fèi)的午餐璧帝,并發(fā)程序

很多詭異問(wèn)題的根源也在這里。

源頭之一:緩存導(dǎo)致的可見性問(wèn)題

在單核時(shí)代富寿,所有的線程都是在一顆 CPU 上執(zhí)行睬隶,CPU 緩存與內(nèi)存的數(shù)據(jù)一致性容易解

決。因?yàn)樗芯€程都是操作同一個(gè) CPU 的緩存页徐,一個(gè)線程對(duì)緩存的寫苏潜,對(duì)另外一個(gè)線程來(lái)

說(shuō)一定是可見的。例如在下面的圖中泞坦,線程 A 和線程 B 都是操作同一個(gè) CPU 里面的緩

存窖贤,所以線程 A 更新了變量 V 的值砖顷,那么線程 B 之后再訪問(wèn)變量 V贰锁,得到的一定是 V 的

最新值(線程 A 寫過(guò)的值)。


CPU 緩存與內(nèi)存的關(guān)系圖

一個(gè)線程對(duì)共享變量的修改滤蝠,另外一個(gè)線程能夠立刻看到豌熄,我們稱為可見性。

多核時(shí)代物咳,每顆 CPU 都有自己的緩存锣险,這時(shí) CPU 緩存與內(nèi)存的數(shù)據(jù)一致性就沒那么容易

解決了,當(dāng)多個(gè)線程在不同的 CPU 上執(zhí)行時(shí),這些線程操作的是不同的 CPU 緩存芯肤。比如

下圖中巷折,線程 A 操作的是 CPU-1 上的緩存,而線程 B 操作的是 CPU-2 上的緩存崖咨,很明

顯锻拘,這個(gè)時(shí)候線程 A 對(duì)變量 V 的操作對(duì)于線程 B 而言就不具備可見性了。這個(gè)就屬于硬

件程序員給軟件程序員挖的“坑”击蹲。


多核 CPU 的緩存與內(nèi)存關(guān)系圖

下面我們?cè)儆靡欢未a來(lái)驗(yàn)證一下多核場(chǎng)景下的可見性問(wèn)題署拟。下面的代碼,每執(zhí)行一次

add10K() 方法歌豺,都會(huì)循環(huán) 10000 次 count+=1 操作推穷。在 calc() 方法中我們創(chuàng)建了兩個(gè)

線程,每個(gè)線程調(diào)用一次 add10K() 方法类咧,我們來(lái)想一想執(zhí)行 calc() 方法得到的結(jié)果應(yīng)該

是多少呢馒铃?


直覺告訴我們應(yīng)該是 20000,因?yàn)樵趩尉€程里調(diào)用兩次 add10K() 方法痕惋,count 的值就是

20000骗露,但實(shí)際上 calc() 的執(zhí)行結(jié)果是個(gè) 10000 到 20000 之間的隨機(jī)數(shù)。為什么呢血巍?

我們假設(shè)線程 A 和線程 B 同時(shí)開始執(zhí)行萧锉,那么第一次都會(huì)將 count=0 讀到各自的 CPU

緩存里,執(zhí)行完 count+=1 之后述寡,各自 CPU 緩存里的值都是 1柿隙,同時(shí)寫入內(nèi)存后,我們

會(huì)發(fā)現(xiàn)內(nèi)存中是 1鲫凶,而不是我們期望的 2禀崖。之后由于各自的 CPU 緩存里都有了 count 的

值,兩個(gè)線程都是基于 CPU 緩存里的 count 值來(lái)計(jì)算螟炫,所以導(dǎo)致最終 count 的值都是小

于 20000 的波附。這就是緩存的可見性問(wèn)題。

循環(huán) 10000 次 count+=1 操作如果改為循環(huán) 1 億次昼钻,你會(huì)發(fā)現(xiàn)效果更明顯掸屡,最終 count

的值接近 1 億,而不是 2 億然评。如果循環(huán) 10000 次仅财,count 的值接近 20000,原因是兩個(gè)

線程不是同時(shí)啟動(dòng)的碗淌,有一個(gè)時(shí)差盏求。


變量 count 在 CPU 緩存和內(nèi)存的分布圖

源頭之二:線程切換帶來(lái)的原子性問(wèn)題

由于 IO 太慢抖锥,早期的操作系統(tǒng)就發(fā)明了多進(jìn)程,即便在單核的 CPU 上我們也可以一邊聽

著歌碎罚,一邊寫 Bug磅废,這個(gè)就是多進(jìn)程的功勞。

操作系統(tǒng)允許某個(gè)進(jìn)程執(zhí)行一小段時(shí)間荆烈,例如 50 毫秒还蹲,過(guò)了 50 毫秒操作系統(tǒng)就會(huì)重新選

擇一個(gè)進(jìn)程來(lái)執(zhí)行(我們稱為“任務(wù)切換”),這個(gè) 50 毫秒稱為“時(shí)間片”耙考。


線程切換示意圖

在一個(gè)時(shí)間片內(nèi)谜喊,如果一個(gè)進(jìn)程進(jìn)行一個(gè) IO 操作,例如讀個(gè)文件倦始,這個(gè)時(shí)候該進(jìn)程可以

把自己標(biāo)記為“休眠狀態(tài)”并出讓 CPU 的使用權(quán)斗遏,待文件讀進(jìn)內(nèi)存,操作系統(tǒng)會(huì)把這個(gè)休

眠的進(jìn)程喚醒鞋邑,喚醒后的進(jìn)程就有機(jī)會(huì)重新獲得 CPU 的使用權(quán)了诵次。

這里的進(jìn)程在等待 IO 時(shí)之所以會(huì)釋放 CPU 使用權(quán),是為了讓 CPU 在這段等待時(shí)間里可

以做別的事情枚碗,這樣一來(lái) CPU 的使用率就上來(lái)了逾一;此外,如果這時(shí)有另外一個(gè)進(jìn)程也讀文

件肮雨,讀文件的操作就會(huì)排隊(duì)遵堵,磁盤驅(qū)動(dòng)在完成一個(gè)進(jìn)程的讀操作后,發(fā)現(xiàn)有排隊(duì)的任務(wù)怨规,

就會(huì)立即啟動(dòng)下一個(gè)讀操作陌宿,這樣 IO 的使用率也上來(lái)了。

是不是很簡(jiǎn)單的邏輯波丰?但是壳坪,雖然看似簡(jiǎn)單,支持多進(jìn)程分時(shí)復(fù)用在操作系統(tǒng)的發(fā)展史上

卻具有里程碑意義掰烟,Unix 就是因?yàn)榻鉀Q了這個(gè)問(wèn)題而名噪天下的爽蝴。

早期的操作系統(tǒng)基于進(jìn)程來(lái)調(diào)度 CPU,不同進(jìn)程間是不共享內(nèi)存空間的纫骑,所以進(jìn)程要做任

務(wù)切換就要切換內(nèi)存映射地址蝎亚,而一個(gè)進(jìn)程創(chuàng)建的所有線程,都是共享一個(gè)內(nèi)存空間的惧磺,

所以線程做任務(wù)切換成本就很低了∮倍裕現(xiàn)代的操作系統(tǒng)都基于更輕量的線程來(lái)調(diào)度,現(xiàn)在我

們提到的“任務(wù)切換”都是指“線程切換”磨隘。

Java 并發(fā)程序都是基于多線程的缤底,自然也會(huì)涉及到任務(wù)切換,也許你想不到番捂,任務(wù)切換竟

然也是并發(fā)編程里詭異 Bug 的源頭之一个唧。任務(wù)切換的時(shí)機(jī)大多數(shù)是在時(shí)間片結(jié)束的時(shí)候,

我們現(xiàn)在基本都使用高級(jí)語(yǔ)言編程设预,高級(jí)語(yǔ)言里一條語(yǔ)句往往需要多條 CPU 指令完成徙歼,例

如上面代碼中的count += 1,至少需要三條 CPU 指令鳖枕。

指令 1:首先魄梯,需要把變量 count 從內(nèi)存加載到 CPU 的寄存器;

指令 2:之后宾符,在寄存器中執(zhí)行 +1 操作酿秸;

指令 3:最后,將結(jié)果寫入內(nèi)存(緩存機(jī)制導(dǎo)致可能寫入的是 CPU 緩存而不是內(nèi)存)魏烫。

操作系統(tǒng)做任務(wù)切換辣苏,可以發(fā)生在任何一條CPU 指令執(zhí)行完,是的哄褒,是 CPU 指令稀蟋,而不

是高級(jí)語(yǔ)言里的一條語(yǔ)句。對(duì)于上面的三條指令來(lái)說(shuō)呐赡,我們假設(shè) count=0退客,如果線程 A

在指令 1 執(zhí)行完后做線程切換,線程 A 和線程 B 按照下圖的序列執(zhí)行链嘀,那么我們會(huì)發(fā)現(xiàn)

兩個(gè)線程都執(zhí)行了 count+=1 的操作井辜,但是得到的結(jié)果不是我們期望的 2,而是 1管闷。


非原子操作的執(zhí)行路徑示意圖

我們潛意識(shí)里面覺得 count+=1 這個(gè)操作是一個(gè)不可分割的整體粥脚,就像一個(gè)原子一樣,線

程的切換可以發(fā)生在 count+=1 之前包个,也可以發(fā)生在 count+=1 之后刷允,但就是不會(huì)發(fā)生

在中間。我們把一個(gè)或者多個(gè)操作在 CPU 執(zhí)行的過(guò)程中不被中斷的特性稱為原子性碧囊。

CPU 能保證的原子操作是 CPU 指令級(jí)別的树灶,而不是高級(jí)語(yǔ)言的操作符,這是違背我們直

覺的地方糯而。因此天通,很多時(shí)候我們需要在高級(jí)語(yǔ)言層面保證操作的原子性。

源頭之三:編譯優(yōu)化帶來(lái)的有序性問(wèn)題

那并發(fā)編程里還有沒有其他有違直覺容易導(dǎo)致詭異 Bug 的技術(shù)呢熄驼?有的像寒,就是有序性烘豹。顧

名思義,有序性指的是程序按照代碼的先后順序執(zhí)行诺祸。編譯器為了優(yōu)化性能携悯,有時(shí)候會(huì)改

變程序中語(yǔ)句的先后順序,例如程序中:“a=6筷笨;b=7憔鬼;”編譯器優(yōu)化后可能變

成“b=7;a=6胃夏;”轴或,在這個(gè)例子中,編譯器調(diào)整了語(yǔ)句的順序仰禀,但是不影響程序的最終

結(jié)果照雁。不過(guò)有時(shí)候編譯器及解釋器的優(yōu)化可能導(dǎo)致意想不到的 Bug。

在 Java 領(lǐng)域一個(gè)經(jīng)典的案例就是利用雙重檢查創(chuàng)建單例對(duì)象悼瘾,例如下面的代碼:在獲取實(shí)

例 getInstance() 的方法中囊榜,我們首先判斷 instance 是否為空,如果為空亥宿,則鎖定

Singleton.class 并再次檢查 instance 是否為空卸勺,如果還為空則創(chuàng)建 Singleton 的一個(gè)實(shí)

例。

假設(shè)有兩個(gè)線程 A烫扼、B 同時(shí)調(diào)用 getInstance() 方法曙求,他們會(huì)同時(shí)發(fā)現(xiàn) instance ==null ,

于是同時(shí)對(duì) Singleton.class 加鎖映企,此時(shí) JVM 保證只有一個(gè)線程能夠加鎖成功

(假設(shè)是線程 A)悟狱,另外一個(gè)線程則會(huì)處于等待狀態(tài)(假設(shè)是線程 B);線程 A 會(huì)創(chuàng)建一

個(gè) Singleton 實(shí)例堰氓,之后釋放鎖挤渐,鎖釋放后,線程 B 被喚醒双絮,線程 B 再次嘗試加鎖浴麻,此

時(shí)是可以加鎖成功的,加鎖成功后囤攀,線程 B 檢查 instance == null 時(shí)會(huì)發(fā)現(xiàn)软免,已經(jīng)創(chuàng)

建過(guò) Singleton 實(shí)例了,所以線程 B 不會(huì)再創(chuàng)建一個(gè) Singleton 實(shí)例焚挠。

這看上去一切都很完美膏萧,無(wú)懈可擊,但實(shí)際上這個(gè) getInstance() 方法并不完美。問(wèn)題出

在哪里呢榛泛?出在 new 操作上蝌蹂,我們以為的 new 操作應(yīng)該是:

1. 分配一塊內(nèi)存 M;

2. 在內(nèi)存 M 上初始化 Singleton 對(duì)象挟鸠;

3. 然后 M 的地址賦值給 instance 變量叉信。

但是實(shí)際上優(yōu)化后的執(zhí)行路徑卻是這樣的:

1. 分配一塊內(nèi)存 M亩冬;

2. 將 M 的地址賦值給 instance 變量艘希;

3. 最后在內(nèi)存 M 上初始化 Singleton 對(duì)象。

優(yōu)化后會(huì)導(dǎo)致什么問(wèn)題呢硅急?我們假設(shè)線程 A 先執(zhí)行 getInstance() 方法覆享,當(dāng)執(zhí)行完指令 2

時(shí)恰好發(fā)生了線程切換,切換到了線程 B 上营袜;如果此時(shí)線程 B 也執(zhí)行 getInstance() 方

法撒顿,那么線程 B 會(huì)發(fā)現(xiàn)instance != null,所以直接返回 instance荚板,而此時(shí)的

instance 是沒有初始化過(guò)的凤壁,如果我們這個(gè)時(shí)候訪問(wèn) instance 的成員變量就可能觸發(fā)空

指針異常。

雙重檢查創(chuàng)建單例的異常執(zhí)行路徑
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末跪另,一起剝皮案震驚了整個(gè)濱河市拧抖,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌免绿,老刑警劉巖唧席,帶你破解...
    沈念sama閱讀 221,576評(píng)論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異嘲驾,居然都是意外死亡淌哟,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,515評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門辽故,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)徒仓,“玉大人,你說(shuō)我怎么就攤上這事誊垢〉舫冢” “怎么了?”我有些...
    開封第一講書人閱讀 168,017評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵彤枢,是天一觀的道長(zhǎng)狰晚。 經(jīng)常有香客問(wèn)我,道長(zhǎng)缴啡,這世上最難降的妖魔是什么壁晒? 我笑而不...
    開封第一講書人閱讀 59,626評(píng)論 1 296
  • 正文 為了忘掉前任,我火速辦了婚禮业栅,結(jié)果婚禮上秒咐,老公的妹妹穿的比我還像新娘谬晕。我一直安慰自己,他們只是感情好携取,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,625評(píng)論 6 397
  • 文/花漫 我一把揭開白布攒钳。 她就那樣靜靜地躺著,像睡著了一般雷滋。 火紅的嫁衣襯著肌膚如雪不撑。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,255評(píng)論 1 308
  • 那天晤斩,我揣著相機(jī)與錄音焕檬,去河邊找鬼。 笑死澳泵,一個(gè)胖子當(dāng)著我的面吹牛实愚,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播兔辅,決...
    沈念sama閱讀 40,825評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼腊敲,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了维苔?” 一聲冷哼從身側(cè)響起碰辅,我...
    開封第一講書人閱讀 39,729評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎蕉鸳,沒想到半個(gè)月后乎赴,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,271評(píng)論 1 320
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡潮尝,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,363評(píng)論 3 340
  • 正文 我和宋清朗相戀三年榕吼,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片勉失。...
    茶點(diǎn)故事閱讀 40,498評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡羹蚣,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出乱凿,到底是詐尸還是另有隱情顽素,我是刑警寧澤,帶...
    沈念sama閱讀 36,183評(píng)論 5 350
  • 正文 年R本政府宣布徒蟆,位于F島的核電站胁出,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏段审。R本人自食惡果不足惜全蝶,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,867評(píng)論 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧抑淫,春花似錦绷落、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,338評(píng)論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至催式,卻和暖如春函喉,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背蓄氧。 一陣腳步聲響...
    開封第一講書人閱讀 33,458評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工函似, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留槐脏,地道東北人喉童。 一個(gè)月前我還...
    沈念sama閱讀 48,906評(píng)論 3 376
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像顿天,于是被迫代替她去往敵國(guó)和親堂氯。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,507評(píng)論 2 359

推薦閱讀更多精彩內(nèi)容

  • CPU、內(nèi)存以及I/O設(shè)備都在不斷迭代鸟缕,不斷朝著更快的方向努力晶框。但是,在這個(gè)快速發(fā)展的過(guò)程中懂从,有一個(gè)核心矛盾一直存...
    頹靡浪蕩君閱讀 298評(píng)論 0 1
  • Swift1> Swift和OC的區(qū)別1.1> Swift沒有地址/指針的概念1.2> 泛型1.3> 類型嚴(yán)謹(jǐn) 對(duì)...
    cosWriter閱讀 11,111評(píng)論 1 32
  • 以上代碼會(huì)重復(fù)運(yùn)行 授段, 不會(huì)停止。 JMM(java內(nèi)存模型) 若想學(xué)習(xí)好多線程番甩, 那么必須了解一下JMM Jav...
    尼爾君閱讀 1,754評(píng)論 0 2
  • Java內(nèi)存區(qū)域 Java虛擬機(jī)在運(yùn)行程序時(shí)會(huì)把其自動(dòng)管理的內(nèi)存劃分為以上幾個(gè)區(qū)域侵贵,每個(gè)區(qū)域都有的用途以及創(chuàng)建銷毀...
    架構(gòu)師springboot閱讀 1,776評(píng)論 0 5
  • 近來(lái)讀韋力的《覓宗記》。韋力是目前國(guó)內(nèi)收藏古書最多的一個(gè)很有學(xué)問(wèn)的人缘薛。我記得他有有個(gè)頭銜是北京博物館的研究員窍育。他的...
    小說(shuō)_54df閱讀 603評(píng)論 0 0