Java併發程式設計Bug源頭:可見性、原子性和有序性問題

java架構大牛發表於2019-04-11

併發程式設計的起源

硬體裝置發展的核心矛盾:CPU、記憶體、I/O裝置三者間存在的速度差異。根據木桶原理,程式整體效能最終受制於速度最慢的I/O裝置。

為了平和三者速度差異,計算機體系結構、作業系統、編譯程式都做出了貢獻,主要體現為:

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

編髮程式設計出現問題的源頭

一:快取導致的可見性問題

單核時代,所有執行緒在同一CPU上雲析,CPU快取與記憶體的資料一致性容易解決。如下圖,執行緒A與B操作同一個CPU裡的快取,故A修改過變數V後,B再訪問變數V,得到的一定是最新值,即A修改過的值。

Java併發程式設計Bug源頭:可見性、原子性和有序性問題

一個執行緒對共享變數的修改,另一個執行緒可以立即看到,稱之為 可見性

多核時代,每個CPU都有各自的快取,當多個執行緒在不同的CPU上執行時,這些執行緒操作的是不同的CPU快取,如下圖所示,執行緒A所修改的CPU-1快取中的變數V,這個操作對執行緒B則不具有可見性。

Java併發程式設計Bug源頭:可見性、原子性和有序性問題

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

高階語言裡一條語句往往需要多條 CPU 指令完成,例如要完成count += 1,至少需要三條CPU指令。

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

作業系統進行執行緒切換,可以發生在任何一條CPU指令執行完(不是高階語言中的一條語句)。如下圖所示,假設線上程A執行第一條CPU指令後發生了執行緒切換,A與B會以圖中順序執行。得到的count不是我們期望的2,而是1.

Java併發程式設計Bug源頭:可見性、原子性和有序性問題

我們把一個或者多個操作在 CPU 執行的過程中不被中斷的特性成為原子性。CPU可以保證的原子操作是CPU指令級別,而高階語言層面保證操作的原子性。

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

有序性指的是程式按照程式碼先後順序執行,而編譯器為了優化效能,有時候會改變程式中語句的先後順序。 舉一個Java中的一個經典案例,雙重檢查的單例模式。

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

    return instance;
}複製程式碼

假設執行緒A、B同時呼叫getInstance()方法,乍一看上去,執行緒發現instance == null 後,會對Singleto.class加鎖,JVM保證只有一個執行緒可以獲得該鎖,則另一個執行緒會處於等待狀態。最後只有一個執行緒建立例項成功,另一個執行緒在鎖釋放後獲得鎖,然後檢查instance == null時,發現Singleto例項已經建立成功,所以不會再建立一個Singleto例項。 實際上,getInstance()方法是存在問題的,問題就在new操作上,我們預設任務new操作會以以下順序執行:

  • 1.在堆上分配一塊記憶體M;
  • 2.在記憶體M上初始化Singleto物件的例項;
  • 3.把M的地址賦值給instance變數。

但經過優化後的執行順序可能是這樣的:

  • 1.分配一塊記憶體M;
  • 2.將M的地址賦值給instance變數;
  • 3.最後在記憶體M上初始化Singleto物件。

假如執行緒A執行完指令2之後恰好發生了執行緒切換,切換到了執行緒B,B也執行getInstance()方法,則B會判斷instance != null,所以直接返回instance,而此時instance還沒有經過初始化,訪問該變數會觸發空指標異常。如下圖所示。

Java併發程式設計Bug源頭:可見性、原子性和有序性問題

總結

併發程式經常出現的問題歸根結底是直覺欺騙了我們,要診斷併發Bug,需要深刻理解可見性、原子性、有序性在併發場景下的原理。

併發程式設計Bug源頭: 快取 帶來的可見性問題; 執行緒 切換帶來的原子性問題; 編譯 優化帶來的有序性問題。


相關文章