古語有云,天上的一天邻吭,地上的一年餐弱,當(dāng)年玉帝妹子私自下凡間,與楊天佑結(jié)為夫婦囱晴,有一天玉帝突然想起膏蚓,妹妹呢,咋好幾天沒見到她了畸写,雖然在天上只是幾天時(shí)間驮瞧,而在凡間玉帝妹子和楊君都有了仨孩子啦,這也才有了后來二郎真君劈山救母的故事枯芬。
言歸正傳论笔,其實(shí)在計(jì)算機(jī)的世界里同樣存在這樣的矛盾,那就是CPU千所、內(nèi)存和I/O設(shè)備之間速度差異狂魔。根據(jù)木桶效應(yīng),即一個(gè)木桶能裝多少水取決于它最短的那塊木板淫痰,I/O設(shè)備的瓶頸制約著軟件的性能最楷。
為了應(yīng)對這個(gè)問題,從計(jì)算機(jī)體系結(jié)構(gòu)層面、操作系統(tǒng)層面和程序編譯層面都有相應(yīng)的優(yōu)化措施:
1)計(jì)算機(jī)體系層面籽孙,CPU增加緩存烈评,均衡了CPU與內(nèi)存之間的速度差異;
2)操作系統(tǒng)層面蚯撩,引入了進(jìn)程和線程础倍,以時(shí)分復(fù)用的方式均衡CPU與I/O設(shè)備之間的速度差異;
3)編譯程序優(yōu)化指令執(zhí)行次序胎挎,使得緩存能夠得到更加合理地利用。
然而沒有一勞永逸的方法忆家,在享受這些便利的時(shí)候犹菇,我們也要承受它給我們帶來的困擾,這些優(yōu)化就是很多并發(fā)編程中詭異問題的根源所在芽卿,主要表現(xiàn)為三個(gè)方面:可見性問題揭芍、原子性問題和有序性問題。
1. 可見性問題
可見性卸例,即一個(gè)線程對共享變量的修改称杨,另一個(gè)線程能夠立即看到,在多核時(shí)代筷转,每個(gè)CPU都有自己的緩存姑原,如下圖所示,線程1操作CPU01的緩存呜舒,線程2操作CPU02的緩存锭汛,顯然線程1對共享變量的操作對于線程2來說就不具備可見性。
我們可以通過如下程序驗(yàn)證這個(gè)問題
public class CurrencyTest {
int count = 0;
public void countAdd() {
for(int i = 0; i < 10000; i++)
count+=1;
}
public static void main(String[] args) throws InterruptedException {
CurrencyTest currencyTest = new CurrencyTest();
Thread thread1 = new Thread(() -> currencyTest.countAdd());
Thread thread2 = new Thread(() -> currencyTest.countAdd());
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(currencyTest.count);
}
}
運(yùn)行上面的代碼袭蝗,其結(jié)果是10000到20000之間的一個(gè)隨機(jī)數(shù)唤殴,這就是可見性問題引起的,每個(gè)CPU中都有共享變量count到腥,自己玩自己的朵逝,每個(gè)線程都是根據(jù)各自CPU中的緩存值操作,最后就會(huì)出現(xiàn)數(shù)據(jù)不一致的問題乡范。
2. 原子性問題
即使是在單核系統(tǒng)中配名,仍然能夠邊上網(wǎng)邊聽歌,這就得益于多線程時(shí)分復(fù)用的出現(xiàn)篓足,當(dāng)年Unix也是因?yàn)檫@個(gè)而名揚(yáng)天下的段誊。它解決了I/O等待時(shí)間長阻塞線程而浪費(fèi)CPU資源的問題,多線程時(shí)分復(fù)用的原理如下圖所示栈拖,將CPU劃分為時(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)了埃儿。
Java中的并發(fā)編程是基于多線程的,會(huì)涉及到任務(wù)切換(任務(wù)切換通常指的就是線程切換)融涣,線程切換也是詭異Bug的源頭之一童番,線程的切換可以發(fā)生在程序運(yùn)行的任何一條指令,注意這里強(qiáng)調(diào)的是指令而不是Java中的一條代碼威鹿,例如我們熟悉的i++操作就是???三條指令完成的剃斧,
1)把變量i的值加載到CPU的寄存器中;
2)在寄存器中執(zhí)行+1操作忽你;
3)將結(jié)果寫入內(nèi)存幼东,緩存機(jī)制可能導(dǎo)致結(jié)果寫入CPU緩存而不是內(nèi)存中。
由于存在線程的切換科雳,i++的操作可能被中斷根蟹,引起數(shù)據(jù)不一致的問題,我們把一個(gè)或者多個(gè)操作在CPU中執(zhí)行的過程中不被中斷的特性稱為原子性糟秘。
3. 有序性問題
編譯階段的指令重排序會(huì)導(dǎo)致順序性問題简逮,從硬件架構(gòu)上來看,指令重排序是指CPU采用了允許將多條指令不按程序規(guī)定的順序分開發(fā)送各相應(yīng)電路單元處理的方式蚌堵,但并不是說指令任意排序买决,指令重排序不能影響正確的執(zhí)行結(jié)果。周志明老師《深入理解Java虛擬機(jī)》一書中總結(jié)道:Java程序中天然的有序性可以總結(jié)為一句話吼畏,如果在本線程內(nèi)觀察督赤,所有的操作都是有序的;如果在一個(gè)線程中觀察另一個(gè)線程泻蚊,所有的操作都是無序的躲舌,前半句指的是線程內(nèi)表現(xiàn)為串行的語義,后半句指的是指令重排序現(xiàn)象和工作內(nèi)存與貯存同步延遲的現(xiàn)象性雄。舉個(gè)例子來說明没卸,單例模式的雙重檢查鎖
public class SingletonDemo {
private /** volatile*/ static SingletonDemo instance;
public static SingletonDemo getInstance() {
if (instance == null) {
synchronized (SingletonDemo.class) {
if (null == instance) {
instance = new SingletonDemo();
}
}
}
return instance;
}
}
這里為什么不能缺少volatile關(guān)鍵字呢?主要在于instance = new Instance()這個(gè)語句實(shí)際上包含了三個(gè)操作:
1)分配對象內(nèi)存空間秒旋;
2)初始化對象约计;
3)設(shè)置instance指向剛分配的內(nèi)存地址
前文中提到一個(gè)線程內(nèi)看其他線程中的指令執(zhí)行順序可能是亂序的,有可能是如下順序:
1)分配對象內(nèi)存迁筛;
2)設(shè)置instance指向剛分配的內(nèi)存煤蚌;
3)初始化對象
那么其他線程可能取得的是沒有初始化的對象,出現(xiàn)詭異的并發(fā)bug。
總結(jié)
本文主要分析了并發(fā)編程中詭異bug的三個(gè)來源尉桩,可見性問題筒占、原子性問題和有序性問題。