本篇文章給大家分享的是有關(guān)并發(fā)Bug之源有哪些,小編覺(jué)得挺實(shí)用的,因此分享給大家學(xué)習(xí),希望大家閱讀完這篇文章后可以有所收獲,話不多說(shuō),跟著小編一起來(lái)看看吧。
創(chuàng)新互聯(lián)10多年成都企業(yè)網(wǎng)站定制服務(wù);為您提供網(wǎng)站建設(shè),網(wǎng)站制作,網(wǎng)頁(yè)設(shè)計(jì)及高端網(wǎng)站定制服務(wù),成都企業(yè)網(wǎng)站定制及推廣,對(duì)成都火鍋店設(shè)計(jì)等多個(gè)方面擁有多年建站經(jīng)驗(yàn)的網(wǎng)站建設(shè)公司。
一個(gè)線程對(duì)共享變量的修改,另外一個(gè)線程能夠立刻看到,我們稱為可見(jiàn)性
談到可見(jiàn)性,要先引出 JMM (Java Memory Model) 概念, 即 Java 內(nèi)存模型,Java 內(nèi)存模型規(guī)定,將所有的變量都存放在 主內(nèi)存中,當(dāng)線程使用變量時(shí),會(huì)把主內(nèi)存里面的變量 復(fù)制到自己的工作空間或者叫作 私有內(nèi)存,線程讀寫(xiě)變量時(shí)操作的是自己工作內(nèi)存中的變量。
用 Git 的工作流程理解上面的描述就很簡(jiǎn)單了,Git 遠(yuǎn)程倉(cāng)庫(kù)就是主內(nèi)存,Git 本地倉(cāng)庫(kù)就是自己的工作內(nèi)存
文字描述有些抽象,我們來(lái)圖解說(shuō)明:
看這個(gè)場(chǎng)景:
主內(nèi)存中有變量 x,初始值為 0
線程 A 要將 x 加 1,先將 x=0 拷貝到自己的私有內(nèi)存中,然后更新 x 的值
線程 A 將更新后的 x 值回刷到主內(nèi)存的時(shí)間是不固定的
剛好在線程 A 沒(méi)有回刷 x 到主內(nèi)存時(shí),線程 B 同樣從主內(nèi)存中讀取 x,此時(shí)為 0,和線程 A 一樣的操作,最后期盼的 x=2 就會(huì)編程 x=1
這就是線程可見(jiàn)性的問(wèn)題
JMM 是一個(gè)抽象的概念,在實(shí)際實(shí)現(xiàn)中,線程的工作內(nèi)存是這樣的:
為了平衡內(nèi)存/IO 短板,會(huì)在 CPU 上增加緩存,每個(gè)核都只有自己的一級(jí)緩存,甚至有一個(gè)所有 CPU 都共享的二級(jí)緩存,就是上圖的樣子了,都說(shuō)這么設(shè)計(jì)是硬件同學(xué)留給軟件同學(xué)的一個(gè)坑,但能否跳過(guò)去這個(gè)坑也是衡量軟件同學(xué)是否走向 Java 進(jìn)階的關(guān)鍵指標(biāo)吧......
小提示
從上圖中你也可以看出,在 Java 中,所有的實(shí)例域,靜態(tài)域和數(shù)組元素都存儲(chǔ)在堆內(nèi)存中,堆內(nèi)存在線程之間共享,這些在后續(xù)文章中都稱之為「共享變量」,局部變量,方法定義參數(shù)和異常處理器參數(shù)不會(huì)在線程之間共享,所以他們不會(huì)有內(nèi)存可見(jiàn)性的問(wèn)題,也就不受內(nèi)存模型的影響
一句話,要想解決多線程可見(jiàn)性問(wèn)題,所有線程都必須要刷取主內(nèi)存中的變量怎么解決可見(jiàn)性問(wèn)題呢?Java 關(guān)鍵字 volatile幫你搞定,后續(xù)章節(jié)會(huì)分析......
原子(atom)指化學(xué)反應(yīng)不可再分的基本微粒,原子性操作你應(yīng)該能感受到其含義:
所謂原子操作是指不會(huì)被線程調(diào)度機(jī)制打斷的操作;這種操作一旦開(kāi)始,就一直運(yùn)行到結(jié)束,中間不會(huì)有任何 context switch
小品「鐘點(diǎn)工」有一句非常經(jīng)典的臺(tái)詞,要把大象裝冰箱,總共分幾步?
來(lái)看一小段程序:
多線程情況下能得到我們期盼的 count = 20000
的值嗎? 也許有同學(xué)會(huì)認(rèn)為,線程調(diào)用的 counter 方法只有一個(gè) count++ 操作,是單一操作,所以是原子性的,非也。在線程第一講中說(shuō)過(guò)我們不能用高級(jí)語(yǔ)言思維來(lái)理解 CPU 的處理方式,count++ 轉(zhuǎn)換成 CPU 指令則需要三步,通過(guò)下面命令解析出匯編指令等信息:
javap -c UnsafeCounter
截取 counter 方法的匯編指令來(lái)看:
解釋一下上面的指令, 16 : 獲取當(dāng)前 count 值,并且放入棧頂 19 : 將常量 1 放入棧頂 20 : 將當(dāng)前棧頂中兩個(gè)值相加,并把結(jié)果放入棧頂 21 : 把棧頂?shù)慕Y(jié)果再賦值給 count
由此可見(jiàn),簡(jiǎn)單的 count++ 不是一步操作,被轉(zhuǎn)換為匯編后就不具備原子性了,就好比大象裝冰箱,其實(shí)要分三步:
第一步,把冰箱門(mén)打開(kāi);第二步,把大象放進(jìn)去;第三步,把冰箱門(mén)帶上
結(jié)合 JMM 結(jié)構(gòu)圖理解,說(shuō)明一下為什么很難得到 count=20000
的結(jié)果:
多線程計(jì)數(shù)器,如何保證多個(gè)操作的原子性呢?最粗暴的方式是在方法上加 synchronized關(guān)鍵字,比如這樣:
問(wèn)題是解決了,如果 synchronized 是萬(wàn)能良方,那么也許并發(fā)就沒(méi)那么多事了,可以靠一個(gè) synchronized 走天下了,事實(shí)并不是這樣,synchronized 是獨(dú)占鎖 (同一時(shí)間只能有一個(gè)線程可以調(diào)用),沒(méi)有獲取鎖的線程會(huì)被阻塞;另外也會(huì)帶來(lái)很多線程切換的上下文開(kāi)銷(xiāo)
所以 JDK 中就有了非阻塞 CAS (Compare and Swap) 算法實(shí)現(xiàn)的原子操作類(lèi) AtomicLong 等工具類(lèi),看過(guò)源碼的同學(xué)也許會(huì)發(fā)現(xiàn)一個(gè)共同特點(diǎn),所有原子類(lèi)中都有下面這樣一段代碼:
private static final Unsafe unsafe = Unsafe.getUnsafe();
這個(gè)類(lèi)是 JDK 的 rt.jar 包中的 Unsafe 類(lèi)提供了 硬件級(jí)別的原子性操作,類(lèi)中的方法都是 native 修飾的,后面介紹原子類(lèi)之前也會(huì)先說(shuō)明這個(gè)類(lèi)中的幾個(gè)方法,這里先簡(jiǎn)單介紹有個(gè)印象即可。
有同學(xué)不理解我剛剛提到的線程上下文切換開(kāi)銷(xiāo)很大是什么意思,舉 2個(gè)例子你就懂了:
你(CPU)在看兩本書(shū)(兩個(gè)線程),看第一本書(shū)很短時(shí)間后要去看第二本書(shū),看第二本書(shū)很短時(shí)間后又回看第一本書(shū),并要精確的記得看到第幾行,當(dāng)初看到了什么(CPU 記住線程級(jí)別的信息),當(dāng)讓你 "同時(shí)"看 10 本甚至更多,切換的開(kāi)銷(xiāo)就很大了吧
綜藝節(jié)目中有很多游戲,讓你一邊數(shù)錢(qián),又要一邊做其他的事,最終保證多樣事情都做正確,大腦開(kāi)銷(xiāo)大不大,你試試就知道了????
生活中你問(wèn)候他人「吃了嗎你?」和「你吃了嗎?」是一個(gè)意思,你寫(xiě)的是下面程序:
a = 1; b = 2; System.out.println(a); System.out.println(b);
編譯器優(yōu)化后可能就變成了這樣:
b = 2; a = 1; System.out.println(a); System.out.println(b);
這個(gè)情況,編譯器調(diào)整了語(yǔ)句順序沒(méi)什么影響,但編譯器 擅自優(yōu)化順序,就給我們埋下了雷,比如應(yīng)用雙重檢查方式實(shí)現(xiàn)的單例
一切又很完美是不是,非也,問(wèn)題出現(xiàn)在 instance = new Singleton();
,這 1 行代碼轉(zhuǎn)換成了 CPU 指令后又變成了 3 個(gè),我們理解 new 對(duì)象應(yīng)該是這樣的:
分配一塊內(nèi)存 M
在內(nèi)存 M 上初始化 Singleton 對(duì)象
然后 M 的地址賦值給 instance 變量
但編譯器擅自優(yōu)化后可能就變成了這樣:
分配一塊內(nèi)存 M
然后將 M 的地址賦值給 instance 變量
在內(nèi)存 M 上初始化 Singleton 對(duì)象
首先 new 對(duì)象分了三步,給 CPU 留下了切換線程的機(jī)會(huì);另外,編譯器優(yōu)化后的順序可能導(dǎo)致問(wèn)題的發(fā)生,來(lái)看:
線程 A 先執(zhí)行 getInstance 方法,當(dāng)執(zhí)行到指令 2 時(shí),恰好發(fā)生了線程切換
線程 B 剛進(jìn)入到 getInstance 方法,判斷 if 語(yǔ)句 instance 是否為空
線程 A 已經(jīng)將 M 的地址賦值給了 instance 變量,所以線程 B 認(rèn)為 instance 不為空
線程 B 直接 return instance 變量
CPU 切換回線程 A,線程 A 完成后續(xù)初始化內(nèi)容
我們還是畫(huà)個(gè)圖說(shuō)明一下:
如果線程 A 執(zhí)行到第 2 步,線程切換,由于線程 A 沒(méi)有把紅色箭頭執(zhí)行完全,線程 B 就會(huì)得到一個(gè)未初始化完全的對(duì)象,訪問(wèn) instance 成員變量的時(shí)候就可能發(fā)生 NPE,如果將變量 instance 用 volatile 或者 final 修飾(涉及到類(lèi)的加載機(jī)制,可看我之前寫(xiě)的文章: 雙親委派模型:大廠高頻面試題,輕松搞定),問(wèn)題就解決了.
你所看到的程序并不一定是編譯器優(yōu)化/編譯后的 CPU 指令,大象裝冰箱是是個(gè)程序,但其隱含三個(gè)步驟,學(xué)習(xí)并發(fā)編程,你要按照 CPU 的思維考慮問(wèn)題,所以你需要深刻理解 可見(jiàn)性/原子性/有序性,這是產(chǎn)生并發(fā) Bug 的源頭
本節(jié)說(shuō)明了三個(gè)問(wèn)題,下面的文章也會(huì)逐個(gè)分析解決以上問(wèn)題的辦法,以及相對(duì)優(yōu)的方案,請(qǐng)持續(xù)關(guān)注,另外關(guān)于并發(fā)的測(cè)試代碼我都會(huì)按例上傳到 github,公眾號(hào)回復(fù)「demo」——> concurrency 獲取更多內(nèi)容
為什么用 final 修飾的變量就是線程安全的了呢?
你會(huì)經(jīng)常查看 CPU 匯編指令嗎?
如果讓你寫(xiě)單例,你通常會(huì)采用哪種實(shí)現(xiàn)?
這是一款 IDEA 的主題插件,安裝后,選擇 Material Palenight
主題,同時(shí)作出如下設(shè)置
以上就是并發(fā)Bug之源有哪些,小編相信有部分知識(shí)點(diǎn)可能是我們?nèi)粘9ぷ鲿?huì)見(jiàn)到或用到的。希望你能通過(guò)這篇文章學(xué)到更多知識(shí)。更多詳情敬請(qǐng)關(guān)注創(chuàng)新互聯(lián)行業(yè)資訊頻道。