【Java 併發編程】 03 萬惡的 Bug 起源—併發編程的三大特性

今天讓我們一起走進併發編程中萬惡的Bug起源—併發編程中三大特性。今天學習目標如下:

  • 併發編程的三大特性都要哪些 ?
  • 併發編程三大特性的由來?
  • 如何解決併發編程三大特性問題?

全文概覽

基本概念

原子性:一組操作要麼全部成功,要麼全部失敗。
可見性:一個線程對變量進行了修改,另外一個線程能夠立刻讀取到此變量的最新值。
有序性:代碼在運行期間保證按照編寫的順序。

爲什麼會有併發編程的三大特性呢?

話說女媧補天,精衛填海 …一直到上世紀60年代英特爾創始人戈登·摩爾講的:“集成電路上可容納的晶體管數目,約每隔18個月便會增加一倍,性能也將提升一倍”,意思就是說每過18個月,電腦的CPU性能就能提升一倍,是不是說一年半我們就需要換一次電腦呢? CPU 的處理數據的能力翻了好多倍,不再是以前的單核處理器,而是變成了多核處理器。但是你又可能聽說過這些道理,不怕神一樣的對手,就怕豬一樣的隊友一個桶能裝多少水完全取決於這個桶的短板, CPU 的性能是快了,內存的性能確沒有提升,磁盤的I/O讀寫速度也沒有提升,即使CPU運行的再快,最終被這些短板拖累着,也無濟於事。 爲了均衡 CPU與主存之間的速度的差異,均衡主存與磁盤讀寫速度的差異。CPU緩存的概念,以及程序編譯優化的誕生了,由於這些高性能概念的誕生,CPU緩存導致軟件代碼代碼程序運行出現了緩存不一致的問題,以及編譯優化帶來的有序性問題,線程併發切換帶來的原子性問題等。

緩存一致性導致了可見性問題

可見性:一個線程修改了共享變量,另一個線程能立即看到。

以前電腦是單CPU,也就意味着有單個CPU緩存,隨着CPU升級,目前大多數電腦是多核CPU,也就意味着CPU緩存不再只是單純的一個,而是多個。
在這裏插入圖片描述

如上圖,修改一個C變量的值大致分爲以下三步:

  • 從內存中獲取變量C的值,到CPU緩存中。
  • 在CPU緩存中對數據進行自增操作。
  • 將修改後的數據重新賦值到內存當中。

爲提高程序運行效率,讓兩個線程同時啓動,對默認值爲0 的共享變量C進行自增加一操作。每個線程各自執行50次。正常來說,程序各自運行50次,C變量的值應爲100,然而結果總是在50—100之間, 你知道是什麼原因麼。 我們假設線程 A 和線程 B 同時開始執行,那麼第一次都會將 count=0 讀到各自的 CPU 緩存裏,執行完 c+=1 之後,各自 CPU 緩存裏的值都是 1,同時寫入內存後,我們會發現內存中是 1,而不是我們期望的 2,長此以往,兩個線程總是做着相同重複的工作。一個線程修改數據後,寫入內存,對另一個線程是不可見的。導致兩者緩存中數據不一致。

線程切換帶來原子性問題

原子性:一組操作要麼全部執行成功,要麼全部失敗。

多線程的存在,時間片輪轉,發生線程的切換,導致一組操作被中斷
在這裏插入圖片描述
修改C變量的一個線程執行過程中分爲三步,時間片輪轉,造成線程被中斷,導致內存中的值沒有發生變化。

  • 從內存中獲取變量C的值,到CPU緩存中。
  • 在CPU緩存中對數據進行自增操作。
  • 將修改後的數據重新賦值到內存當中。

編譯優化帶來有序性問題

有序性:代碼在運行期間保證按照編寫的順序。
有一個去超時購物的例子,有一天自己想去元辰超市買點雞腿,此時你老婆說家裏的平底鍋壞了,你買一個回來,你懂得。你兒子又說,老爸,我想吃奶油蛋糕,如果奶油蛋糕沒有,就買旺仔牛奶吧。此時你媽又說,家裏沒有土豆了,順便你買點土豆回來。於是你列好了一個清單,清單如下:

  • 雞腿
  • 平底鍋
  • 如果沒有奶油蛋糕,就買旺仔牛奶(這個順序是不變的)
  • 土豆

以上清單就是我們寫代碼的順序。

可是一進超市你最先看到的就是蔬菜區,按理說爲了提高代碼執行效率,你應該先去買土豆,因爲土豆離你最近。最後再去二樓買平底鍋。CPU也不傻,爲了提高代碼的執行效率,肯定是執行買土豆的代碼,再去執行買平底鍋的代碼。無論如何執行,買平底鍋的和買土豆兩者是互補影響的。但是如果你先看到的是賣旺仔牛奶的貨架,後看到的是奶油蛋糕的貨架,CPU爲提高代碼執行效率,就會判斷此時有沒有買奶油蛋糕,發現沒有,於是買了旺仔牛奶,程序繼續執行,走到奶油蛋糕的貨架,又買了旺仔牛奶。最後既買了旺仔牛奶,又買了奶油蛋糕,這顯然是不正確的。

CPU指令重排序(土豆,雞腿、平底鍋、誰先後執行並不影響結果,但是買奶油蛋糕和買旺仔牛奶的順序是萬萬不能變的。)

  • 土豆
  • 雞腿
  • 旺仔牛奶
  • 奶油蛋糕
  • 平底鍋
    在這裏插入圖片描述

經典的單例模式:單例模式,每次只能實例化一個對象

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

我們看getInstance() 方法,對單例對象進行了兩次判空,第一次在 synchronized 代碼塊之外,第二次在 synchronized 內部,爲什麼要進行兩次判空呢?

首先我們要明確創建對象的步驟是如何進行的?

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

CPU 優化編譯後,執行代碼的順序如下,我們發現,第二步和第三步執行順序發生了變化。

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

如果CPU的執行順序發生了變化,當線程A執行完第二步,即M 的地址賦值給 instance 變量,此時發生了線程切換,線程 B 也執行 getInstance() 方法,在執行第一個判斷時會發現 instance != null ,所以直接返回 instance,而此時的 instance 是沒有初始化過的,如果我們這個時候訪問 instance 的成員變量就可能觸發空指針異常。

在這裏插入圖片描述

總結

線程切換帶來了原子性問題緩存一致性帶來了可見性問題CPU程序編譯優化帶來了有序性問題。那我們如何解決呢? 不用多線程,不用緩存,不讓CPU進行編譯優化,這顯示是不可能的,因爲這些是高性能的保證。那我們該如何解決這些問題呢?下一篇我們將介紹Java 內存模型(Java Memory Model)。看 Java 內存模型是如何解決多線程有序性和可見性問題的。

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