Java併發之同步器設計

onlythinking發表於2020-06-17

Java併發之記憶體模型瞭解到多程式(執行緒)讀取共享資源的時候存在競爭條件

共享資源

計算機中通過設計同步器來協調程式(執行緒)之間執行順序。同步器作用就像登機安檢人員一樣可以協調旅客按順序通過。

在Java中,同步器可以理解為一個物件,它根據自身狀態協調執行緒的執行順序。比如鎖(Lock),訊號量(Semaphore),屏障(CyclicBarrier),阻塞佇列(Blocking Queue)。

這些同步器在功能設計上有所不同,但是內部實現上有共通的地方。

同步器

同步器的設計一般包含幾個方面:狀態變數設計(同步器內部狀態)訪問條件設定狀態更新等待方式通知策略

同步器構造

訪問條件是控制執行緒是否能執行(訪問共享物件)的條件,它往往與狀態變數緊密相關。而通知策略執行緒釋放鎖定狀態後通知其它等待執行緒的方式,一般有以下幾種情況

  1. 通知所有等待的執行緒。
  2. 通知1個隨機的N個等待執行緒。
  3. 通知1個特定的N個等待執行緒

看下面例子,通過方式的同步器

public class Lock{
  // 狀態變數 isLocked
  private boolean isLocked = false; 
  public synchronized void lock() throws InterruptedException{
    // 訪問條件 當isLocked=false 時獲得訪問許可權否則等待
    while(isLocked){
      // 阻塞等待
      wait();
    }
    //狀態更新 執行緒獲得訪問許可權
    isLocked = true;
  }
  
  public synchronized void unlock(){
    //狀態更新 執行緒釋放訪問許可權
    isLocked = false;
    // 通知策略 object.notify | object.notifyAll
    notify(); 
  }
}

我們用計數訊號量控制同時執行操作活動數。這裡模擬一個連線池。

public class PoolSemaphore {
      // 狀態變數 actives 計數器
    private int actives = 0;
    private int max;
    public PoolSemaphore(int max) {
        this.max = max;
    }
    public synchronized void acquire() throws InterruptedException {
        //訪問條件 啟用數小於最大限制時,獲得訪問許可權否則等待
        while (this.actives == max) wait();
        //狀態更新 執行緒獲得訪問許可權
        this.actives++;
        // 通知策略 object.notify | object.notifyAll
        this.notify();
    }
    public synchronized void release() throws InterruptedException {
        //訪問條件 啟用數不為0時,獲得訪問許可權否則等待
        while (this.actives == 0) wait();
         //狀態更新 執行緒獲得訪問許可權
        this.actives--;
        // 通知策略 object.notify | object.notifyAll
        this.notify();
    }
}

原子指令

同步器設計裡面,最重要的操作邏輯是“如果滿足條件,以更新狀態變數來標誌執行緒獲得釋放訪問許可權”,該操作應具備原子性

比如test-and-set 計算機原子指令,意思是進行條件判斷滿足則設定新值

function Lock(boolean *lock) { 
    while (test_and_set(lock) == 1); 
}

另外還有很多原子指令 fetch-and-add compare-and-swap,注意這些指令需硬體支援才有效。

同步操作中,利用計算機原子指令,可以避開鎖,提升效率。java中沒有 test-and-set 的支援,不過 java.util.concurrent.atomic 給我們提供了很多原子類API,裡面支援了 getAndSetcompareAndSet 操作。

看下面例子,主要在區別是等待方式不一樣,上面是通過wait()阻塞等待,下面是無阻塞迴圈


public class Lock{
  // 狀態變數 isLocked
  private AtomicBoolean isLocked = new AtomicBoolean(false);
  public void lock() throws InterruptedException{
    // 等待方式 變為自旋等待
    while(!isLocked.compareAndSet(false, true));
    //狀態更新 執行緒獲得訪問許可權
    isLocked.set(true);
  }
  
  public synchronized void unlock(){
    //狀態更新 執行緒釋放訪問許可權
    isLocked.set(false);
  }
}

關於阻塞擴充套件說明

阻塞意味著需要將程式執行緒狀態進行轉存,以便還原後恢復執行。這種操作是昂貴繁重,而執行緒基於程式之上相對比較輕量。執行緒的阻塞在不同程式設計平臺實現方式也有所不同,像Java是基於JVM執行,所以它由JVM完成實現。

在《Java Concurrency in Practice》中,作者提到

競爭性同步可能需要OS活動,這增加了成本。當爭用鎖時,未獲取鎖的執行緒必須阻塞。 JVM可以通過旋轉等待(反覆嘗試獲取鎖直到成功)來實現阻塞,也可以通過操作系統掛起阻塞的執行緒來實現阻塞。哪種效率更高取決於上下文切換開銷與鎖定可用之前的時間之間的關係。對於短暫的等待,最好使用自旋等待;對於長時間的等待,最好使用暫停。一些JVM基於對過去等待時間的分析資料來自適應地在這兩者之間進行選擇,但是大多數JVM只是掛起執行緒等待鎖定。

從上面可以看出JVM實現阻塞兩種方式

  • 旋轉等待(spin-waiting),簡單理解是不暫停執行,以迴圈的方式等待,適合短時間場景。
  • 通過作業系統掛起執行緒。

JVM中通過 -XX: +UseSpinning 開啟旋轉等待, -XX: PreBlockSpi =10指定最大旋轉次數。

useSpinning.png

AQS

AQS是AbstractQueuedSynchronizer簡稱。本節對AQS只做簡單闡述,並不全面。

java.util.concurrent包中的 ReentrantLock,CountDownLatch,Semaphore,CyclicBarrier等都是基於是AQS同步器實現。

狀態變數 是用 int state 來表示,狀態的獲取與更新通過以下API操作。

     int getState()
    void setState(int newState)
    boolean compareAndSetState(int expect, int update)

該狀態值在不同API中有不同表示意義。比如ReentrantLock中表示持有鎖的執行緒獲取鎖的次數Semaphore表示剩餘許可數。

關於等待方式通知策略的設計

AQS通過維護一個FIFO同步佇列(Sync queue)來進行同步管理。當多執行緒爭用共享資源時被阻塞入隊。而執行緒阻塞與喚醒是通過 LockSupport.park/unpark API實現。

它定義了兩種資源共享方式。

  • Exclusive(獨佔,只有一個執行緒能執行,如ReentrantLock)
  • Share(共享,多個執行緒可同時執行,如Semaphore/CountDownLatch)

每個節點包含waitStatus(節點狀態),prev(前繼),next(後繼),thread(入隊時執行緒),nextWaiter(condition佇列的後繼節點)

執行緒等待佇列

waitStatus 有以下取值。

  • CANCELLED(1) 表示執行緒已取消。當發生超時或中斷,節點狀態變為取消,之後狀態不再改變。
  • SIGNAL(-1) 表示後繼節點等待前繼的喚醒。後繼節點入隊時,會將前繼狀態更新為SIGNAL。
  • CONDITION(-2) 表示執行緒在Condition queue 裡面等待。當其他執行緒呼叫了Condition.signal()方法後,CONDITION狀態的節點將從 Condition queue 轉移到 Sync queue,等待獲取鎖。
  • PROPAGATE(-3) 在共享模式下,當前節點釋放後,確保有效通知後繼節點。
  • (0) 節點加入佇列時的預設狀態。

AQS 幾個關鍵 API

  • tryAcquire(int) 獨佔方式下,嘗試去獲取資源。成功返回true,否則false。
  • tryRelease(int) 獨佔方式下,嘗試釋放資源,成功返回true,否則false。
  • tryAcquireShared(int) 共享方式下,嘗試獲取資源。返回負數為失敗,零和正數為成功並表示剩餘資源。
  • tryReleaseShared(int) 共享方式下,嘗試釋放資源,如果釋放後允許喚醒後續等待節點返回true,否則false。
  • isHeldExclusively() 判斷執行緒是否正在獨佔資源。

acquire(int arg)

    public final void acquire(int arg) {
        if (
          // 嘗試直接去獲取資源,如果成功則直接返回
          !tryAcquire(arg)
            &&
            //執行緒阻塞在同步佇列等待獲取資源。等待過程中被中斷,則返回true,否則false
            acquireQueued(
              // 標記該執行緒為獨佔方式,並加入同步佇列尾部。
              addWaiter(Node.EXCLUSIVE), arg) 
           )
            selfInterrupt();
    }

release(int arg)

    public final boolean release(int arg) {
          // 嘗試釋放資源
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
              // 喚醒下一個執行緒(後繼節點)
              unparkSuccessor(h);
            return true;
        }
        return false;
    }
private void unparkSuccessor(Node node) {
        ....
              Node s = node.next; // 找到後繼節點
        if (s == null || s.waitStatus > 0) {//無後繼或節點已取消
            s = null;
           // 找到有效的等待節點 
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        if (s != null)
            LockSupport.unpark(s.thread); // 喚醒執行緒
    }

總結

本文記錄併發程式設計中同步器設計的一些共性特徵。並簡單介紹了Java中的AQS。

歡迎大家留言交流,一起學習分享!!!

相關文章