JAVA併發編程(一):可見性、原子性和有序性

CPU、內存、IO直接的關係

CPU:判斷以及邏輯處理。
內存:處理數據的地方,數據的來源是從硬盤加載進內存。內存本身有一定的存儲空間,對內存中的數據進行處理的速度比從硬盤取數據再處理的速度快很多。
硬盤:數據存儲。

我們的 CPU、內存、I/O設備都在不斷迭代,不斷朝着更快的方向努力。但是,在這個快速發展的過程中,有一個核心矛盾一直存在,就是這三者的速度差異。CPU和內存的速度差異可以形象地描述爲:CPU 是天上一天,內存是地上一年(假設 CPU 執行一條普通指令需要一天,那麼 CPU讀寫內存得等待一年的時間)。內存和 I/O 設備的速度差異就更大了,內存是天上一天,I/O 設備是地上十年。

由於三者直接速度的巨大差異,如果不加以平衡,就會出現CPU利用率低下的情況(由於CPU處理效率過快,內存以及IO跟不上,CPU就處於長期空閒狀態),爲了合理利用 CPU 的高性能,平衡這三者的速度差異,計算機體系機構、操作系統、編譯程序都做出了貢獻,主要體現爲:

1、CPU 增加了緩存,以均衡與內存的速度差異;
2、操作系統增加了進程、線程,以分時複用 CPU,進而均衡 CPU 與 I/O 設備的速度差異;
3、編譯程序優化指令執行次序,使得緩存能夠得到更加合理地利用。

線程安全場景一:緩存導致的可見性問題

多核時代,每顆 CPU 都有自己的緩存,這時 CPU 緩存與內存的數據一致性就沒那麼容易解決了,當多個線程在不同的 CPU 上執行時,這些線程操作的是不同的 CPU 緩存。比如下圖中,線程 A 操作的是 CPU-1 上的緩存,而線程 B 操作的是 CPU-2 上的緩存,很明顯,這個時候線程 A 對變量 V 的操作對於線程 B 而言就不具備可見性了。這個就屬於硬件程序員給軟件程序員挖的“坑”。
在這裏插入圖片描述

線程安全場景二:線程切換帶來的原子性問題

我們把一個或者多個操作在 CPU 執行的過程中不被中斷的特性稱爲原子性。CPU 能保證的原子操作是 CPU 指令級別的,而不是高級語言的操作符,這是違揹我們直覺的地方。因此,很多時候我們需要在高級語言層面保證操作的原子性。

例如代碼中的count += 1,至少需要三條 CPU 指令。

指令 1:首先,需要把變量 count 從內存加載到 CPU 的寄存器;
指令 2:之後,在寄存器中執行 +1 操作;
指令 3:最後,將結果寫入內存(緩存機制導致可能寫入的是 CPU 緩存而不是內存)。

現在有兩個線程A,B同時執行了這行count+=1語句,A線程先執行到了指令1,這時候發生線程切換,B線程把三個指令全部執行結束後再次發生線程切換,這時候A線程由於已經讀取了count值爲1,繼續執行+1操作,就發生了線程安全問題,“讀、改、寫”應該作爲一個原子性的操作。

線程安全場景三:編譯優化帶來的有序性問題

那併發編程裏還有沒有其他有違直覺容易導致詭異 Bug 的技術呢?有的,就是有序性。顧名思義,有序性指的是程序按照代碼的先後順序執行。編譯器爲了優化性能,有時候會改變程序中語句的先後順序,例如程序中:“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;
  }
}

現在有A、B線程同時調用getInstance()方法,A線程先進入,由於是第一次執行方法,在執行到instance = new Singleton(),我們先把這段語句翻譯成CPU指令:

指令1、分配一塊內存 M;
指令2、在內存 M 上初始化 Singleton 對象;
指令3、然後 M 的地址賦值給 instance 變量。

但由於編譯器及解釋器的優化,可能CPU指令就變成了這樣:

指令1、分配一塊內存 M;
指令2、將 M 的地址賦值給 instance 變量;
指令3、最後在內存 M 上初始化 Singleton 對象。

好,我們接着前面的繼續走,A線程執行到instance = new Singleton(),我們知道實際上執行的是編譯後的CPU指令,當A線程執行完上述指令2時,發生了線程切換,B線程開始執行getInstance()方法,當判斷if (instance == null) 爲false時,直接返回了instance對象,可是這時候instance對象還未進行初始化,在接下來B線程如果繼續調用instance對象,就會發生空指針異常。

附送:在 32 位的機器上對 long 型變量進行加減操作存在併發隱患的原因

long類型64位,所以在32位的機器上,對long類型的數據操作通常需要多條指令組合出來,無法保證原子性,所以存在併發隱患。

文章參考:Java併發編程實戰 ——王寶令

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章