摘要

編寫正確的併發程式對我來說是一件極其困難的事情,由於知識不足,只知道synchronized這個修飾符進行同步。
本文為學習極客時間:Java併發程式設計實戰 01的總結,文章取圖也是來自於該文章

併發Bug源頭

在計算機系統中,程式的執行速度為:CPU > 記憶體 > I/O裝置 ,為了平衡這三者的速度差異,計算機體系機構、作業系統、編譯程式都進行了優化:

1.CPU增加了快取,以均衡和記憶體的速度差異
2.作業系統增加了程序、執行緒,已分時複用CPU,以均衡 CPU 與 I/O 裝置的速度差異
3.編譯程式優化指令執行順序,使得快取能夠更加合理的利用。

但是這三者導致的問題為:可見性、原子性、有序性

源頭之一:CPU快取導致的可見性問題

一個執行緒對共享變數的修改,另外一個執行緒能夠立即看到,那麼就稱為可見性。
現在多核CPU時代中,每顆CPU都有自己的快取,CPU之間並不會共享快取;

如執行緒A從記憶體讀取變數V到CPU-1,操作完成後儲存在CPU-1快取中,還未寫到記憶體中。
此時執行緒B從記憶體讀取變數V到CPU-2中,而CPU-1快取中的變數V對執行緒B是不可見的
當執行緒A把更新後的變數V寫到記憶體中時,執行緒B才可以從記憶體中讀取到最新變數V的值

上述過程就是執行緒A修改變數V後,對執行緒B不可見,那麼就稱為可見性問題。

源頭之二:執行緒切換帶來的原子性問題

現代的作業系統都是基於執行緒來排程的,現在提到的“任務切換”都是指“執行緒切換”
Java併發程式都是基於多執行緒的,自然也會涉及到任務切換,在高階語言中,一條語句可能就需要多條CPU指令完成,例如在程式碼 count += 1 中,至少需要三條CPU指令。

指令1:把變數 count 從記憶體載入到CPU的暫存器中
指令2:在暫存器中把變數 count + 1
指令3:把變數 count 寫入到記憶體(快取機制導致可能寫入的是CPU快取而不是記憶體)

作業系統做任務切換,可以發生在任何一條CPU指令執行完,所以並不是高階語言中的一條語句,不要被 count += 1 這個操作矇蔽了雙眼。假設count = 0,執行緒A執行完 指令1 後 ,做任務切換到執行緒B執行了 指令1、指令2、指令3後,再做任務切換回執行緒A。我們會發現雖然兩個執行緒都執行了 count += 1 操作。但是得到的結果並不是2,而是1。

如果 count += 1 是一個不可分割的整體,執行緒的切換可以發生在 count += 1 之前或之後,但是不會發生在中間,就像個原子一樣。我們把一個或者多個操作在 CPU 執行的過程中不被中斷的特性稱為原子性

源頭之三:編譯優化帶來的有序性問題

有序性指的是程式按照程式碼的先後順序執行。編譯器為了優化效能,可能會改變程式中的語句執行先後順序。如:a = 1; b = 2;,編譯器可能會優化成:b = 2; a = 1。在這個例子中,編譯器優化了程式的執行先後順序,並不影響結果。但是有時候優化後會導致意想不到的Bug。
在單例模式的雙重檢查建立單例物件中。如下程式碼:

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

問題出現在了new Singletion()這行程式碼,我們以為的執行順序應該是這樣的:

指令1:分配一塊記憶體M
指令2:在記憶體M中例項化Singleton物件
指令3:instance變數指向記憶體地址M

但是實際優化後的執行路徑確實這樣的:

指令1:分配一塊記憶體M
指令2:instance變數指向記憶體地址M
指令3:在記憶體M中例項化Singleton物件

這樣的話看出來什麼問題了嗎?當執行緒A執行完了指令2後,切換到了執行緒B,
執行緒B判斷到 if (instance != null)。直接返回instance,但是此時的instance還是沒有被例項化的啊!所以這時候我們使用instance可能就會觸發空指標異常了。如圖:

總結

在寫併發程式的時候,需要時刻注意可見性、原子性、有序性的問題。在深刻理解這三個問題後,寫起併發程式也會少一點Bug啦~。記住了下面這段話:CPU快取會帶來可見性問題、執行緒切換帶來的原子性問題、編譯優化帶來的有序性問題。

參考文章:極客時間:Java併發程式設計實戰 01 | 可見性、原子性和有序性問題:併發程式設計Bug的源頭

個人部落格網址: https://colablog.cn/

如果我的文章幫助到您,可以關注我的微信公眾號,第一時間分享文章給您
相關文章