亚洲全黄无码一级在线看_国产剧情久久久性色_无码av一区二区三区无码_亚洲成a×人片在线观看

當(dāng)前位置: 首頁 > 科技新聞 >

JAVA并發(fā)編程三大Bug源頭(可見性、原子性、有序性

時間:2020-06-18 17:35來源:網(wǎng)絡(luò)整理 瀏覽:
原創(chuàng)聲明:本文轉(zhuǎn)載自公眾號【胖滾豬學(xué)編程】?某日,胖滾豬寫的代碼導(dǎo)致了一個生產(chǎn)bug,奮戰(zhàn)到凌晨三點依舊沒有解決問題。胖滾熊一看,只用了一個

原創(chuàng)聲明:本文轉(zhuǎn)載自公眾號【胖滾豬學(xué)編程】?

某日,胖滾豬寫的代碼導(dǎo)致了一個生產(chǎn)bug,奮戰(zhàn)到凌晨三點依舊沒有解決問題。胖滾熊一看,只用了一個volatile就解決了。并告知胖滾豬,這是并發(fā)編程導(dǎo)致的坑。這讓胖滾豬堅定了要學(xué)好并發(fā)編程的決心。。于是,開始了我們并發(fā)編程的第一課。

序幕JAVA并發(fā)編程三大Bug源頭(可見性、原子性、有序性),徹底弄懂

BUG源頭之一:可見性

剛剛我們說到,CPU緩存可以提高程序性能,但緩存也是造成BUG源頭之一,因為緩存可以導(dǎo)致可見性問題。我們先來看一段代碼:

private static int count = 0;
public static void main(String[] args) throws Exception {
Thread th1 = new Thread(() -> {
count = 10;
});
Thread th2 = new Thread(() -> {
//極小概率會出現(xiàn)等于0的情況
System.out.println("count=" + count);
});
th1.start();
th2.start();
}

按理來說,應(yīng)該正確返回10,但結(jié)果卻有可能是0。

一個線程對變量的改變另一個線程沒有g(shù)et到,這就是可見性導(dǎo)致的bug。一個線程對共享變量的修改,另外一個線程能夠立刻看到,我們稱為可見性。

那么在談?wù)摽梢娦詥栴}之前,你必須了解下JAVA的內(nèi)存模型,我繪制了一張圖來描述:

JAVA并發(fā)編程三大Bug源頭(可見性、原子性、有序性),徹底弄懂

主內(nèi)存(Main Memory)

主內(nèi)存可以簡單理解為計算機(jī)當(dāng)中的內(nèi)存,但又不完全等同。主內(nèi)存被所有的線程所共享,對于一個共享變量(比如靜態(tài)變量,或是堆內(nèi)存中的實例)來說,主內(nèi)存當(dāng)中存儲了它的“本尊”。

工作內(nèi)存(Working Memory)

工作內(nèi)存可以簡單理解為計算機(jī)當(dāng)中的CPU高速緩存,但準(zhǔn)確的說它是涵蓋了緩存、寫緩沖區(qū)、寄存器以及其他的硬件和編譯器優(yōu)化。每一個線程擁有自己的工作內(nèi)存,對于一個共享變量來說,工作內(nèi)存當(dāng)中存儲了它的“副本”。

線程對變量的所有操作都必須在工作內(nèi)存中進(jìn)行,而不能直接讀寫主內(nèi)存中的變量。

線程之間無法直接訪問對方的工作內(nèi)存中的變量,線程間變量的傳遞均需要通過主內(nèi)存來完成

現(xiàn)在再回到剛剛的問題,為什么那段代碼會導(dǎo)致可見性問題呢,根據(jù)內(nèi)存模型來分析,我相信你會有答案了。當(dāng)多個線程在不同的 CPU 上執(zhí)行時,這些線程操作的是不同的 CPU 緩存。比如下圖中,線程 A 操作的是 CPU-1 上的緩存,而線程 B 操作的是 CPU-2 上的緩存

JAVA并發(fā)編程三大Bug源頭(可見性、原子性、有序性),徹底弄懂

由于線程對變量的所有操作都必須在工作內(nèi)存中進(jìn)行,而不能直接讀寫主內(nèi)存中的變量,那么對于共享變量V,它們首先是在自己的工作內(nèi)存,之后再同步到主內(nèi)存??墒遣⒉粫皶r的刷到主存中,而是會有一定時間差。很明顯,這個時候線程 A 對變量 V 的操作對于線程 B 而言就不具備可見性了 。

JAVA并發(fā)編程三大Bug源頭(可見性、原子性、有序性),徹底弄懂

private volatile long count = 0;
?
private void add10K() {
int idx = 0;
while (idx++ < 10000) {
count++;
}
}
?
public static void main(String[] args) throws InterruptedException {
TestVolatile2 test = new TestVolatile2();
// 創(chuàng)建兩個線程,執(zhí)行 add() 操作
Thread th1 = new Thread(()->{
test.add10K();
});
Thread th2 = new Thread(()->{
test.add10K();
});
// 啟動兩個線程
th1.start();
th2.start();
// 等待兩個線程執(zhí)行結(jié)束
th1.join();
th2.join();
// 介于1w-2w,即使加了volatile也達(dá)不到2w
System.out.println(test.count);
}
?
JAVA并發(fā)編程三大Bug源頭(可見性、原子性、有序性),徹底弄懂

原子性問題

一個不可分割的操作叫做原子性操作,它不會被線程調(diào)度機(jī)制打斷的,這種操作一旦開始,就一直運(yùn)行到結(jié)束,中間不會有任何線程切換。注意線程切換是重點!

我們都知道CPU資源的分配都是以線程為單位的,并且是分時調(diào)用,操作系統(tǒng)允許某個進(jìn)程執(zhí)行一小段時間,例如 50 毫秒,過了 50 毫秒操作系統(tǒng)就會重新選擇一個進(jìn)程來執(zhí)行(我們稱為“任務(wù)切換”),這個 50 毫秒稱為“時間片”。而任務(wù)的切換大多數(shù)是在時間片段結(jié)束以后,

JAVA并發(fā)編程三大Bug源頭(可見性、原子性、有序性),徹底弄懂

那么線程切換為什么會帶來bug呢?因為操作系統(tǒng)做任務(wù)切換,可以發(fā)生在任何一條CPU 指令執(zhí)行完!注意,是 CPU 指令,CPU 指令,CPU 指令,而不是高級語言里的一條語句。比如count++,在java里就是一句話,但高級語言里一條語句往往需要多條 CPU 指令完成。其實count++包含了三個CPU指令!

指令 1:首先,需要把變量 count 從內(nèi)存加載到 CPU 的寄存器;指令 2:之后,在寄存器中執(zhí)行 +1 操作;指令 3:最后,將結(jié)果寫入內(nèi)存(緩存機(jī)制導(dǎo)致可能寫入的是 CPU 緩存而不是內(nèi)存)。

小技巧:可以寫一個簡單的count++程序,依次執(zhí)行javac TestCount.java,javap -c -s TestCount.class得到匯編指令,驗證下count++確實是分成了多條指令的。

volatile雖然能保證執(zhí)行完及時把變量刷到主內(nèi)存中,但對于count++這種非原子性、多指令的情況,由于線程切換,線程A剛把count=0加載到工作內(nèi)存,線程B就可以開始工作了,這樣就會導(dǎo)致線程A和B執(zhí)行完的結(jié)果都是1,都寫到主內(nèi)存中,主內(nèi)存的值還是1不是2,下面這張圖形象表示了該歷程:

JAVA并發(fā)編程三大Bug源頭(可見性、原子性、有序性),徹底弄懂

JAVA并發(fā)編程三大Bug源頭(可見性、原子性、有序性),徹底弄懂

有序性問題

JAVA為了優(yōu)化性能,允許編譯器和處理器對指令進(jìn)行重排序,即有時候會改變程序中語句的先后順序:

例如程序中:“a=6;b=7;”編譯器優(yōu)化后可能變成“b=7;a=6;”只是在這個程序中不影響程序的最終結(jié)果。

有序性指的是程序按照代碼的先后順序執(zhí)行。但是不要望文生義,這里的順序不是按照代碼位置的依次順序執(zhí)行指令,指的是最終結(jié)果在我們看起來就像是有序的。

重排序的過程不會影響單線程程序的執(zhí)行,卻會影響到多線程并發(fā)執(zhí)行的正確性。有時候編譯器及解釋器的優(yōu)化可能導(dǎo)致意想不到的 Bug。比如非常經(jīng)典的雙重檢查創(chuàng)建單例對象。

public class Singleton { 
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}

你可能會覺得這個程序天衣無縫,我兩次判斷是否為空,還用了synchronized,剛剛也說了,synchronized 是獨占鎖/排他鎖。按照常理來說,應(yīng)該是這么一個邏輯:

線程A和B同時進(jìn)來,判斷instance == null,線程A先獲取了鎖,B等待,然后線程 A 會創(chuàng)建一個 Singleton 實例,之后釋放鎖,鎖釋放后,線程 B 被喚醒,線程 B 再次嘗試加鎖,此時加鎖會成功,然后線程 B 檢查 instance == null 時會發(fā)現(xiàn),已經(jīng)創(chuàng)建過 Singleton 實例了,所以線程 B 不會再創(chuàng)建一個 Singleton 實例。

但多線程往往要有非常理性的思維,我們先分析一下 instance = new Singleton()這句話,根據(jù)剛剛原子性說到的,一句高級語言在cpu層面其實是多條指令,這也不例外,我們也很熟悉new了,它會分為以下幾條指令:

1、分配一塊內(nèi)存 M;

2、在內(nèi)存 M 上初始化 Singleton 對象;

3、然后 M 的地址賦值給 instance 變量。

如果真按照上述三條指令執(zhí)行是沒問題的,但經(jīng)過編譯優(yōu)化后的執(zhí)行路徑卻是這樣的:

**1、分配一塊內(nèi)存 M;

2、將 M 的地址賦值給 instance 變量;

3、最后在內(nèi)存 M 上初始化 Singleton 對象**

假如當(dāng)執(zhí)行完指令 2 時恰好發(fā)生了線程切換,切換到了線程 B 上;而此時線程 B 也執(zhí)行 getInstance() 方法,那么線程 B 在執(zhí)行第一個判斷時會發(fā)現(xiàn) instance != null ,所以直接返回 instance,而此時的 instance 是沒有初始化過的,如果我們這個時候訪問 instance 的成員變量就可能觸發(fā)空指針異常,如圖所示:

JAVA并發(fā)編程三大Bug源頭(可見性、原子性、有序性),徹底弄懂

JAVA并發(fā)編程三大Bug源頭(可見性、原子性、有序性),徹底弄懂

總結(jié)

并發(fā)程序是一把雙刃劍,一方面大幅度提升了程序性能,另一方面帶來了很多隱藏的無形的難以發(fā)現(xiàn)的bug。我們首先要知道并發(fā)程序的問題在哪里,只有確定了“靶子”,才有可能把問題解決,畢竟所有的解決方案都是針對問題的。并發(fā)程序經(jīng)常出現(xiàn)的詭異問題看上去非常無厘頭,但是只要我們能夠深刻理解可見性、原子性、有序性在并發(fā)場景下的原理,很多并發(fā) Bug 都是可以理解、可以診斷的。

總結(jié)一句話:可見性是緩存導(dǎo)致的,而線程切換會帶來的原子性問題,編譯優(yōu)化會帶來有序性問題。至于怎么解決呢!欲知后事如何,且聽下回分解。

推薦內(nèi)容