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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM