執行緒同步機制

eacape發表於2022-04-16

鎖概述

  1. 一個執行緒在訪問共享資料的時候必須要申請獲得相應的鎖(相當於許可證),執行緒只有在獲得相應的"許可證"後才能訪問共享資料,一個"許可證"同時只能被一個執行緒訪問,訪問完畢後執行緒需要釋放相應的鎖(交還許可證)以便於其它執行緒進行訪問,鎖申請後到鎖釋放前的這段程式碼被稱作為臨界區.
  2. 內部鎖:synchronized 顯示鎖:ReentrantLock
  3. 可見性是由寫執行緒沖刷處理器快取和讀執行緒重新整理處理器快取兩個動作保證的,在使用鎖的時候,會在獲取取鎖之前進行刷處理器快取動作,在釋放鎖後進行沖刷處理器快取動作.
  4. 儘管鎖能保證有序性,但臨界區內的操作仍然可能重排序,因為臨界區內的操作對其它執行緒是不可見的 ,這意味著即使臨界區的操作會發生重排序但是並不會造成有序性問題.
  5. 可重入性:一個執行緒在擁有這個鎖的時候能否繼續獲取這把鎖,如果可以,我們把這把鎖稱可重入鎖

    void metheadA(){
      acquireLock(lock); // 申請鎖lock
    
      // 省略其他程式碼
      methodB();
      releaseLock(lock); // 釋放鎖lock
    }
    
    void metheadB(){
      acquireLock(lock); // 申請鎖lock
    
      // 省略其他程式碼
      releaseLock(lock); // 釋放鎖lock
    }
  6. 鎖洩露:鎖被獲取後,一直未釋放.

內部鎖:synchronized

  1. 內部鎖得使用方式

    同步程式碼塊
    synchronized(lock){
      ......
    }
    ==============================
    同步方法
    public synchronized void method(){
      ......
    }
    等同於
    public void method(){
      synchronized(this){
        ......
      }
    }
    ==============================
    同步靜態方法
    class Example{
      public synchronized static void method(){
        ......
      }
    }
    等同於
    class Example{
      public static void method(){
        synchronized(Example.class){
          ......
        }
      }
    }
    
  2. 內部鎖並不會導致鎖洩露,這是因為java編譯器(javac)在將同步程式碼塊編譯成位元組碼得時候,對臨界區內的可能丟擲但是程式程式碼又未捕獲的異常進行了特殊處理,這使得即使臨界區的程式碼塊丟擲異常也不會影響內部鎖的正常釋放.
  3. java虛擬機器會為每一個內部鎖維護一個入口集(Entry Set)用於維護申請這個鎖的等待執行緒集合,多個執行緒同時申請獲取鎖的時候只有一個執行緒會申請成功,其它的失敗執行緒並不會丟擲異常,而是會被暫停(變成Blocked狀態)等待,並存入到這個入口集當中,待擁有鎖的這個執行緒執行完畢,java虛擬機器會從入口集當中隨機喚醒一個Blocked執行緒來申請這把鎖,但是它也並不一定能夠獲取到這把鎖,因為此時它可能還會面臨著其它新的活躍執行緒(Runnable)來爭搶這把鎖.

顯式鎖:Lock

  1. 內部鎖只支援非公平鎖,顯式鎖既可以支援公平鎖,也可以支援非公平鎖(預設非公平鎖).
  2. 公平鎖往往會帶來額外的開銷,因為,為了"公平"原則,多數情況下虛擬機器會增加執行緒的切換,這樣就會增加相比於非公平鎖來說更多的上下文切換。因此,公平鎖適合於執行緒會佔用鎖時間比較長的任務,這樣不至於導致某些執行緒飢餓.
  3. Lock的使用方法

    lock.lock()
    try{
      ......
    }catch(Exception e){
      ......
    }finally{
      lock.unlock()
    }
  4. synchronized和Lock的區別

    • synchronized是java的內建關鍵字屬於jvm層面,Lock是java的一個類
    • Lock.tryLock()可以嘗試獲取鎖,但是,synchronized不能
    • synchronized可以自動釋放鎖,Lock得手動unlock
    • synchronized是非公平鎖,Lock可以設定為公平也可以設定為非公平
    • Lock適合大量同步程式碼,synchronized適合少量同步程式碼
  5. 讀寫鎖:一個讀執行緒持有鎖得情況下允許其它讀執行緒獲取讀鎖,但是不允許寫執行緒獲取這把鎖,寫執行緒持有這個鎖的情況下不允許其它任何執行緒獲取這把鎖.
  6. 讀寫鎖的使用

    class Apple{
      ReadWriteLock lock = new ReentrantReadWriteLock();
      Lock writeLock = lock.writeLock();
      Lock readLock = lock.readLock();
      
      private BigDecimal price;
      
      public double getPrice(){
        double p;
        readLock.lock();
        try{
          p = price.divide(new BigDecimal(100)).doubleValue();
        }catch(Exception e){
          ...
        }finally{
          readLock.unLock();
        }   
        return double;
      }
      
      public void setPrice(double p){
        writeLock.lock();
        try{
          price = new BigDecimal(p);
        }catch(Exception e){
          ...
        }finally{
          writeLock.unLock();
        }
      }
    }
  7. 讀寫鎖適合以下場景:

    • 讀操作比寫操作更頻繁
    • 讀執行緒持有的時間比較長
  8. 鎖降級:一個執行緒再持有寫鎖的情況下可以申請將寫鎖降級為讀鎖.

    public class ReadWriteLockDowngrade {
      private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
      private final Lock readLock = rwLock.readLock();
      private final Lock writeLock = rwLock.writeLock();
    
      public void operationWithLockDowngrade() {
        boolean readLockAcquired = false;
        writeLock.lock(); // 申請寫鎖
        try {
          // 對共享資料進行更新
          // ...
          // 當前執行緒在持有寫鎖的情況下申請讀鎖readLock
          readLock.lock();
          readLockAcquired = true;
        } finally {
          writeLock.unlock(); // 釋放寫鎖
        }
    
        if (readLockAcquired) {
          try {
          // 讀取共享資料並據此執行其他操作
          // ...
    
          } finally {
          readLock.unlock(); // 釋放讀鎖
          }
        } else {
          // ...
        }
      }
    }
  9. 不支援鎖升級的原因 - 因為存在同時有多個執行緒擁有讀鎖的情況,所以鎖升級的過程中,可能發生死鎖.

    假設有A和B兩個讀執行緒獲取的是同一把讀鎖,那麼A執行緒想升級為寫鎖,等到B執行緒釋放讀鎖只之後B執行緒就可以升級成功.但是如果A執行緒想升級的同時B也想升級那麼,它們倆會同時等待對方釋放讀鎖,這樣的話就會造成一種對峙局面,即一種典型的死鎖.

記憶體屏障

  1. 記憶體屏障是指兩個指令插入到一段指令的兩側從而起到"屏障"的編譯器 處理器重排序的作用.
  2. 內部鎖的申請和釋放對應的位元組碼指令分別是 MonitorEnter 和 MonitorExit
  3. 由可見性可以將記憶體屏障劃分為 載入屏障(Load Barrier)和儲存屏障(Store Barrier),載入屏障的作用是重新整理處理器快取,儲存屏障的作用是沖刷處理器快取.

    java虛擬機器會在MonitorEnter指令之後的臨界區開始的之前的地方插入一個載入屏障保證其它執行緒對於共享變數的更新能夠同步到執行緒所在處理器的快取記憶體當中.同時,也會在MonitorExit指令之後插入一個儲存屏障,保證臨界區的程式碼對共享變數的變更能及時同步.

  4. 按照有序性可以將記憶體屏障劃分為 獲取屏障(Acquire Barrier)和釋放屏障(Release Barrier),獲取屏障會禁止臨界區指令與臨界區之前的程式碼指令發生重排序,釋放屏障會禁止臨界區指令和臨界區之後的程式碼指令發生重排序

    java虛擬機器會在MonitorEnter指令之後插入一個獲取屏障,在MonitorExit指令之前插入一個釋放屏障.

  5. 記憶體屏障下的排序規則(實線代表可排序,虛線代表不可排序)

輕量級同步機制:volatile關鍵字

  1. volatile關鍵字的作用包括:保障可見性、保障有序性和保障long/double型變數讀寫操作的原子性。

    long和double這兩種基本型別寫操作非原子性的原因是它們在32位java虛擬機器中的寫操作都是分雙32bit操作的,所以在java位元組碼當中,一個long或者double變數的寫操作是要執行兩步位元組碼指令.

  2. volatile變數不會被編譯器分配到暫存器進行儲存,對volatile的速寫操作都是記憶體訪問
  3. volatile關鍵字僅保證其修飾變數本身的讀寫原子性,如果要涉及其修飾變數的賦值原子性,那麼這個賦值操作不能涉及任何共享變數,否則其操作就不具有原子性.

    A = B + 1

    若A是一個volatile修飾的共享變數,那麼該賦值操作實際上是read-modify-write操作,如果B是一個共享變數那麼在賦值的過程中B可能已經被修改,所以可能會出現執行緒安全問題,但是如果B是一個區域性變數,那麼則這個賦值操作將是原子性的.

  4. volatile保證變數讀寫的有序性原理與synchronized基本相同 - 在寫操作前後增加相關的記憶體屏障(硬體基礎和記憶體模型文章中有詳細的內容)
  5. 如果被volatile修飾的是一個陣列,那麼volatile只對陣列本身的操作起作用,而並不對陣列元素的操作起作用。

    //nums被volatile修飾
    int num = nums[0];             //1
    nums[1] = 2;                    //2
    volatile int[] newNums = nums; //3

    如操作1實際上是兩個子步驟①讀取陣列引用,這個子步驟是屬於陣列操作是volatile的讀操作,所以可以讀取到nums陣列的相對新值,步驟②是在①的基礎上計算偏移量取得nums[0]的值,它並不是一個volatile操作,所以不能保障其讀取到的是一個相對新值。

    操作2可以分為①陣列的讀取操作和②陣列元素的寫操作,同樣①是一個volatile讀操作,但是②的寫操作可能產生相應的問題。

    操作3相當於是用一個volatile陣列更新另一個volatile陣列的引用,所有操作都是在陣列層面上的操作,所以不會產生併發問題。

  6. volatile的開銷

    volatile變數的讀、寫操作都不會導致上下文切換,因此volatile的開銷比鎖要小。寫一個volatile變數會使該操作以及該操作之前的任何寫操作的結果對其他處理器是可同步的,因此volatile變數寫操作的成本介於普通變數的寫操作和在臨界區內進行的寫操作之間。讀取volatile變數的成本也比在臨界區中讀取變數要低(沒有鎖的申請與釋放以及上下文切換的開銷),但是其成本可能比讀取普通變數要高一些。這是因為volatile變數的值每次都需要從快取記憶體或者主記憶體中讀取,而無法被暫存在暫存器中,從而無法發揮訪問的高效性。

單例模式執行緒安全問題

下面是一個經典的雙重校驗鎖的單例實現

public class Singleton {
  // 儲存該類的唯一例項
  private static Singleton instance = null;

  /**
   * 私有構造器使其他類無法直接通過new建立該類的例項
   */
  private Singleton() {
    // 什麼也不做
  }
  /**
   * 獲取單例的主要方法
   */
  public static Singleton getInstance() {
    if (null == instance) {// 操作1:第1次檢查
      synchronized (Singleton.class) { //操作2
        if (null == instance) {// 操作3:第2次檢查
          instance = new Singleton(); // 操作4
        }
      }
    }
    return instance;
  }
}

首先我們分析一下為什麼操作1和操作2的作用

如果沒有操作1和操作2,此時執行緒1來呼叫getInstance()方法,正在執行操作4時,同時執行緒2也來呼叫這個方法,有與操作4並沒有執行完成所以,執行緒2可以順利通過操作3的判斷,這樣就會出現問題,new Singleton()被執行了兩次,這也就違背了單例模式的本意。

由於上述的的問題所以在操作3之前加一個操作2這樣就會保證一次只會有一個執行緒來執行操作4,但是,這樣就會造成每次呼叫getInstance()都要申請/釋放鎖會造成極大的效能消耗,所以需要在操作2之前加一個操作1就會避免這樣的問題。

另外static修飾變數保證它只會被載入一次。

這樣看來這個雙重校驗鎖就完美了?

上面的操作4可以分為以下3個子操作

objRef = allocate(IncorrectDCLSingletion.class); // 子操作1:分配物件所需的儲存空間
invokeConstructor(objRef); // 子操作2:初始化objRef引用的物件
instance = objRef; // 子操作3:將物件引用寫入共享變數

synchronized的臨界區內是允許重排序的,JIT編譯器可能把以上的操作重排序成 子操作1→子操作3→子操作 2,所以可能發生的情況是一個執行緒在執行到重排序後的操作4(1→3→2)的時候,執行緒剛執行完子操作3的時候(子操作2沒有被執行),有其它的執行緒執行到操作1,那麼此時instance ≠ null就會直接將其retuen回去,但是這個instance是沒有被初始化的,所以會出現問題。

如果instance使用volatile關鍵字修飾,這種情況就不會發生,volatile解決了子操作2和子操作3的的重排序問題。

volatile還能避免這個共享變數不被存到暫存器當中,避免了可見性問題。

此外靜態內部類和列舉類也可以安全的實現單例模式

public class Singleton {
  // 私有構造器
  private Singleton() {
  }

  private static class InstanceHolder {
    // 儲存外部類的唯一例項
    final static Singleton INSTANCE = new Singleton();
  }

  public static Singleton getInstance() {
    return InstanceHolder.INSTANCE;
  }
}

上面是內部靜態類的實現方式,InstanceHolder會在呼叫的時候載入因此這也是一種懶漢式的單例。

CAS

CAS是一種更輕量級的鎖,它的主要實現方式是通過賦值前的比較實現的,比如i = i + 1操作,執行緒在將i+1的結果賦值給i之前會比較當前的i與i舊值(i+1之前記錄的值)是否相同,若相同則認為i在這個過程中沒有被其它執行緒修改過,反之就要廢棄之前的i+1操作,重新執行。

這種更新機制是以CAS操作是一個原子操作為基礎的,這一點直接由處理器來保障。但是CAS只能保證操作的原子性,不能保證操作的可見性(可見性得不到保證,有序性自然得不到保證)。

CAS可能會出現ABA問題也就是在i的初始值為 0 進行i + 1這個操作時,另一個執行緒將這個i變數修改為10,然後在這個過程中又有第三個執行緒將i修改回0,然後當執行緒在進行比較時發現i還是初始值,便將i+1的操作結果賦值給了i,這顯然不是我們想要的情況。解決方法可以在對這個變數操作的時候加一個版本號,每一次對其進行修改後版本+1,這個我們就可以清楚的看到,變數有沒有被其它執行緒改變過。

常用原子類的實現原理就是CAS

分組
基礎資料型AtomicInteger、AtomicLong、AtomicBoolean
陣列型AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
欄位更新器AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater
引用型AtomicReference、AtomicStampedReference、AtomicMarkableReference

static與final

  1. 一個類被虛擬機器載入後,該類的靜態變數的值都仍然是預設值(引用型別變數為null,boolean型別為false),直到有一個靜態變數第一次被訪問時static程式碼塊和變數才會被初始化。

    public class InitStaticExample {
        static class InitStatic{
            static String s = "hello world";
            static {
                System.out.println("init.....");
                Integer a = 100;
            }
        }
        public static void main(String[] args) {
            System.out.println(InitStaticExample.InitStatic.class.getName());
            System.out.println(InitStatic.s);
        }
    }
    =================結果=================
    io.github.viscent.mtia.ch3.InitStaticExample$InitStatic
    init.....
    hello world
    
  2. 對於引用型靜態變數來說,任何一個執行緒拿到這個變數的時候他都是初始化完成的(這裡和雙重校驗鎖的錯誤時不一樣的,雖然instance時靜態變數,雙重校驗鎖的Singleton物件並不是靜態類,所以new Singleton()有未初始化的風險)。但是,static的這種可見性和有序性保障僅在一個執行緒初次讀取靜態變數的時候起作用。
  3. 當一個物件被髮布到其它執行緒時,這個物件中的final變數總是初始化完成的(也保證引用變數時初始化完成的物件),保證了其它執行緒讀取到的這個值不是預設值。final只能解決有序性問題,即保證拿到的變數是初始化完成的,但是它並不能保證可見性。

相關文章