寫在前面
編寫并發(fā)程序是比較困難的,因?yàn)椴l(fā)程序極易出現(xiàn)Bug,這些Bug有都是比較詭異的肠槽,很多都是沒辦法追蹤,而且難以復(fù)現(xiàn)奢啥。
要快速準(zhǔn)確的發(fā)現(xiàn)并解決這些問題秸仙,首先就是要弄清并發(fā)編程的本質(zhì),并發(fā)編程要解決的是什么問題桩盲。
本文將帶你深入理解并發(fā)編程要解決的三大問題:原子性寂纪、可見性、有序性赌结。
補(bǔ)充知識(shí)
硬件的發(fā)展中弊攘,一直存在一個(gè)矛盾抢腐,CPU、內(nèi)存襟交、I/O設(shè)備的速度差異迈倍。
速度排序:CPU >> 內(nèi)存 >> I/O設(shè)備
為了平衡這三者的速度差異,做了如下優(yōu)化:
CPU 增加了緩存捣域,以均衡內(nèi)存與CPU的速度差異啼染;
操作系統(tǒng)增加了進(jìn)程、線程焕梅,以分時(shí)復(fù)用CPU迹鹅,進(jìn)而均衡I/O設(shè)備與CPU的速度差異;
編譯程序優(yōu)化指令執(zhí)行次序贞言,使得緩存能夠得到更加合理地利用斜棚。
可見性
可見性是什么?
一個(gè)線程對(duì)共享變量的修改该窗,另外一個(gè)線程能夠立刻看到弟蚀,我們稱為可見性。
為什么會(huì)有可見性問題酗失?
對(duì)于如今的多核處理器义钉,每顆CPU都有自己的緩存,而緩存僅僅對(duì)它所在的處理器可見规肴,CPU緩存與內(nèi)存的數(shù)據(jù)不容易保證一致捶闸。
為了避免處理器停頓下來等待向內(nèi)存寫入數(shù)據(jù)而產(chǎn)生的延遲,處理器使用寫緩沖區(qū)來臨時(shí)保存向內(nèi)存寫入的數(shù)據(jù)拖刃。寫緩沖區(qū)合并對(duì)同一內(nèi)存地址的多次寫删壮,并以批處理的方式刷新,也就是說寫緩沖區(qū)不會(huì)即時(shí)將數(shù)據(jù)刷新到主內(nèi)存中兑牡。
緩存不能及時(shí)刷新導(dǎo)致了可見性問題醉锅。
可見性問題舉例
public class Test {
public int a = 0;
public void increase() {
a++;
}
public static void main(String[] args) {
final Test test = new Test();
for (int i = 0; i < 10; i++) {
new Thread() {
public void run() {
for (int j = 0; j < 1000; j++)
test.increase();
};
}.start();
}
while (Thread.activeCount() > 1) {
// 保證前面的線程都執(zhí)行完
Thread.yield();
}
System.out.println(test.a);
}
}
目的:10個(gè)線程將inc加到10000。
結(jié)果:每次運(yùn)行发绢,得到的結(jié)果都小于10000硬耍。
原因分析:
假設(shè)線程1和線程2同時(shí)開始執(zhí)行,那么第一次都會(huì)將a=0 讀到各自的CPU緩存里边酒,線程1執(zhí)行a++之后a=1经柴,但是此時(shí)線程2是看不到線程1中a的值的,所以線程2里a=0墩朦,執(zhí)行a++后a=1坯认。
線程1和線程2各自CPU緩存里的值都是1,之后線程1和線程2都會(huì)將自己緩存中的a=1寫入內(nèi)存,導(dǎo)致內(nèi)存中a=1牛哺,而不是我們期望的2陋气。所以導(dǎo)致最終 a 的值都是小于 10000 的。這就是緩存的可見性問題引润。
原子性
原子性是什么巩趁?
把一個(gè)或者多個(gè)操作在 CPU 執(zhí)行的過程中不被中斷的特性稱為原子性。
在并發(fā)編程中淳附,原子性的定義不應(yīng)該和事務(wù)中的原子性(一旦代碼運(yùn)行異骋槲浚可以回滾)一樣。應(yīng)該理解為:一段代碼奴曙,或者一個(gè)變量的操作别凹,在一個(gè)線程沒有執(zhí)行完之前,不能被其他線程執(zhí)行洽糟。
為什么會(huì)有原子性問題炉菲?
線程是CPU調(diào)度的基本單位。CPU會(huì)根據(jù)不同的調(diào)度算法進(jìn)行線程調(diào)度坤溃,將時(shí)間片分派給線程拍霜。當(dāng)一個(gè)線程獲得時(shí)間片之后開始執(zhí)行,在時(shí)間片耗盡之后浇雹,就會(huì)失去CPU使用權(quán)。多線程場(chǎng)景下屿讽,由于時(shí)間片在線程間輪換昭灵,就會(huì)發(fā)生原子性問題。
如:對(duì)于一段代碼伐谈,一個(gè)線程還沒執(zhí)行完這段代碼但是時(shí)間片耗盡烂完,在等待CPU分配時(shí)間片,此時(shí)其他線程可以獲取執(zhí)行這段代碼的時(shí)間片來執(zhí)行這段代碼诵棵,導(dǎo)致多個(gè)線程同時(shí)執(zhí)行同一段代碼抠蚣,也就是原子性問題。
線程切換帶來原子性問題履澳。
在Java中嘶窄,對(duì)基本數(shù)據(jù)類型的變量的讀取和賦值操作是原子性操作,即這些操作是不可被中斷的距贷,要么執(zhí)行柄冲,要么不執(zhí)行
i = 0; // 原子性操作
j = i; // 不是原子性操作,包含了兩個(gè)操作:讀取i忠蝗,將i值賦值給j
i++; // 不是原子性操作现横,包含了三個(gè)操作:讀取i值、i + 1 、將+1結(jié)果賦值給i
i = j + 1; // 不是原子性操作戒祠,包含了三個(gè)操作:讀取j值骇两、j + 1 、將+1結(jié)果賦值給i
原子性問題舉例
還是上文中的代碼姜盈,10個(gè)線程將inc加到10000低千。假設(shè)在保證可見性的情況下,仍然會(huì)因?yàn)樵有詥栴}導(dǎo)致執(zhí)行結(jié)果達(dá)不到預(yù)期贩据。為方便看栋操,把代碼貼到這里:
public class Test {
public int a = 0;
public void increase() {
a++;
}
public static void main(String[] args) {
final Test test = new Test();
for (int i = 0; i < 10; i++) {
new Thread() {
public void run() {
for (int j = 0; j < 1000; j++)
test.increase();
};
}.start();
}
while (Thread.activeCount() > 1) {
// 保證前面的線程都執(zhí)行完
Thread.yield();
}
System.out.println(test.a);
}
}
目的:10個(gè)線程將inc加到10000。
結(jié)果:每次運(yùn)行饱亮,得到的結(jié)果都小于10000矾芙。
原因分析:
首先來看a++操作,其實(shí)包括三個(gè)操作:
①讀取a=0;
②計(jì)算0+1=1;
③將1賦值給a;
保證a++的原子性近上,就是保證這三個(gè)操作在一個(gè)線程沒有執(zhí)行完之前剔宪,不能被其他線程執(zhí)行。
實(shí)際執(zhí)行時(shí)序圖如下:
關(guān)鍵一步:線程2在讀取a的值時(shí)壹无,線程1還沒有完成a=1的賦值操作葱绒,導(dǎo)致線程2的計(jì)算結(jié)果也是a=1。
問題在于沒有保證a++操作的原子性斗锭。如果保證a++的原子性地淀,線程1在執(zhí)行完三個(gè)操作之前,線程2不能執(zhí)行a++岖是,那么就可以保證在線程2執(zhí)行a++時(shí)帮毁,讀取到a=1,從而得到正確的結(jié)果豺撑。
有序性
有序性:程序執(zhí)行的順序按照代碼的先后順序執(zhí)行烈疚。
編譯器為了優(yōu)化性能,有時(shí)候會(huì)改變程序中語句的先后順序聪轿。例如程序中:“a=6爷肝;b=7;”編譯器優(yōu)化后可能變成“b=7陆错;a=6灯抛;”,在這個(gè)例子中音瓷,編譯器調(diào)整了語句的順序牧愁,但是不影響程序的最終結(jié)果。不過有時(shí)候編譯器及解釋器的優(yōu)化可能導(dǎo)致意想不到的Bug外莲。
有序性問題舉例
Java中的一個(gè)經(jīng)典的案例:利用雙重檢查創(chuàng)建單例對(duì)象
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
在獲取實(shí)例getInstance()的方法中猪半,我們首先判斷 instance是否為空兔朦,如果為空,則鎖定 Singleton.class并再次檢查instance是否為空磨确,如果還為空則創(chuàng)建Singleton的一個(gè)實(shí)例沽甥。
看似很完美,既保證了線程完全的初始化單例乏奥,又經(jīng)過判斷instance為null時(shí)再用synchronized同步加鎖摆舟。但是還有問題!
instance = new Singleton(); 創(chuàng)建對(duì)象的代碼邓了,分為三步:
①分配內(nèi)存空間
②初始化對(duì)象Singleton
③將內(nèi)存空間的地址賦值給instance
但是這三步經(jīng)過重排之后:
①分配內(nèi)存空間
②將內(nèi)存空間的地址賦值給instance
③初始化對(duì)象Singleton
會(huì)導(dǎo)致什么結(jié)果呢恨诱?
線程A先執(zhí)行g(shù)etInstance()方法,當(dāng)執(zhí)行完指令②時(shí)恰好發(fā)生了線程切換骗炉,切換到了線程B上照宝;如果此時(shí)線程B也執(zhí)行g(shù)etInstance()方法,那么線程B在執(zhí)行第一個(gè)判斷時(shí)會(huì)發(fā)現(xiàn)instance!=null句葵,所以直接返回instance厕鹃,而此時(shí)的instance是沒有初始化過的,如果我們這個(gè)時(shí)候訪問instance的成員變量就可能觸發(fā)空指針異常乍丈。
執(zhí)行時(shí)序圖:
總結(jié)
并發(fā)編程的本質(zhì)就是解決三大問題:原子性剂碴、可見性、有序性轻专。
原子性:一個(gè)或者多個(gè)操作在 CPU 執(zhí)行的過程中不被中斷的特性忆矛。由于線程的切換,導(dǎo)致多個(gè)線程同時(shí)執(zhí)行同一段代碼请垛,帶來的原子性問題催训。
可見性:一個(gè)線程對(duì)共享變量的修改,另外一個(gè)線程能夠立刻看到叼屠。緩存不能及時(shí)刷新導(dǎo)致了可見性問題瞳腌。
有序性:程序執(zhí)行的順序按照代碼的先后順序執(zhí)行绞铃。編譯器為了優(yōu)化性能而改變程序中語句的先后順序镜雨,導(dǎo)致有序性問題。
啟發(fā):線程的切換儿捧、緩存及編譯優(yōu)化都是為了提高性能荚坞,但是引發(fā)了并發(fā)編程的問題。這也告訴我們技術(shù)在解決一個(gè)問題時(shí)菲盾,必然會(huì)帶來另一個(gè)問題颓影,需要我們提前考慮新技術(shù)帶來的問題以規(guī)避風(fēng)險(xiǎn)。
轉(zhuǎn)載自 公眾號(hào) <java進(jìn)階架構(gòu)師>