我們的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è)備,也就是所單方面提高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í)行次序,使得緩存能夠得到更加合理的利用.
并發(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ù)一只熊就沒(méi)那么容易解決了,當(dāng)多個(gè)線程再不同的CPU上執(zhí)行時(shí),這些線程操作的是不同的CPU緩存.比如下圖中,線程A操作的是CPU-1上的緩存,而線程B操作的是CPU-2上的緩存,很明顯,這時(shí)候線程A對(duì)變量V的操作對(duì)于線程B而言就不具備可見性了.
用代碼驗(yàn)證多核場(chǎng)景下可見性問(wèn)題,下面的代碼,每執(zhí)行一次add10k()方法,都會(huì)循環(huán)10000次count+=1操作,在calc()方法中創(chuàng)建了兩個(gè)線程,每次線程調(diào)用依次add10k()方法,calc()方法得到的結(jié)果會(huì)是多少?
看似是20000,因?yàn)樵趩尉€程里帶哦用兩次add10k()方法,count值就是20000,但實(shí)際上calc()的執(zhí)行結(jié)果是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操作如果改成1億次,效果會(huì)更明顯,最終count的值接近1億,而不是2億.如果循環(huán)10000次,count的值接近20000,原因是兩個(gè)線程不是同時(shí)啟動(dòng)的,有一個(gè)時(shí)差.
源頭二:線程切換帶來(lái)的原子性問(wèn)題
由于I/O太慢,早期的操作系統(tǒng)就發(fā)明了多進(jìn)程,即便在單核的CPU上我們可以一邊寫bug一邊聽歌.
操作系統(tǒng)允許某個(gè)線程執(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)單的邏輯?
早期的操作系統(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ù)切換的時(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 指令,是 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)題
有序性指的是程序按照代碼的先后順序執(zhí)行魂莫。編譯器為了優(yōu)化性能,有時(shí)候會(huì)改變程序中語(yǔ)句的先后順序爹耗,例如程序中:“a=6耙考;b=7;”編譯器優(yōu)化后可能變成“b=7潭兽;a=6
在 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í)例.
看上去沒(méi)毛病.但是getInstance()方法不完美.問(wèn)題出在new操作上,你以為的new操作是這樣:
1.開辟一塊內(nèi)存M
2.在內(nèi)存M上初始化Singleton對(duì)象,
3.然后M的地址賦值給instance變量.
實(shí)際上優(yōu)化后是這樣:
1.開辟一塊內(nèi)存M
2.M的地址賦值給instance變量.
3.在內(nèi)存M上初始化Singleton對(duì)象
我們假設(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 是沒(méi)有初始化過(guò)的,如果我們這個(gè)時(shí)候訪問(wèn) instance 的成員變量就可能觸發(fā)空指針異常
總結(jié)
只要我們能夠深刻理解可見性磨隘、原子性惜互、有序性在并發(fā)場(chǎng)景下的原理,很多并發(fā) Bug 都是可以理解琳拭、可以診斷的训堆。
緩存導(dǎo)致的可見性問(wèn)題,線程切換帶來(lái)的原子性問(wèn)題白嘁,編譯優(yōu)化帶來(lái)的有序性問(wèn)題坑鱼,其實(shí)緩存、線程絮缅、編譯優(yōu)化的目的和我們寫并發(fā)程序的目的是相同的鲁沥,都是提高程序性能。但是技術(shù)在解決一個(gè)問(wèn)題的同時(shí)耕魄,必然會(huì)帶來(lái)另外一個(gè)問(wèn)題画恰,所以在采用一項(xiàng)技術(shù)的同時(shí),一定要清楚它帶來(lái)的問(wèn)題是什么吸奴,以及如何規(guī)避允扇。