1. 程式人生 > >【漫畫】JAVA併發程式設計三大Bug源頭(可見性、原子性、有序性)

【漫畫】JAVA併發程式設計三大Bug源頭(可見性、原子性、有序性)

> 原創宣告:本文轉載自公眾號【胖滾豬學程式設計】​ 某日,胖滾豬寫的程式碼導致了一個生產bug,奮戰到凌晨三點依舊沒有解決問題。胖滾熊一看,只用了一個volatile就解決了。並告知胖滾豬,這是併發程式設計導致的坑。這讓胖滾豬堅定了要學好併發程式設計的決心。。於是,開始了我們併發程式設計的第一課。 # 序幕 ![con2](https://yqfile.alicdn.com/e1d85f718b364fef96f087b13300db0faf6fefc7.jpeg) # BUG源頭之一:可見性 剛剛我們說到,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的記憶體模型,我繪製了一張圖來描述: ![JAVA_](https://yqfile.alicdn.com/217ba14a5c1cb5155974050bdfeec953314f3b39.jpeg) **主記憶體(Main Memory)** 主記憶體可以簡單理解為計算機當中的記憶體,但又不完全等同。主記憶體被所有的執行緒所共享,對於一個共享變數(比如靜態變數,或是堆記憶體中的例項)來說,主記憶體當中儲存了它的“本尊”。 **工作記憶體(Working Memory)** 工作記憶體可以簡單理解為計算機當中的CPU快取記憶體,但準確的說它是涵蓋了快取、寫緩衝區、暫存器以及其他的硬體和編譯器優化。每一個執行緒擁有自己的工作記憶體,對於一個共享變數來說,工作記憶體當中儲存了它的“副本”。 **執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。** **執行緒之間無法直接訪問對方的工作記憶體中的變數,執行緒間變數的傳遞均需要通過主記憶體來完成** 現在再回到剛剛的問題,為什麼那段程式碼會導致可見性問題呢,根據記憶體模型來分析,我相信你會有答案了。當多個執行緒在不同的 CPU 上執行時,這些執行緒操作的是不同的 CPU 快取。比如下圖中,執行緒 A 操作的是 CPU-1 上的快取,而執行緒 B 操作的是 CPU-2 上的快取 ![image](https://yqfile.alicdn.com/83734113d085378660e601c80fb8a0971de46a1f.png) 由於執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數,那麼對於共享變數V,它們首先是在自己的工作記憶體,之後再同步到主記憶體。可是並不會及時的刷到主存中,而是會有一定時間差。很明顯,這個時候執行緒 A 對變數 V 的操作對於執行緒 B 而言就不具備可見性了 。 ![con3_1](https://yqfile.alicdn.com/6b4cc09b2fac7de4dd35e12a0a771d9a195517bb.jpeg) ``` 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); } ​ ``` ![con3_2](https://yqfile.alicdn.com/8e4df5531113ac5f357432b84283dcd0db9c0dc9.jpeg) > 原創宣告:本文轉載自公眾號【胖滾豬學程式設計】​ # 原子性問題 一個不可分割的操作叫做原子性操作,它不會被執行緒排程機制打斷的,這種操作一旦開始,就一直執行到結束,中間不會有任何執行緒切換。注意執行緒切換是重點! 我們都知道CPU資源的分配都是以執行緒為單位的,並且是分時呼叫,作業系統允許某個程序執行一小段時間,例如 50 毫秒,過了 50 毫秒作業系統就會重新選擇一個程序來執行(我們稱為“任務切換”),這個 50 毫秒稱為“時間片”。而任務的切換大多數是在時間片段結束以後, ![_](https://yqfile.alicdn.com/b0fd9bdb11240493b7ffc2b1841e8cb6347d25dd.jpeg) 那麼執行緒切換為什麼會帶來bug呢?因為作業系統做任務切換,可以發生在任何一條CPU 指令執行完!注意,是 CPU 指令,CPU 指令,CPU 指令,而不是高階語言裡的一條語句。比如count++,在java裡就是一句話,但高階語言裡一條語句往往需要多條 CPU 指令完成。其實count++包含了三個CPU指令! - 指令 1:首先,需要把變數 count 從記憶體載入到 CPU 的暫存器; - 指令 2:之後,在暫存器中執行 +1 操作; - 指令 3:最後,將結果寫入記憶體(快取機制導致可能寫入的是 CPU 快取而不是記憶體)。 小技巧:可以寫一個簡單的count++程式,依次執行javac TestCount.java,javap -c -s TestCount.class得到彙編指令,驗證下count++確實是分成了多條指令的。 volatile雖然能保證執行完及時把變數刷到主記憶體中,但對於count++這種非原子性、多指令的情況,由於執行緒切換,執行緒A剛把count=0載入到工作記憶體,執行緒B就可以開始工作了,這樣就會導致執行緒A和B執行完的結果都是1,都寫到主記憶體中,主記憶體的值還是1不是2,下面這張圖形象表示了該歷程: ![_](https://yqfile.alicdn.com/844cb7d6cf97eb9474a53063afce89c890dc18e4.jpeg) ![image](https://yqfile.alicdn.com/3f9303166aaab3ad052ec54365ce8f64ae91c07f.png) > 原創宣告:本文轉載自公眾號【胖滾豬學程式設計】​ # 有序性問題 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了,它會分為以下幾條指令: 1、分配一塊記憶體 M; 2、在記憶體 M 上初始化 Singleton 物件; 3、然後 M 的地址賦值給 instance 變數。 如果真按照上述三條指令執行是沒問題的,但經過編譯優化後的執行路徑卻是這樣的: **1、分配一塊記憶體 M; 2、將 M 的地址賦值給 instance 變數; 3、最後在記憶體 M 上初始化 Singleton 物件** 假如當執行完指令 2 時恰好發生了執行緒切換,切換到了執行緒 B 上;而此時執行緒 B 也執行 getInstance() 方法,那麼執行緒 B 在執行第一個判斷時會發現 instance != null ,所以直接返回 instance,而此時的 instance 是沒有初始化過的,如果我們這個時候訪問 instance 的成員變數就可能觸發空指標異常,如圖所示: ![_](https://yqfile.alicdn.com/fda190fd58940c4fd3166e8325688e1ecae4a1fd.jpeg) ![con4](https://yqfile.alicdn.com/f766dd14f723c1ffbefedf8d51f2a9d573aeaa74.jpeg) # 總結 併發程式是一把雙刃劍,一方面大幅度提升了程式效能,另一方面帶來了很多隱藏的無形的難以發現的bug。我們首先要知道併發程式的問題在哪裡,只有確定了“靶子”,才有可能把問題解決,畢竟所有的解決方案都是針對問題的。併發程式經常出現的詭異問題看上去非常無厘頭,但是隻要我們能夠深刻理解可見性、原子性、有序性在併發場景下的原理,很多併發 Bug 都是可以理解、可以診斷的。 總結一句話:可見性是快取導致的,而執行緒切換會帶來的原子性問題,編譯優化會帶來有序性問題。至於怎麼解決呢!欲知後事如何,且聽下回分解。 > 原創宣告:本文轉載自公眾號【胖滾豬學程式設計】​ > 本文轉載自公眾號【胖滾豬學程式設計】 用漫畫讓程式設計so easy and interesting!歡迎關注!形象來源於微信表情包【胖滾家族】喜歡可以