Java 多執行緒 相關概念

baiiu發表於2017-03-31

前言

本篇文章介紹一些多執行緒的相關的深入概念。理解後對於執行緒的安全性會有更深的理解。

先說一個格言,摘自Java核心技術:
如果向一個變數寫入值,而這個變數接下來可能會被另一個執行緒讀取;或者一個變數讀值,而這個變數可能是之前被另一個執行緒寫入的,此時必須同步。

下面就是概念了。

1. Monitor機制:

  • Monitor其實是一種同步工具、同步機制,通常被描述成一個物件,主要特點是:

    1. 同步。
      物件的所有方法都被互斥的執行。好比一個Monitor只有一個執行“許可”,任一個執行緒進入任何一個方法都需要獲得這個“許可”,離開時把許可歸還。
    2. 協作。
      通常提供signal機制。允許正持有許可的執行緒暫時放棄許可,等待某個監視條件成真,條件成立後,當前執行緒可以通知正在等待這個條件的執行緒,讓它可以重新獲得執行許可。
  • 在 Monitor Object 模式中,主要有四種型別參與者:

    1. 監視者物件 Monitor Object
      負責公共的介面方法,這些公共的介面方法會在多執行緒的環境下被呼叫執行。
    2. 同步方法
      這些方法是監視者物件所定義。為了防止競爭條件,無論是否有多個執行緒併發呼叫同步方法,還是監視者物件還用多個同步方法,在任一事件內只有一個同步方法能夠執行。
    3. 監控鎖 Monitor Lock
      每一個監視者物件都會擁有一把監視鎖。
    4. 監控條件 Monitor Condition
      同步方法使用監視鎖和監視條件來決定方法是否需要阻塞或重新執行。
  • Java中,Object 類本身就是監視者物件,Java 對於 Monitor Object 模式做了內建的支援。

    • Object 類本身就是監視者物件
    • 每個 Object 都帶了一把看不見的鎖,通常叫 內部鎖/Monitor 鎖/Instrinsic Lock, 這把鎖就是 監控鎖
    • synchronized 關鍵字修飾方法和程式碼塊就是同步方法
    • wait()/notify()/notifyAll() 方法構成監控條件(Monitor Condition)

2. 記憶體模型

Java的併發採用的是共享記憶體模型,執行緒間通訊是隱式的,同步是顯示的;而我們在Android中所常說的Handler通訊即採用的是訊息傳遞模型,通訊是顯示的,同步是隱式的。

  • 併發程式設計模型的分類
    併發程式設計中,需要處理兩個問題:執行緒之間如何通訊、執行緒之間如何同步。

    • 通訊是指執行緒之間以何種機制來交換資訊。在指令式程式設計中,執行緒之間的通訊機制有兩種:共享記憶體和訊息傳遞。
      在共享記憶體的併發模型裡,執行緒之間通過寫-讀記憶體中的公共狀態來隱式進行通訊;而在訊息傳遞模型裡,執行緒之間沒有公共狀態,必須通過明確的傳送資訊來顯示進行通訊。
    • 同步是指程式用於控制不同執行緒之間操作發生相對順序的機制。
      在共享記憶體併發模型裡,同步是顯示進行的,程式設計師必須顯示指定某段程式碼或方法需要線上程間互斥執行;而在訊息傳遞模型中,由於訊息的傳送必須在訊息的接收之前,因此同步是隱式進行的。
  • Java記憶體模型的抽象
    Java堆記憶體線上程間共享,下文所說的共享變數即被儲存在堆記憶體中變數:例項域、靜態域和陣列。區域性變數、方法定義引數和異常處理引數不會線上程之間共享,不會有記憶體可見性問題,也不受記憶體模型影響。

  • Java執行緒之間的通訊由Java記憶體模型(JMM,Java Memory Module)控制,JMM決定了一個執行緒對共享變數的寫入何時對另一個執行緒可見。
    JMM定義了執行緒和主記憶體之間的抽象關係:執行緒之間的共享變數儲存在主記憶體中,每個執行緒都有一個私有的本地記憶體,也叫工作記憶體,本地記憶體中儲存了該執行緒以讀/寫共享變數的副本。(本地記憶體是JMM的一個抽象概念,並不真實存在,它涵蓋了快取、寫緩衝區、暫存器以及其他的硬體和編譯器優化。)
    所以執行緒A和執行緒B要通訊步驟如下:

    1. 首先,執行緒A把本地記憶體A中更新過的共享變數重新整理到主記憶體中去
    2. 然後,執行緒B到主記憶體中去讀取執行緒A之前已更新過的共享變數
  • 執行緒模型圖

    執行緒模型
    執行緒模型

3. 原子性

原子性指:一個操作(有可能包含有多個子操作)要麼全部執行(生效),要麼全部都不執行(都不生效)。
java.util.concurrent.atomic包中很多類使用了CAS指令來保證原子性,而不再使用鎖。如AtomicIntergerAtomicBooleanAtomicLongAtomicReference等。
原子性不保證順序一致性,只保證操作是原子的。

4. 記憶體可見性

可見性是指,當多個執行緒併發訪問共享變數時,一個執行緒對共享變數的修改,其它執行緒能夠立即看到。

  • 從上面可知道執行緒模型,執行緒a對共享變數修改時先把值放到自己的工作記憶體中,然後再把工作記憶體中的共享變數更新到主記憶體中;執行緒b同樣如此;當執行緒a更新了主記憶體後執行緒b重新整理工作記憶體後就能看到a更新後的最新值。這就是記憶體可見性問題。
  • 記憶體可見性要保證兩點:
    1. 執行緒修改後的共享變數更新到主記憶體;
    2. 從主記憶體中更新最新值到工作記憶體中;

5. happens-before

happens-before規則對應於一個或多個編譯器和處理器重排序規則,對於程式設計師來說,該規則易懂,避免為了理解JMM提供的記憶體可見性保證而去學習複雜的重排序規則以及這些規則的具體實現。

使用happens-before的概念來闡述操作之間的記憶體可見性
如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在happens-before關係。
這兩個操作可以在一個執行緒內,也可以是不同執行緒。

兩個操作之間具有happens-before關係,並不意味著前一個操作必須要在後一個操作前執行;僅僅要求前一個操作的執行結果對後一個可見,且前一個操作按順序排在第二個操作之前。

  • 傳遞規則:如果操作1在操作2前面,而操作2在操作3前面,則操作1肯定會在操作3前發生。該規則說明了happens-before原則具有傳遞性
  • 鎖定規則:一個unlock操作肯定會在後面對同一個鎖的lock操作前發生。這個很好理解,鎖只有被釋放了才會被再次獲取
  • volatile變數規則:對一個被volatile修飾的寫操作先發生於後面對該變數的讀操作
  • 程式次序規則:一個執行緒內,按照程式碼順序執行
  • 執行緒啟動規則:Thread物件的start()方法先發生於此執行緒的其它動作
  • 執行緒終結原則:執行緒的終止檢測後發生於執行緒中其它的所有操作
  • 執行緒中斷規則: 對執行緒interrupt()方法的呼叫先發生於對該中斷異常的獲取
  • 物件終結規則:一個物件構造先於它的finalize發生

6. CAS指令

是現代處理器上提供的高效機器級別的原子指令,這些原子指令以原子方式對記憶體執行讀-寫-改操作,這是在多處理器中實現同步的關鍵。
AtomicIntergerAtomicBooleanAtomicLong的實現都是基於CAS指令。

7. 重排序

在計算機中,軟體技術和硬體技術有一個共同的目標:在不改變程式執行結果的前提下,儘可能的提高開發並行度。

  • 編譯器和處理器會對指令進行重排序以提高效能,重排序有三種型別:
    1. 編譯器優化的重排序。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。
    2. 指令級別的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴,處理器可以改變語句對應機器指令的執行順序。
    3. 記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是亂序執行。
  • 這些重排序都可能會導致多執行緒程式出現記憶體可見性問題。
    對於處理器重排序,JMM會要求編譯器在生成指令序列時插入特定型別的記憶體屏障指令來禁止特定型別的處理器重排。
    JMM屬於語言級別的記憶體模型,它確保在不同的編譯器和不同的處理器平臺上,通過禁止一些重排序問題來保證記憶體可見性。
  • as-if-serial語義
    是指不管怎麼重排序,單執行緒程式的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。
    所以,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果。

  • 資料依賴性

    • 有三種型別:
      1. 寫後讀:a = 1; b = a;
      2. 寫後寫:a = 1; a = 2;
      3. 讀後寫:a = b; b =1;
    • 舉個例子:
      int a = 1; int b = 1; int sum = a + b;
      A和B不存在資料依賴,sum卻依賴A和B。所以執行順序可能是ABsum,也可能是BAsum。
  • 重排序對多執行緒的影響
    重排序破壞了多執行緒程式的語義。對於存在控制依賴的操作(if語句)進行重排序,因為單執行緒程式是按順序來執行的,所以執行結果不會改變;而多執行緒程式中,重排序可能會改變執行結果。
    對控制依賴if(flag){b = a*a}的重排序如下,編譯器和處理器會採用猜測執行來克服相關性來對並行度的影響,對先提取並計算a*a,然後把計算結果儲存到名為重排序緩衝的硬體快取中,接下來再判斷flag是否為真。另一個執行緒設定為true了,並設定a=1,然而取得的值可能為0,與預期不符。這就是影響的一個案例。

  • 重排序的一個示例,摘自EffectiveJava:

    while(!done) {
      i++
    }
    //重排後。這種優化稱作提示,是HopSpot Server VM的工作
    if(!done){
      while(true) {
        i++;
      }
    }複製程式碼

8. 順序一致性

如果一個多執行緒程式能正確同步,這個程式將是一個沒有資料競爭的程式。JMM對正確同步的多執行緒程式的記憶體一致性做了如下保證:如果程式是正確同步的,程式的執行將具有順序一致性,即程式的執行結果與該程式在順序一致性記憶體模型中的執行結果相同。

  • 順序一致性記憶體模型(為程式設計師提供了極強的記憶體可見性保證)的兩大特性:
    1. 一個執行緒中的所有操作必須按照程式的順序來執行
    2. 所有執行緒都只能看到一個單一的操作執行順序。每個操作都必須原子執行且立刻對所有執行緒可見。
  • 其中對順序一致性和原子性的區別
    原子性保證操作的原子性,而不是順序的一致性。

9. volatile域

首先要明確,執行緒的安全性需要三點保證:原子性、可見性,順序性。只有滿足了這三個條件時執行緒才是安全的。

  • 一旦一個共享變數(類的成員變數、類的靜態成員變數)被volatile修飾之後,那麼就具備了兩層語義:
    1. 保證了不同執行緒對這個變數進行操作時的可見性,即一個執行緒修改了某個變數的值,這新值對其他執行緒來說是立即可見的。
      volatile 變數保證的是一個執行緒對它的寫會立即重新整理到主記憶體中,並置其它執行緒的副本為無效,它並不保證對 volatile 變數的操作都是具有原子性的。
    2. 禁止進行指令重排序。
  • synchronized、Lock完全保證了這三點;volatile僅保證了可見性和順序性(禁止指令重排),在某些情況下可以使用volatile代替synchronized以提高效能。在這種情況下,volatile是輕量級的synchronized。

    • 某些情況下是指:
      假設對共享變數除了賦值以外並不完成其他操作,那麼可以將這些共享變數宣告為volatile。即共享變數本身的操作是原子性的、順序性的,只缺可見性了,此時可以用volatile關鍵字。在使用時要仔細分析。
      具體是指:

      • 對變數的寫操作不依賴於當前值。
      • 該變數沒有包含在具有其他變數的不變式中。
    • 要記住,原子性指的是對共享變數的操作(包括其子操作,即多條語句)是一塊的,要麼執行,要麼不執行。不是說用了AtomicInteger就是原子性的,而是對AtomicInteger這個共享變數的操作是不是多條語句,這些多條語句是不是原子性的。

  • 經典示例1:單例模式

  • 經典示例2:

    boolean volatile isRunning = false;
    public void start () {
    new Thread( () -> {
      while(isRunning) {
        someOperation();
      }
    }).start();
    }
    public void stop () {
    isRunning = false;//只有賦值操作,非多條語句
    }複製程式碼


參考:
Java進階(二)當我們說執行緒安全時,到底在說什麼
Java併發程式設計:volatile關鍵字解析
併發模型——共享記憶體模型(執行緒與鎖)理論篇
《深入理解Java記憶體模型》

相關文章