原創聲明:本文轉載自公衆號【胖滾豬學編程】java
某日,胖滾豬寫的代碼致使了一個生產bug,奮戰到凌晨三點依舊沒有解決問題。胖滾熊一看,只用了一個volatile就解決了。並告知胖滾豬,這是併發編程致使的坑。這讓胖滾豬堅決了要學好併發編程的決心。。因而,開始了咱們併發編程的第一課。編程
剛剛咱們說到,CPU緩存能夠提升程序性能,但緩存也是形成BUG源頭之一,由於緩存能夠致使可見性問題。咱們先來看一段代碼:緩存
private static int count = 0; public static void main(String[] args) throws Exception { Thread th1 = new Thread(() -> { count = 10; }); Thread th2 = new Thread(() -> { //極小機率會出現等於0的狀況 System.out.println("count=" + count); }); th1.start(); th2.start(); }
按理來講,應該正確返回10,但結果卻有多是0。微信
一個線程對變量的改變另外一個線程沒有get到,這就是可見性致使的bug。一個線程對共享變量的修改,另一個線程可以馬上看到,咱們稱爲可見性。多線程
那麼在談論可見性問題以前,你必須瞭解下JAVA的內存模型,我繪製了一張圖來描述:併發
主內存(Main Memory)性能
主內存能夠簡單理解爲計算機當中的內存,但又不徹底等同。主內存被全部的線程所共享,對於一個共享變量(好比靜態變量,或是堆內存中的實例)來講,主內存當中存儲了它的「本尊」。優化
工做內存(Working Memory)操作系統
工做內存能夠簡單理解爲計算機當中的CPU高速緩存,但準確的說它是涵蓋了緩存、寫緩衝區、寄存器以及其餘的硬件和編譯器優化。每個線程擁有本身的工做內存,對於一個共享變量來講,工做內存當中存儲了它的「副本」。線程
線程對變量的全部操做都必須在工做內存中進行,而不能直接讀寫主內存中的變量。
線程之間沒法直接訪問對方的工做內存中的變量,線程間變量的傳遞均須要經過主內存來完成
如今再回到剛剛的問題,爲何那段代碼會致使可見性問題呢,根據內存模型來分析,我相信你會有答案了。當多個線程在不一樣的 CPU 上執行時,這些線程操做的是不一樣的 CPU 緩存。好比下圖中,線程 A 操做的是 CPU-1 上的緩存,而線程 B 操做的是 CPU-2 上的緩存
因爲線程對變量的全部操做都必須在工做內存中進行,而不能直接讀寫主內存中的變量,那麼對於共享變量V,它們首先是在本身的工做內存,以後再同步到主內存。但是並不會及時的刷到主存中,而是會有必定時間差。很明顯,這個時候線程 A 對變量 V 的操做對於線程 B 而言就不具有可見性了 。
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(); // 建立兩個線程,執行 add() 操做 Thread th1 = new Thread(()->{ test.add10K(); }); Thread th2 = new Thread(()->{ test.add10K(); }); // 啓動兩個線程 th1.start(); th2.start(); // 等待兩個線程執行結束 th1.join(); th2.join(); // 介於1w-2w,即便加了volatile也達不到2w System.out.println(test.count); }
原創聲明:本文轉載自公衆號【胖滾豬學編程】
一個不可分割的操做叫作原子性操做,它不會被線程調度機制打斷的,這種操做一旦開始,就一直運行到結束,中間不會有任何線程切換。注意線程切換是重點!
咱們都知道CPU資源的分配都是以線程爲單位的,而且是分時調用,操做系統容許某個進程執行一小段時間,例如 50 毫秒,過了 50 毫秒操做系統就會從新選擇一個進程來執行(咱們稱爲「任務切換」),這個 50 毫秒稱爲「時間片」。而任務的切換大多數是在時間片斷結束之後,
那麼線程切換爲何會帶來bug呢?由於操做系統作任務切換,能夠發生在任何一條CPU 指令執行完!注意,是 CPU 指令,CPU 指令,CPU 指令,而不是高級語言裏的一條語句。好比count++,在java裏就是一句話,但高級語言裏一條語句每每須要多條 CPU 指令完成。其實count++包含了三個CPU指令!
小技巧:能夠寫一個簡單的count++程序,依次執行javac TestCount.java,javap -c -s TestCount.class獲得彙編指令,驗證下count++確實是分紅了多條指令的。
volatile雖然能保證執行完及時把變量刷到主內存中,但對於count++這種非原子性、多指令的狀況,因爲線程切換,線程A剛把count=0加載到工做內存,線程B就能夠開始工做了,這樣就會致使線程A和B執行完的結果都是1,都寫到主內存中,主內存的值仍是1不是2,下面這張圖形象表示了該歷程:
原創聲明:本文轉載自公衆號【胖滾豬學編程】
JAVA爲了優化性能,容許編譯器和處理器對指令進行重排序,即有時候會改變程序中語句的前後順序:
例如程序中:「a=6;b=7;」編譯器優化後可能變成「b=7;a=6;」只是在這個程序中不影響程序的最終結果。
有序性指的是程序按照代碼的前後順序執行。可是不要望文生義,這裏的順序不是按照代碼位置的依次順序執行指令,指的是最終結果在咱們看起來就像是有序的。
重排序的過程不會影響單線程程序的執行,卻會影響到多線程併發執行的正確性。有時候編譯器及解釋器的優化可能致使意想不到的 Bug。好比很是經典的雙重檢查建立單例對象。
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 是獨佔鎖/排他鎖。按照常理來講,應該是這麼一個邏輯:
線程A和B同時進來,判斷instance == null,線程A先獲取了鎖,B等待,而後線程 A 會建立一個 Singleton 實例,以後釋放鎖,鎖釋放後,線程 B 被喚醒,線程 B 再次嘗試加鎖,此時加鎖會成功,而後線程 B 檢查 instance == null 時會發現,已經建立過 Singleton 實例了,因此線程 B 不會再建立一個 Singleton 實例。
但多線程每每要有很是理性的思惟,咱們先分析一下 instance = new Singleton()這句話,根據剛剛原子性說到的,一句高級語言在cpu層面實際上是多條指令,這也不例外,咱們也很熟悉new了,它會分爲如下幾條指令:
一、分配一塊內存 M;
二、在內存 M 上初始化 Singleton 對象;
三、而後 M 的地址賦值給 instance 變量。
若是真按照上述三條指令執行是沒問題的,但通過編譯優化後的執行路徑倒是這樣的:
一、分配一塊內存 M;
二、將 M 的地址賦值給 instance 變量;
三、最後在內存 M 上初始化 Singleton 對象
假如當執行完指令 2 時剛好發生了線程切換,切換到了線程 B 上;而此時線程 B 也執行 getInstance() 方法,那麼線程 B 在執行第一個判斷時會發現 instance != null ,因此直接返回 instance,而此時的 instance 是沒有初始化過的,若是咱們這個時候訪問 instance 的成員變量就可能觸發空指針異常,如圖所示:
併發程序是一把雙刃劍,一方面大幅度提高了程序性能,另外一方面帶來了不少隱藏的無形的難以發現的bug。咱們首先要知道併發程序的問題在哪裏,只有肯定了「靶子」,纔有可能把問題解決,畢竟全部的解決方案都是針對問題的。併發程序常常出現的詭異問題看上去很是無厘頭,可是隻要咱們可以深入理解可見性、原子性、有序性在併發場景下的原理,不少併發 Bug 都是能夠理解、能夠診斷的。
總結一句話:可見性是緩存致使的,而線程切換會帶來的原子性問題,編譯優化會帶來有序性問題。至於怎麼解決呢!欲知後事如何,且聽下回分解。
原創聲明:本文轉載自公衆號【胖滾豬學編程】
本文轉載自公衆號【胖滾豬學編程】 用漫畫讓編程so easy and interesting!歡迎關注!形象來源於微信表情包【胖滾家族】喜歡能夠下載哦~