1. 程式人生 > >【Java併發基礎】併發程式設計bug源頭:可見性、原子性和有序性

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

前言

CPU 、記憶體、I/O裝置之間的速度差距十分大,為了提高CPU的利用率並且平衡它們的速度差異。計算機體系結構、作業系統和編譯程式都做出了改進:

  • 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執行的過程中不被中斷的特性。

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].北京:機械工業出版社,2