本文基于: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ò)的值)。
一個(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è)就屬于硬
件程序員給軟件程序員挖的“坑”击蹲。
下面我們?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í)差盏求。
源頭之二:線程切換帶來(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管闷。
我們潛意識(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ā)空
指針異常。