如果你細心觀察的話慷嗜,你會發(fā)現(xiàn),不管是哪一門語言丹壕,并發(fā)類的只是都是在高級篇里庆械,換句話說,這塊知識點對于程序員來講是比較進階的知識雀费,比如你若對操作系統(tǒng)的知識一無所知的話,那去理解一些原理就會比較費力氣痊焊。
你我都知道編寫并發(fā)程序是一件困難的事情盏袄,并發(fā)程序的Bug 往往會詭異的出現(xiàn),然后又詭異的消失薄啥,很多時候讓人很抓狂辕羽。但要快速而精準(zhǔn)的解決“并發(fā)” 類的疑難雜癥,你就要了解事情的本質(zhì)垄惧,追本溯源刁愿,深入分析Bug 的源頭。
并發(fā)程序幕后的故事
這些年到逊,我們的 CPU铣口、內(nèi)存滤钱、I/O 設(shè)備都在不斷迭代, 不斷朝著更快的方向努力脑题,但是件缸,在這個快速發(fā)展的過程中,有一個核心矛盾一直存在叔遂,就是者三者的速度差異他炊,CPU 和內(nèi)存的速度差異可以形象地描述為:CPU 是天上一天,內(nèi)存是地上一年(假設(shè) CPU 執(zhí)行一條普通指令需要一天已艰,那么 CPU 讀寫內(nèi)存得等待一年的時間)痊末。內(nèi)存和 I/O 設(shè)備的速度差異就更大了,內(nèi)存是天上一天哩掺,I/O 設(shè)備是地上十年凿叠。
程序里大部分語句都要訪問內(nèi)存,有些還要訪問I/O疮丛, 根據(jù)木桶理論幔嫂,程序的整體性能取決于最慢的讀寫-讀寫 I/O 設(shè)備, 也就是說單方面提高CPU 性能是無效的誊薄。
為了合理利用CPU 的高性能履恩,平衡者三者的差異,計算機體系結(jié)構(gòu)呢蔫,操作系統(tǒng)切心,編譯程序都做出了貢獻,主要體現(xiàn)為:
1片吊、CPU 增加了緩存绽昏,以均衡與內(nèi)存的速度差異;
2俏脊、操作系統(tǒng)增加了進程全谤、線程,以分時復(fù)用CPU 爷贫,進而均衡CPU 與 I/O 設(shè)備的速度差異认然;
3、編譯程序優(yōu)化指令執(zhí)行次序漫萄,使得緩存能夠更加合理的利用卷员。
現(xiàn)在我們都在默默的享受著這些成果,但是天下沒有免費的午餐腾务,并發(fā)程序很多詭異問題的根源也在這里毕骡。
源頭之一:緩存導(dǎo)致的可見性問題
在單核時代,所有的線程都是在一顆 CPU 上執(zhí)行,CPU 緩存與數(shù)據(jù)的一致性容易解決未巫。因為所有的線程都是操作同一個CPU 的緩存窿撬, 一個線程對緩存的讀寫,對另一個線程來說一定是可見的橱赠。例如在下圖中線程 A 和線程 B 都是操作同一個 CPU 里面的緩存尤仍,所以線程 A 更新了變量 V 的值,那么線程 B 之后再訪問變量 V狭姨,得到的一定是 V 的最新值(線程 A 寫過的值)宰啦。
一個線程對共享變量的修改,另外一個線程能夠立刻看到饼拍,我們成為可見性赡模。
多核時代,每顆CPU都有自己的緩存师抄,這時漓柑,CPU 緩存與內(nèi)存的數(shù)據(jù)一致性就沒那么容易解決了,當(dāng)多個線程在不同的CPU 上執(zhí)行時叨吮,這些線程操作的時不同的CPU 緩存辆布,比如下圖中 線程 A 操作的是 CPU-1 上的緩存,而線程 B 操作的是 CPU-2 上的緩存茶鉴,很明顯锋玲,這個時候線程 A 對變量 V 的操作對于線程 B 而言就不具備可見性了。這個就屬于硬件程序員給軟件程序員挖的“坑”涵叮。
下面我們再用一段代碼來驗證一下多核場景下的可見性問題惭蹂。下面的代碼,每執(zhí)行一次 add10K() 方法割粮,都會循環(huán) 10000 次 count+=1 操作盾碗。在 calc() 方法中我們創(chuàng)建了兩個線程,每個線程調(diào)用一次 add10K() 方法舀瓢,我們來想一想執(zhí)行 calc() 方法得到的結(jié)果應(yīng)該是多少呢廷雅?
當(dāng)只有一個線程調(diào)用的時候
public class CounterTest {
private int count = 0;
private void add10k() {
for (int idx = 0; idx < 10000; idx++) {
count += 1;
}
}
public static void main(String[] args) {
CounterTest test = new CounterTest();
//當(dāng)只有一個線程調(diào)用時,輸出結(jié)果
new Thread(() -> {
test.add10k();
System.out.println(test.count);
}).start();
System.out.println("main thread is over");
}
}
輸出結(jié)果:
main thread is over
10000
增加一個線程:
public class CounterTest {
private int count = 0;
private void add10k() {
for (int idx = 0; idx < 10000; idx++) {
count += 1;
}
}
public static void main(String[] args) throws Exception {
CounterTest test = new CounterTest();
//當(dāng)只有一個線程調(diào)用時京髓,輸出結(jié)果
Thread t1 = new Thread(() -> {
test.add10k();
System.out.println(test.count);
});
//增加一個線程
Thread t2 = new Thread(() -> {
test.add10k();
System.out.println(test.count);
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("main thread is over");
}
}
輸出結(jié)果:
11827
19664
main thread is over
直覺告訴我們應(yīng)該是 20000航缀,因為在單線程里調(diào)用兩次 add10K() 方法,count 的值就是 20000朵锣,但實際上 calc() 的執(zhí)行結(jié)果是個 10000 到 20000 之間的隨機數(shù)谬盐。為什么呢甸私?
我們假設(shè)線程 A 和線程 B 同時開始執(zhí)行诚些, 那么第一次都會將count=0 讀到各自的 CPU 緩存里,執(zhí)行完 count+=1 之后,各自 CPU 緩存里的值都是 1诬烹,同時寫入內(nèi)存后砸烦,我們會發(fā)現(xiàn)內(nèi)存中是 1,而不是我們期望的 2绞吁。之后由于各自的 CPU 緩存里都有了 count 的值幢痘,兩個線程都是基于 CPU 緩存里的 count 值來計算,所以導(dǎo)致最終 count 的值都是小于 20000 的家破。這就是緩存的可見性問題颜说。
循環(huán) 10000 次 count+=1 操作如果改為循環(huán) 1 億次,你會發(fā)現(xiàn)效果更明顯汰聋,最終 count 的值接近 1 億门粪,而不是 2 億。如果循環(huán) 10000 次烹困,count 的值接近 20000玄妈,原因是兩個線程不是同時啟動的,有一個時差髓梅。
源頭之二:線程切換帶來的原子性問題
由于IO 太慢拟蜻, 早期的操作系統(tǒng)就發(fā)明了多線程,即便在單核的CPU 上枯饿,我們也可以一邊聽歌酝锅,一邊寫B(tài)UG,這個就是多進程的功勞鸭你。
操作系統(tǒng)允許某個進程執(zhí)行一小段時間屈张,例如:50 毫秒,過了 50 毫秒操作系統(tǒng)就會重新選擇一個進程來執(zhí)行(我們稱為“任務(wù)切換”)袱巨,這個 50 毫秒稱為“時間片”阁谆。
在一個時間片里,如果一個進程進行IO操作愉老,例如讀某個文件场绿,這個時候該進程可以把自己標(biāo)記為休眠狀態(tài)” 并讓出CPU的使用權(quán),待文件讀進內(nèi)存嫉入,操作系統(tǒng)會把這個休眠的進程喚醒焰盗,喚醒后的進程就有機會從新獲得CPU的使用權(quán)了。
這里的進程在等待IO時之所以會釋放CPU 的使用權(quán)咒林,是為了讓CPU 在這段時間可以做別的事情熬拒,這樣一來CPU 的使用率就上來了;此外垫竞,如果這時也有另外一個進程也讀文件澎粟,讀文件的操作就會排隊蛀序,磁盤驅(qū)動在完成一個進程的讀操作后,發(fā)現(xiàn)有排隊的任務(wù)活烙,就會立即啟動下一個讀操作徐裸,這樣IO的使用率就上來了。
是不是很簡單的邏輯啸盏?但是重贺,雖然看似簡單,支持多進程分時復(fù)用在操作系統(tǒng)的發(fā)展史上卻有里程碑的意義回懦,Unix 就是因為解決了這個問題而名震天下气笙。
早期的操作系統(tǒng)基于進程來調(diào)度CPU ,不同進程間是不共享內(nèi)存空間的怯晕,所以進程要做切換就要切換內(nèi)存映射地址健民。而一個進程創(chuàng)建的所有線程,都是共享一個內(nèi)存空間的贫贝,所以線程做任務(wù)切換成本就很低了秉犹,現(xiàn)代操作系統(tǒng)都是基于更輕量的線程來調(diào)度的,現(xiàn)在我們提到的“任務(wù)切換”都是指“線程切換”稚晚。
Java 并發(fā)程序都是基于多線程的崇堵,自然也會涉及到任務(wù)切換,也許你想不到客燕,任務(wù)切換竟然也是并發(fā)編程的Bug 源頭之一鸳劳,任務(wù)切換的時機大多是在時間片結(jié)束的時候,我們現(xiàn)在基本都使用高級編程語言也搓,高級語言里赏廓,一條語句往往需要多條CPU指令完成,例如上面代碼中的count += 1傍妒,至少需要三條 CPU 指令幔摸。
指令 1:首先,需要把變量 count 從內(nèi)存加載到 CPU 的寄存器颤练;
指令 2:之后既忆,在寄存器中執(zhí)行 +1 操作;
指令 3:最后嗦玖,將結(jié)果寫入內(nèi)存(緩存機制導(dǎo)致可能寫入的是 CPU 緩存而不是內(nèi)存)患雇。
操作系統(tǒng)做任務(wù)切換,可以發(fā)生在任何一條CPU 指令 執(zhí)行完宇挫,是的苛吱,是CPU 指令 ,而不是高級語言里的一條語句器瘪,對于上面的三條指令來說翠储,我們假定count=0拼缝, 如果線程A 在指令 1 執(zhí)行完后做線程切換, 線程 A 和線程 B 按照下圖的序列執(zhí)行彰亥,那么我們會發(fā)現(xiàn)兩個線程都執(zhí)行了 count+=1 的操作,但是得到的結(jié)果不是我們期望的 2衰齐,而是 1任斋。
我們潛意識里就覺得count+=1 這個 操作是一個不可分割的整體,就像一個原子一樣耻涛,線程的切換可以發(fā)生在count+=1 之前废酷,也可以發(fā)生在count+=1 之后,但就是不會發(fā)生在中間抹缕,我們把一個或者多個操作在CPU 執(zhí)行的過程中不被中斷的特性成為原子性澈蟆。CPU 能保證的原子操作是CPU 指令級別的,而不是高級語言的操作符卓研。這是違背我們直覺的地方趴俘。因此,很多時候我們需要在高級語言層面保證操作的原子性奏赘。
源頭之三:編譯優(yōu)化帶來的有序性問題
那并發(fā)編程還有沒有其他有違直覺容易導(dǎo)致Bug 的技術(shù)呢寥闪?有,就是有序性磨淌,顧名思義疲憋,有序性是指按照代碼的先后順序執(zhí)行。編譯器為了優(yōu)化性能梁只,有時候會改變程序中語句的先后順序缚柳,例如程序中:“a=6;b=7搪锣;”編譯器優(yōu)化后可能變成“b=7秋忙;a=6;”构舟, 在這個例子中翰绊,編譯器調(diào)整了語句的順序,但是不影響程序的最終結(jié)果旁壮。有時候編譯器及解釋器的優(yōu)化可能導(dǎo)致意想不到的Bug 监嗜。
在Java 領(lǐng)域一個經(jīng)典的案例就是利用雙重檢查創(chuàng)建單利對象,例如下面代碼:在獲取實例 getInstance() 的方法中抡谐,我們首先判斷 instance 是否為空裁奇,如果為空,則鎖定 Singleton.class 并再次檢查 instance 是否為空麦撵,如果還為空則創(chuàng)建 Singleton 的一個實例刽肠。
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
假設(shè)有兩個線程 A溃肪、B 同時調(diào)用 getInstance() 方法,他們會同時發(fā)現(xiàn) instance == null 音五,于是同時對 Singleton.class 加鎖惫撰,此時 JVM 保證只有一個線程能夠加鎖成功(假設(shè)是線程 A),另外一個線程則會處于等待狀態(tài)(假設(shè)是線程 B)躺涝;線程 A 會創(chuàng)建一個 Singleton 實例厨钻,之后釋放鎖,鎖釋放后坚嗜,線程 B 被喚醒夯膀,線程 B 再次嘗試加鎖,此時是可以加鎖成功的苍蔬,加鎖成功后诱建,線程 B 檢查 instance == null 時會發(fā)現(xiàn),已經(jīng)創(chuàng)建過 Singleton 實例了碟绑,所以線程 B 不會再創(chuàng)建一個 Singleton 實例俺猿。
這看上去一切都很完美,無懈可擊格仲,但實際上這個 getInstance() 方法并不完美辜荠。 問題出在哪里呢?出在 new 操作上抓狭,我們以為的 new 操作應(yīng)該是:
- 分配一塊內(nèi)存M伯病;
- 在內(nèi)存 M 上初始化 Singleton 對象;
- 然后 M 的地址賦值給 instance 變量否过。
但是實際上優(yōu)化后的路徑卻是 這樣的:
- 分配一塊內(nèi)存M午笛;
- 將 M 的地址賦值給 instance 變量;
- 最后在內(nèi)存 M 上初始化 Singleton 對象苗桂。
優(yōu)化后會導(dǎo)致什么問題呢药磺?我們假設(shè)現(xiàn)在A先執(zhí)行g(shù)etInstance() 方法, 當(dāng)執(zhí)行完指令 2 時恰好發(fā)生了線程切換煤伟,切換到了線程 B 上癌佩;如果此時線程 B 也執(zhí)行 getInstance() 方法,那么線程 B 在執(zhí)行第一個判斷時會發(fā)現(xiàn) instance != null 便锨,所以直接返回 instance围辙,而此時的 instance 是沒有初始化過的,如果我們這個時候訪問 instance 的成員變量就可能觸發(fā)空指針異常放案。
總結(jié)
要寫好并發(fā)程序姚建,首先要知道并發(fā)程序的問題在哪里,只有確定了“靶子”吱殉, 才有可能把問題解決掸冤,畢竟所有的解決方案都是針對問題的厘托。并發(fā)程序經(jīng)常出現(xiàn)的詭異問題看上去非常無厘頭,但是深究的話稿湿,無外乎就是直覺欺騙了我們铅匹,只要我們能夠深刻理解可見性、原子性饺藤、有序性在并發(fā)場景下的原理包斑,很多并發(fā)Bug 都是可以理解、可以診斷的策精。
在介紹可見性、原子性崇棠、有序性的時候咽袜,特意提到緩存導(dǎo)致的可見性問題,線程切換帶來的原子性問題枕稀,編譯優(yōu)化帶來的有序性問題询刹,其實緩存、線程萎坷、編譯優(yōu)化的目的和我們寫并發(fā)程序的目的是相同的凹联,都是提高程序性能。但是技術(shù)在解決一個問題的同時哆档,必然會帶來另一個問題蔽挠,所以在采用一項技術(shù)的同時,一定要清楚他帶來的問題是什么瓜浸,以及如何規(guī)避澳淑。