學習極客時間上的《Java併發編程實戰》課程之餘,結合自己的理解整理一部分筆記以鞏固知識。
併發編程的起源
- 1.硬件設備發展的核心矛盾:CPU、內存、I/O設備三者間存在的速度差異。根據木桶原理,程序整體性能最終受制於速度最慢的I/O設備。
- 2.爲了平和三者速度差異,計算機體系結構、操作系統、編譯程序都做出了貢獻,主要體現爲:
- (1)CPU增加了緩存,以均衡與內存的速度差異;
- (2)操作系統增加了進程、線程,以分時複用CPU,進而均衡CPU與I/O設備的速度差異;
- (3)編譯程序優化指令執行順序,使得緩存能夠得到更加合理地利用。
併發編程出現問題的源頭
一:緩存導致的可見性問題
單核時代,所有線程在同一CPU上雲析,CPU緩存與內存的數據一致性容易解決。如下圖,線程A與B操作同一個CPU裏的緩存,故A修改過變量V後,B再訪問變量V,得到的一定是最新值,即A修改過的值。
一個線程對共享變量的修改,另一個線程可以立即看到,稱之爲可見性。
多核時代,每個CPU都有各自的緩存,當多個線程在不同的CPU上執行時,這些線程操作的是不同的CPU緩存,如下圖所示,線程A所修改的CPU-1緩存中的變量V,這個操作對線程B則不具有可見性。
二:線程切換帶來的原子性問題
高級語言裏一條語句往往需要多條 CPU 指令完成,例如要完成count += 1,至少需要三條CPU指令。
- 指令1:把變量count從內存加載到CPU的寄存器中;
- 指令2:在寄存器中執行 +1 操作;
- 指令3:將結果寫入內存(緩存機制導致可能寫入的是CPU緩存而不是內存)
操作系統進行線程切換,可以發生在任何一條CPU指令執行完(不是高級語言中的一條語句)。如下圖所示,假設在線程A執行第一條CPU指令後發生了線程切換,A與B會以圖中順序執行。得到的count不是我們期望的2,而是1.
我們把一個或者多個操作在 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還沒有經過初始化,訪問該變量會觸發空指針異常。如下圖所示。
總結
併發程序經常出現的問題歸根結底是直覺欺騙了我們,要診斷併發Bug,需要深刻理解可見性、原子性、有序性在併發場景下的原理。
併發編程Bug源頭:緩存帶來的可見性問題;線程切換帶來的原子性問題;編譯優化帶來的有序性問題。