Java高階:條件佇列與同步器Synchronizer的原理+AQS的應用

xuxh120發表於2021-12-24

14.構建自定義的同步工具

 類庫中包含了許多存在狀態依賴性的類,例如FutureTaskSemaphore和BlockingQueue等。在這些類中的一些操作中有著基於狀態的前提條件,例如,不能從一個空的佇列中刪除元素,或者獲取一個尚未結束的任務的計算結果,在這些操作可以執行之前,必須等到佇列進入“非空”狀態,或者任務進入“已完成”狀態。

建立狀態依賴類的最簡單方法通常是在類庫中現有的狀態依賴類的基礎上進行構造。本章將介紹實現狀態依賴性的各種選擇,以及在使用平臺提供的狀態依賴性機制時需要遵守的各項規則。

 

 

14.1 狀態依賴性的管理

1.1 狀態依賴的管理

對單執行緒的程式而言,如果基於狀態的前提條件未得到滿足,那麼這個條件將永遠無法成真,此時失敗即可。

但是在併發程式中,基於狀態的條件可能會由於其他執行緒的操作而改變:一個資源池在前幾條指令之前還是空的,但現在卻變為非空的,因為另外一個執行緒往裡面新增了元素。對於併發物件,依賴於狀態的方法,雖然有時可以在不滿足前提條件的情況下選擇失敗, 不過更好的選擇是等待前提條件為真。

 

狀態依賴的操作可以一直阻塞直到可以繼續執行,這比使他們先失敗在實現起來要更為方便且更不容易出錯。內建的條件佇列可以使執行緒一直阻塞,直到物件進入某個程式可以繼續執行的狀態,並且當被阻塞的執行緒可以執行時再喚醒他們。為了突出搞笑的條件等待機制的價值。我們先介紹如何通過輪訓與休眠等方式來解決狀態依賴性的問題。

 程式清單 14-1 可阻塞的狀態依賴操作的結構

acquire lock object state
while (precondition does not hold) {
        release lock
        wait until precondition might hold
        optionally fail if interrupted or timeout expires
        reacquire lock
}
perform action
release lock

程式清單 14-1 的這種加鎖模式有些不同尋常,因為鎖是在操作執行過程中被釋放與重新獲取的。構成前提條件的狀態變數必須由物件的鎖保護起來,這樣它們能在測試前提條件的過程中保持不變。如果前提條件尚未滿足,就必須釋放鎖,讓其他執行緒可以修改物件的狀態。否則,前提條件就永遠無發成真了。再次測試前提條件之前,必須要重新獲得鎖.

 

接下來以有屆快取的實現為例,介紹一下采用不同方式來處理前提條件失敗。在每種實現中都擴充套件了程式清單14-2中的BaseBoundedBuffer,在該類中實現了一個基於陣列的迴圈快取,其中各個快取狀態變數(buf、head、tail、count)均有快取的內建鎖來保護。同時還提供了同步的doPut和doTake方法,並在子類中通過這些方法來實現put和take操作,底層狀態對子類隱藏。

 程式清單 14-2 有屆快取實現的基類

public class BaseBoundedBuffer<V> {
    private  final V[] buf;
    private int tail;
    private int head;
    private int count;
    
    protected BaseBoundedBuffer(int capacity) {
        this.buf = (V[]) new Object[capacity];
    }

 
    protected synchronized final void doPut(V v){
        buf[tail]  = v;
        if(++tail == buf.length)
            tail = 0;
        ++count;
    }
 
    protected  synchronized  final V doTake(){
        V v = buf[head];
        buf[head] = null;
        if (++head == buf.length)
            head = 0;
        -- count;
        return v;
    }

    public synchronized  final boolean isFull(){
        return count == buf.length;
    }

    public synchronized  final boolean isEmpty(){
        return count == 0 ;
    }
}

 

1.2 通過"輪詢加休眠"實現拙劣的阻塞

 程式清單 14-3 使用簡單阻塞實現的有屆快取

public class SleepyBoundedBuffer<V> extends BaseBoundedBuffer<V> {

    protected SleepyBoundedBuffer(int capacity) {
        super(capacity);
    }
 
    public void put(V v) throws InterruptedException {
        //無限嘗試將v新增入集合
        while (true) {
            //獲得鎖
            synchronized (this) {
                //如果不空,就新增進集合,退出迴圈
                if (!isFull()) {
                    doPut(v);
                    return;
                }
            }

            //否則釋放鎖,休眠一段時間,給其他執行緒一些修改的機會.
            Thread.sleep(1000);
        }
    }
 
    public V take() throws InterruptedException {
        while (true) {
            synchronized (this) {
                if (!isEmpty()) 
                    return doTake();
            }
            Thread.sleep(1000);
        }
    }
}

另外,除了阻塞休眠等待的方式,還可以將前提條件的失敗傳遞給呼叫者,由呼叫者控制是否進入休眠。如果呼叫者不進入休眠而直接重新呼叫的方式成為忙等待或者自旋等待。

如果快取的狀態在很長一段時間內不會發生變化,那麼使用這個方式就會消耗大量的CPU時間。但是,在進入休眠的情況下,如果快取的狀態在剛調完sleep後就立即發生變化,那麼將不必要地休眠一段時間。因此我們必須要在這兩者中做出選擇:要麼容忍自旋導致的CPU始終週期浪費,要麼容忍由於休眠而導致的低響應性。

 

1.3 條件佇列

通過輪詢與休眠來實現阻塞操作的過程需要付出大量的努力。如果存在某中掛起執行緒的方法,並且這種方法能夠確保當某個條件成真時執行緒立即醒來,那麼將極大地簡化實現工作。這正是條件佇列的功能。

“條件佇列”這個名字的來源於:它使得一組執行緒(稱之為等待執行緒集合)能夠通過某種方式來等待特定的條件變成真。傳統佇列的元素是一個個資料,而與之不同的是,條件佇列中的元素是一個個正在等待相關條件的執行緒。

正如每個Java物件都可以作為一個鎖,每個物件同樣可以作為一個條件佇列,並且Object中的wait、notify和notifuAll方法就構成了內部條件佇列的API。物件的內建鎖與其內部條件佇列是相互聯絡的,要呼叫物件X中條件佇列的任何一個方法,必須持有物件X上的鎖。這是因為“等待由狀態構成的條件”與“維護狀態一致性”這兩種機制必須被緊密地繫結在一起:只有能對狀態進行檢查時,才能在某個條件上等待,並且只有能修改狀態時,才能從條件等待中釋放另一個執行緒。

Object.wait會自動釋放鎖,並請求作業系統掛起當前的執行緒,從而使其他執行緒能夠獲得這個鎖並修改物件的狀態。當被掛起的執行緒醒來時,他將在返回之前重新獲取鎖。程式清單14-6中使用了wait和notifyAll實現了一個有屆快取。

 程式清單 14-6 使用條件佇列實現的有屆快取

public class BoundedBuffer<V> extends BaseBoundedBuffer <V>{
    // 條件謂詞:not-full (!isFull)
    // 條件謂詞:not-empty (!isEmpty)
    protected BoundedBuffer(int capacity) {
        super(capacity);
    }
    
    public synchronized void put(V v) throws InterruptedException {
        while(isFull())
            wait();
        doPut(v);
        notifyAll();
    }
 
    public synchronized V take() throws InterruptedException {
        while (isEmpty())
            wait();
        V v = doTake();
        notifyAll();
        return v;
    }
}

最終這比使用休眠的有屆快取更加簡單,並且更加高效(執行緒醒來的次數更少),響應性也更高(當發生特定狀態變化時將立即醒來)。

注意:與使用休眠的有屆快取相比,條件多列並沒有改變原來的語義。他只是在多個方面進行了優化:CPU效率,上下文切換開銷和響應性等。如果某個功能無法通過“輪詢+休眠”來實現,那麼使用條件佇列也無法實現,但條件佇列是的在表達和管理狀態依賴時更加的簡單和高效。

 

14.2 使用條件佇列

條件佇列使構建高效以及高可響應性的狀態依賴類變得更容易,但同時也很容易被不正確的使用。

雖然許多規則都能確保正確地使用條件佇列,但在編譯器或系統平臺上卻並沒有強制要求遵循這些規則。(這也是為什麼要儘量基於 LinkedBlockingQueue、Latch、Semaphore 和 FutureTask 等類來構造程式的原因之一,如果能避免使用條件佇列,那麼實現起來將容易許多)。

 

2.1 條件謂詞

要想正確地使用條件佇列,關鍵是找出物件在哪個條件謂詞上等待。如果沒有條件謂詞,條件等待機制將無法發揮作用。條件謂詞是使某個操作成為狀態依賴操作的前提條件。對 take 方法來說,它的條件謂詞就是 "快取不為空"take 方法在執行之前必須首先測試該條件謂詞。

在條件等待中存在一種重要的三元關係,包括:

  • 加鎖
  • wait 方法
  • 條件謂詞

在條件謂詞中包含多個狀態變數,而狀態變數由一個鎖來保護,因此在測試條件謂詞之前必須先持有這個鎖。鎖物件與條件佇列物件(即呼叫 wait 和 notify 等方法所在的物件)必須是同一個物件。

每一次 wait 呼叫都會隱式地與特定的條件謂詞關聯起來。當呼叫某個特定條件謂詞的 wait 時,呼叫者必須已經持有與條件佇列相關的鎖,並且這個鎖必須保護著構成條件謂詞的狀態變數。

注意,當執行緒從wait方法中被喚醒時,他在重新請求鎖時不具有任何特殊的優先順序,而要與任何其他嘗試進入同步程式碼塊的執行緒一起正常的在鎖上競爭。

  

2.2 過早喚醒

wait的返回並不一定意味著執行緒正在等待的條件謂詞已經變成真了。內建條件佇列可以與多個條件謂詞一起使用,這是一種常見的情況,如在 BoundedBuffer 中使用的條件佇列與 "非滿" 和 "非空"兩個條件謂詞相關。

當一個執行緒由於呼叫 notifyAll 而醒來時,並不意味該執行緒正在等待的條件謂詞已經變成真了。這就像烤麵包機和咖啡機共用一個鈴聲,當響鈴後,你必須檢視是哪個裝置發出的鈴聲。另外,wait 方法還可以 "假裝" 返回,而不是由於某個執行緒呼叫了 notify。
當執行控制重新進入呼叫 wait 的程式碼時,它已經重新獲取了與條件佇列相關聯的鎖。現在條件謂詞是不是已經變為真了?或許。在發出通知的執行緒呼叫 notifyAll 時,條件謂詞可能已經變成真,但在重新獲取鎖時將再次變為假。線上程被喚醒到 wait 重新獲取鎖的這段時間裡,可能有其他執行緒已經獲取了這個鎖,並修改了物件的狀態。或者,條件謂詞從呼叫 wait 起根本就沒有變成真。你並不知道另一個執行緒為什麼呼叫 notify 或 notifyAll,也許是因為與同一條件佇列相關的另一個條件謂詞變成了真。

 

基於所有這些原因,每當執行緒從 wait 中喚醒時,都必須再次測試條件謂詞,如果條件謂詞不為真,那麼就繼續等待(或者失敗)。由於執行緒在條件謂詞不為真的情況下也可以反覆地醒來,因此必須在一個迴圈中呼叫 wait,並在每次迭代中都測試條件謂詞。

程式清單 14-7 狀態依賴方法的標準形式
void stateDependentMethod() throws InterruptedException {

    //  必須通過一個鎖來保護條件謂詞
    synchronized (lock) {
        while (!conditionPredicate())
            lock.wait(); 
        // 現在物件處於合適的狀態
    }
}

當使用條件等待時(Object.wait或者Condition.await):

  • 遠設定一個條件謂詞---一些對狀態的測試,執行緒執行前必須滿足它;
  • 遠在呼叫wait前測試條件謂詞,並且從wait中返回後再次測試;
  • 永遠在迴圈中呼叫wait;
  • 確保構成條件謂詞的狀態變數被鎖保護,而這個鎖正是與條件佇列相關聯的;
  • 當呼叫wait、notify或者notifyAll時,要持有與條件佇列相關聯的鎖;並且,
  • 在檢查條件謂詞之後、開始執行被保護的邏輯之前,不要釋放鎖.

 

2.3 丟失訊號

之前我們討論活躍性故障,有死鎖和活鎖。另一種形式的活躍性故障是丟失訊號。

丟失的訊號是指:執行緒必須等待一個已經為真的條件,但在開始等待之前沒有檢查條件謂詞。現在,執行緒將等待一個已經發生過的事件。這就好比在啟動了烤麵包機出去拿報紙,當你還在屋外時烤麵包機的鈴聲響了,但你沒有聽到,因此還會坐在廚房的桌子前等著烤麵包機的鈴聲。你可能會等待很長的時間(為了擺脫等待,其他人也不得不開始烤麵包,從而使得情況變得糟糕,當鈴聲響起時,還要與別人爭論這個麵包是屬於誰的。)如果執行緒 A 通知了一個條件佇列,而執行緒 B 隨後在這個條件佇列上等待,那麼執行緒 B 將不會立即醒來,而是需要另一個通知來喚醒它。編碼錯誤(例如沒有在呼叫 wait 之前檢測條件謂詞)會導致訊號的丟失。如果按照程式清單 14-7 的方式來設計條件等待,則不會發生訊號丟失的問題。

注意:保證notify一定在wait之後!!!

 

2.4 通知

到目前為止,我們介紹了條件等待的前一半內容:等待。另一半內容是:通知。在有界快取中,如果快取為空,在呼叫 take 時將阻塞。在快取變為非空時,為了使 take 解除阻塞,必須確保在每條使快取變為非空的程式碼路徑中都發出一個通知。

 

在條件佇列 API 中有兩個發出通知的方法,即:

  • 單次通知: notify
  • 全部通知: notifyAll

無論呼叫哪個,都必須持有與條件佇列物件相關聯的鎖。在呼叫 notify 時,JVM 會從這個條件佇列上等待的多個執行緒中選擇一個來喚醒,而呼叫 notifyAll 則會喚醒所有在這個條件佇列上等待的執行緒。由於在呼叫 notify 或 notifyAll 時必須持有條件佇列物件的鎖,因此發出通知的執行緒應該儘快地釋放鎖,從而確保正在等待的執行緒儘可能快地解除阻塞。

 

BoundedBuffer 中很好的說明了為什麼在大多數情況下應該優先選擇notifyAll。 這裡的條件佇列用於兩個不同的條件謂詞:"非空" 和 "非滿"。假設執行緒 A 在條件佇列上等待條件謂詞 PA,同時執行緒 B 在同一個條件佇列上等待條件謂詞 PB。現在,假設 PB 變成真,並且執行緒 C 執行一個 notifyJVM 將從它擁有的眾多執行緒中選擇一個並喚醒。如果選擇了執行緒 A,那麼它被喚醒,並且看到 PA 尚未變成真,因此將繼續等待。同時,執行緒 B 本可以開始執行,卻沒有被喚醒。這並不是嚴格意義上的 "丟失訊號",更像一種 "被劫持的" 訊號,但導致的問題時相同的:執行緒正在等待一個已經(或本應該)發生過的訊號。

 

只有同時滿足以下兩個條件時,才能用單一的 notify 而不是 notifyAll

  • 所有等待執行緒的型別都相同:只有一個條件謂詞與條件佇列相關,並且每個執行緒在從 wait 返回後將執行相同的操作。
  • 單進單出:在條件變數上的每次通知,最多隻能喚醒一個執行緒來執行。

由於大多數類並不滿足這些需求,因此普遍認可的做法是優先使用 notifyAll 而不是 notify。雖然 notifyAll 可能比 notify 更低效,但卻更容易確保類的行為是正確的。這種低效情況帶來的影響有時候很小,但有時候卻非常大。當只有一個執行緒可以執行時,如果有 10 個執行緒在一個條件佇列上等待,那麼呼叫 notifyAll 將喚醒每個執行緒,並使得它們在鎖上發生競爭。然後,它們中的大多數或者全部又都回到休眠狀態。因而,在每個執行緒執行一個事件的同時,將出現大量的上下文切換操作以及發生競爭的鎖獲取操作。(最壞的情況是,在使用 notifyAll 時將導致 O(n^2) 次喚醒操作,而實際上只需要 n 次喚醒操作就足夠了)這是 "效能考慮因素與安全性考慮因素相互矛盾" 的另一種情況。

 

BoundedBuffer 的 put 和 take 方法中採用的通知機制是保守的,可以對其進行優化:首先,僅當快取從空變為非空,或者從滿轉為非滿時,才需要釋放一個執行緒。並且,僅當 put 或 take 影響到這些狀態轉換時,才發出通知。這也被稱為"條件通知"。雖然可以提升效能,但卻很難正確地實現(而且還會使子類的實現變得復雜),因此在使用時需謹慎。

程式清單 14-8 使用條件通知
    public synchronized void put(V v) throws InterruptedException {
        while (isFull())
            wait();
        boolean wasEmpty = isEmpty();
        doPut(v);
        if (wasEmpty)
            notifyAll();
    }

單次通知和條件通知都屬於優化措施。通常,在使用這些優化措施時,應該遵循“首先使程式正確地執行,然後才使其執行的更快”這個原則。如果不正確地使用這些優化措施,很容易在程式中引入奇怪的活躍性故障。

2.5 示例

通過使用條件等待,ThreadGate實現了一個可開啟和重新關閉的閥門,並提供了一個await方法,該方法能一直阻塞知道閥門被開啟。這open中使用了notifyAll,因為這個類的語義不滿足單次通知的條件。

程式清單 14-9 實現一個可重新關閉的閥門
@ThreadSafepublic 
class ThreadGate {
    // CONDITION-PREDICATE: opened-since(n) (isOpen || generation>n)
    @GuardedBy("this") private boolean isOpen;
    @GuardedBy("this") private int generation;    
    
    public synchronized void close() {
        isOpen = false;
    }    
    
    public synchronized void open() {
        ++generation;
        isOpen = true;
        notifyAll();
    }
    
    // BLOCKS-UNTIL: opened-since(generation on entry)
    public synchronized void await() throws InterruptedException {
        int arrivalGeneration = generation;
        while (!isOpen && arrivalGeneration == generation) 
            wait();
    }
}

await中的使用的條件謂詞比較復雜,這種條件謂詞時必需的,因為如果當閥門開啟時有 N 個執行緒正在等待它,那麼這些執行緒都應該被允許執行。然而,如果閥門在開啟後又非常快速地關閉了,並且 await 方法只檢查 isOpen,那麼所有執行緒都可能無法釋放:當所有執行緒收到通知時,將重新請求鎖並退出 wait,而此時的閥門可能已經再次關閉了。因此,在 ThreadGate 中使用了一個更復雜的條件謂詞:每次閥門關閉時,遞增一個 Generation 計數器,如果閥門現在是開啟的,或者閥門自從該執行緒到達後就一直是開啟的,那麼執行緒就可以通過 await。

 

由於 ThreadGate 只支援等待開啟閥門,因此它只在 open 中執行通知。要想既支援等待開啟又支援等待關閉,那麼必須在 open 和 close 中都進行通知。這很好地說明了為什麼在維護狀態依賴的類時是非常困難的——當增加一個新的狀態依賴操作時,可能需要對多條修改物件的程式碼路徑進行改動,才能正確地執行通知。

 

可見在使用條件佇列時,除了使用起來比較複雜且易出錯,還有面臨諸如子類安全問題以及條件佇列封裝問題等。

 

14.3 顯示的Condition物件

正如在某些情況下,當內建鎖過於不靈活時,可以使用顯式鎖。在內建條件佇列不滿足需求時,可以使用顯示條件佇列Condition。注意:Lock 是一種廣義的內建鎖,Condition 也是一種廣義的內建條件佇列。

程式清單 14-10 Condition介面
public interface Condition {
    void await() throws InterruptedException;
    boolean await(long time, TimeUnit unit) throws InterruptedException;
    long awaitNanos(long nanosTimeout) throws InterruptedException;
    void awaitUninterruptibly();
    boolean awaitUntil(Date deadline) throws InterruptedException;
    void signal();
    void signalAll();
}

內建條件佇列存在一些缺陷,每個內建鎖都只能有一個相關聯的條件佇列,因而在像 BoundedBuffer 這種類中,多個執行緒可能在同一個條件佇列上等待不同的條件謂詞,並且在最常見的加鎖模式下公開條件佇列物件。這些因素都使得無法滿足在使用 notifyAll 時所有等待執行緒為同一型別的需求。如果想編寫一個帶有多個條件謂詞的併發物件,或者想獲得除了條件佇列可見性之外的更多控制權,就可以使用顯式的 Lock 和 Condition 而不是內建鎖和條件佇列,這是一種更靈活的選擇。

一個 Condition 和一個 Lock 關聯在一起,就像一個條件佇列和一個內建鎖相關聯一樣。要建立一個 Condition,可以在相關聯的 Lock 上呼叫 Lock.newCondition 方法。Condition 同樣比內建條件佇列提供更豐富的功能:在每個鎖上可存在多個等待、條件等待可以使可中斷的或不可中斷的、基於時限的等待,以及公平的或非公平的佇列操作。

與內建條件佇列不同的是,對於每個 Lock,可以有任意數量的 Condition 物件。Condition 物件繼承了相關的 Lock 物件的公平性,對於公平的鎖,執行緒會依照 FIFO 順序從 Condition.await 中釋放。

有界快取的另一種實現,即使用兩個 Condition,分別為 notFull 和 notEmpty,用於表示 "非滿" 與 "非空" 兩個條件謂詞。當快取為空時,take 將阻塞並等待 notEmpty,此時 put 向 notEmpty 傳送訊號,可以解除任何在 take 中阻塞的執行緒。

程式清單 14-11 使用顯示條件佇列的有屆快取
public class ConditionBoundedBuffer<T> {

    protected final Lock lock = new ReentrantLock();
    // 條件謂詞:not-full (count < items.length)
    private final Condition notFull = lock.newCondition();
    // 條件謂詞:not-empty (count > 0)
    private final Condition notEmpty = lock.newCondition();
    @GuardedBy("lock")
    private final T[] items = (T[]) new Object[100];
    @GuardedBy("lock")
    private int tail, head, count;

    // 阻塞並指導not-full
    public void put(T x) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length) 
                notFull.await();
            items[tail] = x;
            if (++tail == items.length)
                tail = 0;
            ++count;
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }

 
    // 阻塞並直到not-empty
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) 
                notEmpty.await();
            T x = items[head];
            items[head] = null;
            if (++head == items.length)
                head = 0;
            --count;
            notFull.signal();
            return x;
        } finally {
            lock.unlock();
        }
    }
}

注意:在 Condition 物件中,與 wait、notify 和 notifyAll 方法對應的分別是 await、signal 和 signalAll。但是,Condition 對 Object 進行了擴充套件,因而它也包含 wait 和 notify 方法。一定要確保使用正確的版本—— await 和 signal

ConditionBoundedBuffer的行為與BoundedBuffer相同,但它對條件佇列的使用方式更容易理解——在分析使用多個 Condition 的類時,比分析一個使用單一內部佇列加多個條件謂詞的類簡單得多。通過將兩個條件謂詞分開並放到兩個等待執行緒集中,Condition 使其更容易滿足單次通知的需求。signal 比 signalAll 更高效,它能極大地減少在每次快取操作中發生的上下文切換與鎖請求次數。

與內建鎖和條件佇列一樣,當使用顯式的 Lock 和 Condition 時,也必須滿足鎖、條件謂詞和條件變數之間的三元關係。在條件謂詞中包含的變數必須由 Lock 來保護,並且在檢查條件謂詞以及呼叫 await 和 signal 時,必須持有 Lock 物件。

在顯示condition與內建條件佇列進行選擇時,與在ReentrantLock和synchronized之間選擇是一樣的:如果需要一些高階功能,例如使用公平的佇列操作或者在每個鎖上對應多個等待執行緒集,那麼應該優先使用 Condition。但如果需要 ReentrantLock 的高階功能,並且已經使用了它,那麼就應該選擇 Condition。

 

14.4 同步器Synchronizer刨析

ReentrantLock 和 Semaphore 這兩個介面之間存在許多的共同點。這兩個類都可以用做一個 "閥門" 類,即每次只允許一定數量的執行緒通過,並當執行緒到達閥門時,可以通過(在呼叫 lock 或 acquire 時成功返回),也可以等待(在呼叫 lock 或 acquire 時阻塞),還可以取消(在呼叫 tryLock 或 tryAcquire 時返回 false,表示在指定時間內鎖是不可用的或者無法獲得許可)。而且,這兩個介面都支援可中斷的、不可中斷的以及限時的獲取操作,並且也都支援等待執行緒執行公平或非公平的佇列操作。

 

列出了這麼多共性以後,你或許會認為 Semaphore 是基於 ReentrantLock 實現的,或者認為 ReentrantLock 實際上是帶有一個許可的Semaphore,這些實現方式都是可行的,可以通過鎖來實現計數訊號量,以及可以通過計數訊號量來實現鎖。

程式清單 14-12 使用Lock來實現訊號量
// 並非j.u.c的真實實現方式
@ThreadSafepublic
class ConditionBoundedBuffer<T> {
    protected final Lock lock = new ReentrantLock();
    // CONDITION PREDICATE: notFull (count < items.length)
    private final Condition notFull = lock.newCondition(); 
    // CONDITION PREDICATE: notEmpty (count > 0)
    private final Condition notEmpty = lock.newCondition();
    private static final int BUFFER_SIZE = 100;
    @GuardedBy("lock")
    private final T[] items = (T[]) new Object[BUFFER_SIZE];
    @GuardedBy("lock")
    private int tail, head, count;
    
    // BLOCKS-UNTIL: notFull
    public void put(T x) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length)
                notFull.await();
            items[tail] = x;
            if (++tail == items.length)
                tail = 0;
            ++count;
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    } 

    
    // BLOCKS-UNTIL: notEmpty
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0)
                notEmpty.await();
            T x = items[head];
            items[head] = null;
            if (++head == items.length)
                head = 0;
            --count;
            notFull.signal();
            return x;
        } finally {
            lock.unlock();
        }
    }
}

事實上,以上程式碼並非j.u.c的真實實現方式,而是在實現時使用了一個共同的基類,即AbstractQueuedSynchronizer(AQS),這個類也是其他許多同步類的基類。AQS 是一個用於構建鎖和同步器的框架,許多同步器都可以通過 AQS 很容易並且高效地構造出來。不僅 ReentrantLock 和 Semaphore 時基於 AQS 構建的,還包括CountDownLatch、ReentrantReadWriteLock、SynchronousQueue 和 FutureTask

AQS 解決了在實現同步器時涉及的大量細節問題,例如等待執行緒採用 FIFO 佇列操作順序。在不同的同步器中還可以定義一些靈活的標準來判斷某個執行緒時應該通過還是需要等待。

基於 AQS 來構建同步器能帶來許多好處。它不僅能極大地減少實現工作,而且也不必處理在多個位置上發生的競爭問題。在 SemaphoreOnLock中,獲取許可的操作可能在兩個時刻阻塞:當鎖保護訊號量狀態時,以及當許可不可用時。而在基於AQS構建的同步器中,只有一個時刻發生阻塞,從而降低上下文切換的開銷,並提高吞吐量。所有基於AQS構建的同步器都具有這個優勢。

 

 

14.5 AbstractQueuedSychronizer AQS

5.1 AQS獲取和釋放操作的標準形式

大多數開發者都不會直接使用AQS,標準的同步器類的集合更夠滿足絕大多數情況的需求。但如果能瞭解標準同步器類的實現方式,那麼對於理解它們的工作原理是非常有幫助的。

 

在基於 AQS 構建的同步器類中,最基本的操作包括各種形式的獲取和釋放操作,獲取操作時一種依賴狀態的操作,並且通常會阻塞。釋放並不是一個可阻塞的操作,當執行釋放操作時,所有在請求時被阻塞執行緒都會開始執行。

     當使用鎖或訊號量時,獲取操作的含義就很直觀,即獲取的是鎖或許可,並且呼叫者可能會一直等待直到同步器類處於可被獲取的狀態。在使用 CountDownLatch 時,獲取操作意味著 "等待並直到閉鎖到達結束狀態",而在使用 FutureTask 時,則意味著 "等待並直到任務已經完成"。

 

如果一個類想成為狀態依賴的類,那麼它必須擁有一些狀態。AQS負責管理同步器類中的狀態,它管理了一個整數狀態資訊,可以通過 getStatesetState 以及 compareAndSetState 等 protected型別方法來進行操作。

這個整數可以用於表示任意狀態。例如,ReentrantLock 用它來表示所有執行緒已經重複獲取該鎖的次數,Semaphore 用它來表示剩餘的許可數量,FutureTask 用它來表示任務的狀態(尚未開始、正在執行、已完成以及已取消)。在同步器類中還可以自行管理一些額外的狀態變數,例如 ReentrantLock 儲存了鎖的當前所有者資訊,以區分某個獲取操作時重入還是競爭的。

 

程式清單 14-13給出了程式清單 14-13 AQS中獲取和釋放操作的標準形式。根據同步器的不同,獲取操作可以是一種獨佔操作(ReentrantLock),也可以是一種非獨佔操作(Semaphore CountDownLatch)。一個獲取操作包括兩部分,首先,同步器判斷當前狀態是否允許獲得操作,如果是,則允許執行緒執行,否則獲取操作將阻塞或失敗。這種判斷是由同步器的語義決定的。例如,對於鎖來說,如果它沒有被某個執行緒持有,那麼就能被成功地獲取,而對於閉鎖來說,如果它處於結束狀態,那麼也能被成功地獲取。

程式清單 14-13 AQS中獲取和釋放操作的標準形式
boolean acquire() throws InterruptedException { 
    while (state does not permit acquire) {
         if (blocking acquisition requested) {
            enqueue current thread if not already queued
            block current thread
         } else
            return failure
    }
    possibly update synchronization state
    dequeue thread if it was queued 
    return success
}
 
void release() {
     update synchronization state 
     if (new state may permit a blocked thread to acquire)
        unblock one or more queued threads
}

其次,就是更新同步器的狀態,獲取同步器的某個執行緒可能會對其他執行緒能否也獲取該同步器造成影響。例如,當獲取一個鎖後,鎖的狀態將從 "未被持有" 變成 "已被持有",而從 Semaphore 中獲取一個許可後,將把剩餘許可的數量減 1。然而,當一個執行緒獲取閉鎖時,並不會影響其他執行緒能否獲取它,因此獲取閉鎖的操作不會改變閉鎖的狀態。

 

如果某個同步器支援獨佔的獲取操作,那麼就需要實現一些保護方法,包括 tryAcquire、tryRelease 和 isHeldExclusively 等,而對於支援共享獲取的同步器,則應該實現 tryAcquireShared 和 tryReleaseShared 等方法。AQS 中的 acquire、acquireShared、release 和 releaseShared 等方法都將呼叫這些方法在子類中帶有字首 try 的版本來判斷某個操作是否能執行。在同步器的子類中,可以根據其獲取操作和釋放操作的語義,使用 getState、setState 以及 compareAndSetState 來檢查和更新狀態,並通過返回的狀態值來告知基類 "獲取" 或 "釋放" 同步器的操作是否成功。例如,如果 tryAcquireShared 返回一個負值,那麼表示獲取操作失敗,返回零值表示同步器通過獨佔方式被獲取,返回正值則表示同步器通過非獨佔方式被獲取。對於 tryRelease 和 tryReleaseShared 方法來說,如果釋放操作使得所有在獲取同步器時被阻塞的執行緒恢復執行,那麼這兩個方法應該返回 true。

 

5.2 例子:一個簡單的閉鎖

程式清單 14-14 中的OneShotLatch 是一個使用AQS實現的二元閉鎖。它包含兩個公有方法:await 和 signal,分別對應獲取操作和釋放操作。起初,閉鎖是關閉的,任何呼叫 await 的執行緒都將阻塞並直到閉鎖被開啟。當通過呼叫 signal 開啟閉鎖時,所有等待中的執行緒都將被釋放,並且隨後到達閉鎖的執行緒也被允許執行。

程式清單 14-14 使用AQS實現的二元閉鎖

  

public class OneShotLatch {

    private final Sync sync = new Sync();
 
    public void signal() {
        sync.releaseShared(0);
    }
    
    public void await() throws InterruptedException {
        //此方法會請求tryAcquireShared()
        sync.acquireSharedInterruptibly(0);
    }
    
    private class Sync extends AbstractQueuedSynchronizer {
        @Override
        protected int tryAcquireShared(int ignored) {
            // 如果閉鎖開啟則成功(state == 1),否則失敗
            return (getState() == 1) ? 1 : -1;
        }

        @Override
        protected boolean tryReleaseShared(int ignored) {
            //閉鎖現在已開啟
            setState(1);
            //現在其他執行緒可以獲得比索
            return true;
        }
    }
}

在 OneShotLatch 中,AQS 狀態用來表示閉鎖狀態——關閉(0)或者開啟(1)。await 方法呼叫 AQS 的 acquireSharedInterruptibly,然後接著呼叫 OneShotLatch 中的 tryAcquireShared 方法。在 tryAcquireShared 的實現中必須返回一個值來表示該獲取操作能夠執行。如果之前已經開啟了閉鎖,那麼 tryAcquireShared 將返回成功並允許執行緒通過,否則就會返回一個表示獲取操作失敗的值。acquireSharedInterruptibly 方法在處理失敗的方式,是把這個執行緒放入等待執行緒佇列中。signal 將呼叫 releaseShared,接下來又會呼叫 tryReleaseShared。在 tryReleaseShared 中將無條件地把閉鎖的狀態設定為開啟,(通過返回值)表示該同步器處於完全被釋放的狀態。因而 AQS 讓所有等待中的執行緒都嘗試重新請求該同步器,並且由於 tryAcquireShared 將返回成功,因而現在的請求操作將成功。

 

OneShotLatch 可以通過擴充 AQS 來實現,而不是將一些功能委託給 AQS,但這種做法並不合理,這樣做將破壞該介面(只有兩個方法)的簡潔性,並且雖然 AQS 的公共方法不允許呼叫者破壞閉鎖的狀態,但呼叫者仍可以容易地誤用它們。java.util.concurrent 中的所有同步器都沒有直接擴充 AQS,而都是將它們的相應功能委託給私有的 AQS 子類來實現。

  

14.6 j.u.c同步容器類中AQS

java.util.concurrent 中的許多可阻塞類,例如 ReentrantLock、Semaphore、ReentrantReadWriteLock、CountDownLatch、SynchronousQueue 和 FutureTask 等,都是基於 AQS 構建的。接下來簡單看一下每個類是如果使用AQS的,如果需要深入瞭解細節可閱讀原始碼。

 

6.1 ReentrantLock

只支援獨佔方式的獲取操作,因此實現了 tryAcquire、tryRelease 和 IsHeldExclusively。程式清單 14-15 給出了非公平版本的tryAcquire。ReentrantLock 將同步狀態由於儲存鎖獲取操作的次數,並且還維護了一個 owner 變數來儲存當前所有者執行緒的識別符號,只有在當前執行緒剛剛獲取到鎖,或者正要釋放鎖的時候,才會修改這個變數(由於受保護的狀態操作方法具有 volatile 型別的記憶體讀寫語義,同時 ReentrantLock 只是在呼叫 getState 之後才會讀取 owner 域,並且只有在呼叫 setState 之前才會寫入 owner,因此 ReentrantLock 可以擁有同步狀態的記憶體語義,因此避免了進一步的同步)。在 tryRelease 中檢查 owner 域,從而確保當前執行緒在執行 unlock 操作之前已經獲取了鎖:在 tryAcquire 中將使用這個域來區分獲取操作是重入的還是競爭的。

程式清單 14-15 基於非公平ReentrantLock實現tryAcquire
    protected boolean tryAcquire(int ignored) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            if (compareAndSetState(0, 1)) {
                owner = current;
                return true;
            }
        } else if (current == owner) {
            setState(c + 1);
            return true;
        }
        return false;
    }

當一個執行緒嘗試獲取鎖時,tryAcquire 將首先檢查鎖的狀態。如果鎖未被持有,它將嘗試更新鎖的狀態以表示鎖已經被持有。由於狀態可能在檢查後被立即修改,因此 tryAcquire 使用 compareAndSetState 來原子地更新狀態,表示這個鎖已經被佔有,並確保狀態在最後一次檢查以後就沒有被修改過。如果鎖狀態表明它已經被持有,並且如果當前執行緒是鎖的擁有者,那麼獲取計數會遞增,如果當前執行緒不是鎖的擁有者,那麼獲取操作將失敗。

 

ReentrantLock 還利用 AQS 對多個條件變數和多個等待執行緒集的內建支援。Lock.newCondition 將返回一個新的 ConditionObject 例項,這是 AQS 的一個內部類。

 

6.2 Semaphore CountDownLatch

Semaphore 將 AQS 的同步狀態用於儲存當前可用許可的數量。tryAcquireShared 方法首先計算剩餘許可的數量,如果沒有足夠的許可,那麼會返回一個值表示獲取操作失敗。如果還有剩餘的許可,那麼 tryAcquireShared 會通過 compareAndSetState 以原子方式來降低許可的計數。如果這個操作成功(這意味著許可的計數從上一次讀取後就沒有被修改過),那麼將返回一個值表示獲取操作成功。在返回值中還包含了表示其他共享獲取操作能否成功的資訊,如果成功,那麼其他等待的執行緒同樣會解除阻塞。

 

當沒有足夠的許可,或者當 tryAcquireShared 可以通過原子方式來更新許可的計數以響應獲取操作時,while 迴圈將終止。雖然對 compareAndSetState 的呼叫可能由於與另一個執行緒發生競爭而失敗,並使其重新嘗試,但在經過了一定次數的重試操作以後,在這兩個結束條件中有一個會變為真。同樣,tryReleaseShared 將增加許可計數,這可能會解除等待中執行緒的阻塞狀態,並且不斷地重試直到更新操作成功。tryReleaseShared 的返回值表示在這次釋放操作中解除了其他執行緒的阻塞。

程式清單 14-16 Semaphore中的tryAcquireShared和tryReleaseShared
    protected int tryAcquireShared(int acquires) {
        while (true) {
            int available = getState();
            int remaining = available - acquires;
            if (remaining < 0 || compareAndSetState(available, remaining)) {
                return remaining;
            }
        }
    }

    protected boolean tryReleaseShared(int releases) {
        while (true) {
            int p = getState();
            if (compareAndSetState(p, p + releases)) {
                return true;
            }
        }
    }

CountDownLatch 使用 AQS 的方式與 Semaphore 相似:在同步狀態中儲存的是當前的計數值,countDown 方法呼叫 release,從而導致計數值遞減,並且當計數值為零時,解除所有等待執行緒的阻塞。await 呼叫 acquire,當計數器為零時,acquire 將立即返回,否則將阻塞。

 

6.3 FutureTask

FutureTask 不像一個同步器,但 Future.get 的語義非常類似於閉鎖的語義——如果發生了某個事件(由 FutureTask 表示的任務執行完成或被取消),那麼執行緒就可以恢復執行,否則這些執行緒將停留在佇列中並直到該事件完成。

 

FutureTask 中,AQS 同步狀態被用來儲存任務的狀態,例如,正在執行、已完成或已取消。FutureTask 還維護一些額外的狀態變數,用來儲存計算結果或丟擲的異常。此外,還維護了一個引用,指向正在執行計算任務的執行緒(如果它當前處於執行狀態),因而如果任務取消,該執行緒就會中斷。

 

6.4 ReentrantReadWriteLock

ReadWriteLock 介面表示存在兩個鎖:一個讀取鎖和一個寫入鎖,但在基於 AQS 實現的 ReentrantReadWriteLock 中,單個 AQS 子類將同時管理讀取加鎖和寫入加鎖。ReentrantReadWriteLock 使用一個 16 位的狀態來表示寫入鎖的計數,並且使用另一個 16 位的狀態來表示讀取鎖的計數。在讀取鎖上的操作將使用共享的獲取方法與釋放方法,在寫入鎖上的操作將使用獨佔的獲取方法與釋放方法。

 

AQS 在內部維護一個等待執行緒佇列,其中記錄了某個執行緒請求的是獨佔訪問還是共享訪問。在 ReentrantReadWriteLock 中,當鎖可用時,如果位於佇列頭部的執行緒執行寫入操作,那麼執行緒會得到這個鎖,如果位於頭部的執行緒執行讀取訪問,那麼佇列中在第一個寫入執行緒之前的所有執行緒都將獲得這個鎖。(這種機制並不允許選擇讀取執行緒優先或寫入執行緒優先等策略,在某些讀寫鎖實現中也採用這種方式。因此,要麼 AQS 的等待佇列不能是一個 FIFO 佇列,要麼使用兩個佇列。然而,在實際中很少需要這麼嚴格的排序策略。如果非公平版本中 ReentrantReadWriteLock 無法提供足夠的活躍性,那麼公平版本的 ReentrantReadWriteLock 通常會提供令人滿意的排序保證,並且能確保讀取執行緒和寫入執行緒不會發生飢餓問題。)

 

 

 

瞭解更多知識,關注我。  ???

相關文章