併發編程三大bug產生背景

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

程序裏大部分語句都要訪問內存,有些還需要訪問I/O,根據木桶原理,程序整體的性能取決於最慢的操作----- 讀寫I/O設備,也就是說不能單方面的提供某一性能。

爲了合理利用CPU的高性能,平衡三者之間的速度差異,計算機體系機構、操作系統、編譯程序都做了相對應的優化和改善:

1、CPU增加緩存,以均衡與內存的速度差異

2、操作系統增加了進程、線程,以分時複用CPU,進而均衡CPU和I/O設備的速度差異

3,編譯程序優化指令執行秩序,使得緩存能夠得到更加合理的利用

但是,優化的同時,還帶來了一些新的問題:

緩存導致共享資源的可見性問題

在單核時代,所有的線程都在在一顆CPU上執行,CPU緩存與內存的數據一致性容易解決。因爲所有線程都是操作一個CPU緩存,一個線程對緩存的讀寫,對另一個線程來說一定是可見的。

多核時代,每顆CPU都有自己的緩存,這時CPU緩存與內存的數據一致性就沒有那麼容易解決了,當多個線程在不同的CPU上執行時,這些線程操作的是不同的CPU緩存。

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

由於IO太慢,早期的操作系統就發明了多進程,即使在單核的CPU上,我們也可以一邊聽歌,一邊寫bug,這就是多進程的功勞。

操作系統允許某個進程執行一小段時間,例如50毫秒,過了50毫秒操作系統就會重新選擇一個進程來執行,這個50毫秒就稱之爲 時間分片

在一個時間分片內,如果一個進程運行一個IO操作,例如讀個文件,這個時候該進程可以把自己標記爲“休眠狀態”並出讓CPU的使用權,待文件讀進內存,操作系統會把這個休眠吧的進程喚醒,喚醒後的進程就有機會重新獲取CPU的使用權。

這裏的進程在等待IO時之所以釋放CPU使用權,是爲了讓CPU在這段等待時間裏可以做別的事情,這樣一來,CPU的使用率就上來了。

早期的操作系統基於進程來調度CPU,不同進程間是不共享內存空間的,所以進程要做任務切換就要切換內存隱射地址,而一個進程創建的所有線程,都是共享一個內存空間的,所以線程做任務切換的成本就很低了。

現在的操作系統都基於更輕量的線程來調度,現在我們提到的“任務切換”都是指“線程切換”。

java併發程序都是基於多線程的,自然也會涉及到任務切換。任務切換的時間大多數都是在時間片結束的時候,我們現在基本都使用高級語言編程,高級語言裏一條語句往往需要多條CPU完成。我們把一個或者多個操作在CPU執行的過程中不被中斷的特性稱之爲原子性

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

有序性是指程序按照代碼的先後順序執行。編譯器爲了優化性能,有時候會改變程序中語句的先後順序。

在 Java 領域一個經典的案例就是利用雙重檢查創建單例對象,例如下面的代碼:在獲取實例 getInstance() 的方法中,我們首先判斷 instance 是否爲空,如果爲空,則鎖定 Singleton.class 並再次檢查 instance 是否爲空,如果還爲空則創建 Singleton 的一個實例。

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 加鎖,此時 JVM 保證只有一個線程能夠加鎖成功(假設是線程 A),另外一個線程則會處於等待狀態(假設是線程 B);線程 A 會創建一個 Singleton 實例,之後釋放鎖,鎖釋放後,線程 B 被喚醒,線程 B 再次嘗試加鎖,此時是可以加鎖成功的,加鎖成功後,線程 B 檢查 instance == null 時會發現,已經創建過 Singleton 實例了,所以線程 B 不會再創建一個 Singleton 實例。 這看上去一切都很完美,無懈可擊,但實際上這個 getInstance() 方法並不完美。問題出在哪裏呢?出在 new 操作上,我們以爲的 new 操作應該是:
1、 分配一塊內存 M;
2、在內存 M 上初始化 Singleton 對象;
3、 然後 M 的地址賦值給 instance 變量。

但是實際上優化後的執行路徑卻是這樣的:
1、 分配一塊內存 M;
2、將 M 的地址賦值給 instance 變量;
3、 最後在內存 M 上初始化 Singleton 對象。

優化後會導致什麼問題呢?我們假設線程 A 先執行 getInstance() 方法,當執行完指令 2 時恰好發生了線程切換,切換到了線程 B 上;如果此時線程 B 也執行 getInstance() 方法,那麼線程 B 在執行第一個判斷時會發現 instance != null ,所以直接返回 instance,而此時的 instance 是沒有初始化過的,如果我們這個時候訪問 instance 的成員變量就可能觸發空指針異常。

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