【Java併發基礎】併發編程bug源頭:可見性、原子性和有序性

前言

CPU 、內存、I/O設備之間的速度差距十分大,爲了提升CPU的利用率而且平衡它們的速度差別。計算機體系結構、操做系統和編譯程序都作出了改進:java

  • CPU增長了緩存,用於平衡和內存之間的速度差別。
  • 操做系統增長了進程、線程,以時分複用CPU,進而均衡CPU與I/O設備之間的速度差別。
  • 編譯程序優化指令執行次序,使得緩存可以獲得更加合理地利用。

可是,每一種解決問題的技術出現都不可避免地帶來一些其餘問題。下面這三個問題也是常見併發程序出現詭異問題的根源。編程

  • 緩存——可見性問題
  • 線程切換——原子性問題
  • 編譯優化——有序性問題

CPU緩存致使的可見性問題

可見性指一個線程對共享變量的修改,另一個線程能夠馬上看見修改後的結果。緩存致使的可見性問題即指一個線程對共享變量的修改,另一個線程不能看見。緩存

單核時代:全部線程都是在一顆CPU上運行,CPU緩存與內存數據一致性很容易解決。
多核時代:每顆CPU都有本身的緩存,CPU緩存與內存數據一致性不易被解決。多線程

例如代碼:併發

public class Test {
  private long count = 0;
  private void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
  public static long calc() {
    final Test test = new Test();
    // 建立兩個線程,執行 add() 操做
    Thread th1 = new Thread(()->{
      test.add10K();
    });
    Thread th2 = new Thread(()->{
      test.add10K();
    });
    // 啓動兩個線程
    th1.start();
    th2.start();
    // 等待兩個線程執行結束
    th1.join();
    th2.join();
    return count;
  }
}

最後執行的結果確定不是20000,cal() 結果應該爲10000到20000之間的一個隨機數,由於一個線程改變了count的值,有緩存的緣由因此另一個線程不必定知道,因而就會使用舊值。這就是緩存致使的可見性問題。優化

 線程切換帶來的原子性問題

原子性指一個或多個操做在CPU執行的過程當中不被中斷的特性。spa

UNIX因支持時分複用而名噪天下,早期操做系統基於進程來調度CPU,不一樣進程之間是不共享內存空間的,因此進程要作任務切換就須要切換內存映射地址,可是這樣代價高昂。而一個進程建立的全部線程都是在一個共享內存空間中,因此,使用線程作任務切換的代價會比較低。如今的OS都是線程調度,「任務切換」——「線程切換」。操作系統

Java的併發編程是基於多線程的。任務切換大多數是在時間片結束時。
時間片:操做系統將對CPU的使用權期限劃分爲一小段一小段時間,這個小段時間就是時間片。線程耗費完所分配的時間片後,就會進行任務切換。線程

高級語言的一句代碼等價於多條CPU指令,而OS作任務切換能夠發生在任何一條CPU指令執行完後,因此,一個連續的操做可能會因任務切換而被中斷,即產生原子性問題。指針

例如:count+=1, 至少須要三條指令:

  1. 將變量count從內存加載到CPU寄存器;
  2. 在寄存器中執行+1操做;
  3. 將結果寫入內存(緩存機制致使寫入的是CPU緩存而非內存)

例如:

競態條件

因爲不恰當的執行時序而致使的不正確的結果,是一種很是嚴重的狀況,咱們稱之爲競態條件(Race Condition)。

當某個計算的正確性取決於多個線程的交替執行時序時,那麼就可能會發生競態條件。最多見的會出現競態條件的狀況即是「先檢查後執行(Check-Then-Act)」操做,即經過一個可能失效的觀測結果來決定下一步的動做。

例子:延遲初始化中的競態條件

使用「先檢查後執行」的一種常見狀況就是延遲初始化。延遲初始化的目的是將對象的初始化操做推遲到實際被使用時才進行,同時要確保只被初始化一次。

public class LazyInitRace{
    private ExpensiveObject instance = null;
    public ExpensiveObject getInstance(){
        if(instance == null){
            instance = new ExpensiveObject();
        }
        return instance;
    }
}

以上代碼便展現了延遲初始化的狀況。getInstance()方法首先判斷ExpensiveObject是否已經被初始化,若是已經初始化則返回現有的實例,不然,它將建立一個新的實例,並返回一個引用,從而在後來的調用中就無須再執行這段高開銷的代碼路徑。

getInstance()方法中包含了一個競態條件,這將會破壞類的正確性,即獲得錯誤的結果。
假設線程A和線程B同時執行getInstace()方法,線程A檢查到此時instance爲空,所以要建立一個ExpensiveObject的實例。線程B也會判斷instance是否爲空,而此時instance是否爲空則取決於不可預測的時序,包括線程的調度方式,以及線程A須要花費多長時間來初始化ExpensiveObject實例並設置instance。若是線程B檢查到instance爲空,那麼兩次調用getInstance()時可能會獲得不一樣的結果,即便getInstance一般被認爲是返回相同的實例。

競態條件並不老是產生錯誤,還須要某種不恰當的執行時序。然而,競態條件也可能會致使嚴重的問題。假設LazyInitRace被用於初始化應用程序範圍內的註冊表,若是在屢次調用中返回不一樣的實例,那麼要麼會丟掉部分註冊信息,要麼多個行爲對同一組對象表現出不一致的視圖。

要避免競態條件問題,就必須在某個線程修改該變量時,經過某種方式防止其餘線程使用這個變量,從而確保其餘線程只能在修改操做完成以前或者以後讀取和修改狀態,而不是在修改狀態的過程當中。

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

有序性是指程序按照代碼的前後順序執行。編譯器以及解釋器的優化,可能讓代碼產生意想不到的結果。

以Java領域一個經典的案例,進行解釋。
利用雙重檢查建立單例對象

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()方法,它們會同時發現instance==null,因而它們同時對Singleton.class加鎖,可是Java虛擬機保證只有一個線程能夠加鎖成功(假設爲線程A),而另外一個線程就會被阻塞處於等待狀態(假設是線程B)。
線程A會建立一個Singleton實例,而後釋放鎖,鎖釋放後,線程B被喚醒,線程B再次嘗試對Singleton.class加鎖,此時能夠加鎖成功,而後檢查instance==null時,發現對象已經被建立,因而線程B不會再建立Singleton實例。

可是,優化後new操做的指令,將會與咱們理解的不同:
咱們的理解:

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

可是優化後的執行路徑倒是這樣:

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

優化後將形成以下問題:

在如上的異常執行路徑中,線程B執行第一個判斷if(instance==null)時,會認爲instance!=null,因而直接返回了instance。可是此時的instance是沒有進行初始化的,這將致使空指針異常。
注意,線程執行synchronized同步塊時,也可能被OS剝奪CPU的使用權,可是其餘線程依舊是拿不到鎖的。

解決如上問題的一個方案就是使用volatile關鍵字修飾共享變量instance。

public class Singleton {
  volatile static Singleton instance;    //加上volatile關鍵字修飾
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

目前能夠簡單地將volatile關鍵字的做用理解爲:

  1. 禁用重排序;

  2. 保證程序的可見性(一個線程修改共享變量後,會馬上刷新內存中的共享變量值)。

小結

本篇博客介紹了致使併發編程bug出現的三個因素:可見性,有序性和原子性。本文僅限於引出這三個因素,後面將繼續寫文介紹如何來解決這些因素致使的問題。若有不足,還望各位看官指出,萬分感謝。

參考: [1]極客時間專欄王寶令《Java併發編程實戰》 [2]Brian Goetz.Tim Peierls. et al.Java併發編程實戰[M].北京:機械工業出版社,2016

相關文章
相關標籤/搜索