透徹理解Java併發的等待佇列——Condition

weixin_34377065發表於2018-12-11

Condition介面——等待佇列

前言

任意一個Java物件,都擁有一組監視器方法(定義在java.lang.Object上),主要包括wait()、 wait(long timeout)、notify()以及notifyAll()方法,這些方法與synchronized同步關鍵字配合,可以實現等待/通知模式。Condition介面也提供了類似Object的監視器方法,與Lock配合可以實現等待/通知模式,但是這兩者在使用方式以及功能特性上還是有差別的。

Condition的作用

Condition介面作為Lock介面的wait()、notify()方法,實現等待/通知模式。

Object的監視器方法和Condition介面的對比

對比項 Object監視器方法 Codition
前置條件 獲取物件的鎖(隱式獲取) 呼叫Lock()獲取鎖,然後呼叫Lock.newCondition()獲取Condition物件
呼叫方式 直接呼叫,如:object.wait() 直接呼叫,如:condition.await()
等待佇列個數 一個 多個(這裡思考一下為什麼會有多個等待佇列?)
當前執行緒釋放鎖進入等待狀態 支援 支援
當前執行緒釋放鎖進入等待狀態,在等待狀態中不響應中斷 不支援 支援
當前執行緒釋放鎖並進入超時等待狀態 支援 支援
當前執行緒釋放鎖並進入等待狀態到將來的某個時間 不支援 支援
喚醒等待佇列中的某個執行緒 支援 支援
喚醒等待佇列中的全部執行緒 支援 支援

看不懂沒關係,把下面的內容看完再回頭過來看,就會懂了。:)

Condition是在AQS中配合使用的wait/nofity執行緒通訊協調工具類,我們可以稱之為等待佇列 Condition定義了等待/通知兩種型別的方法,當前執行緒呼叫這些方法時,需要提前獲取到Condition物件關聯的鎖。Condition物件是呼叫Lock物件的newCondition()方法建立出來的,換句話說,Condition是依賴Lock物件。

Condition示例

下面看看Condition介面是如何和Lock介面使用

Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
public void conditionWait() throws InterruptedException {
    lock.lock();
    try {
        condition.await();
    } finally {
        lock.unlock;
    }
}
public void conditionSignal() throws InterruptedException {
    lock.lock();
    try {
        condition.signal();
    } finally {
        lock.unlock();
    }
}
複製程式碼

如示例所示,一般都會將Condition物件作為成員變數。當呼叫await()方法後,當前執行緒會 釋放鎖並在此等待,而其他執行緒呼叫Condition物件的signal()方法,通知當前執行緒後,當前執行緒 才從await()方法返回,並且在返回前已經獲取了鎖。

下面看看Condition部分方法的詳細解析

下面以一個有界佇列來深入瞭解Condition的使用方式

public class BoundedQueue<T> {
    //儲存佇列元素的物件陣列
    private Object[] items;
    //新增的下標,刪除的下標和陣列當前數量
    private int addIndex, removeIndex, count;
    private Lock lock = new ReentrantLock();
    //定義一個非空等待佇列
    private Condition notEmpty = lock.newCondition();
    //定義一個非滿等待佇列
    private Condition notFull = lock.newCondition();
    public BoundedQueue(int size) {
        items = new Object[size];
    }
    public void add(T t) throws InterruptedException {
        lock.lock();
        try {
            //如果當前佇列已滿,則進入非滿等待佇列
            while(count == items.length) {
                //呼叫await()方法,進入等待佇列,只有在remove方法將元素移出有界佇列,然後進行notFull.signal()方法才能繼續進行新增操作。
                notFull.await();                
            }
            items[addIndex] = t;
            if(++addIndex == items.length) {
                addIndex = 0;
            }
            ++count;
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }
}
public T remove() throws InterruptedException {
    lock.lock();
    try {
        //如果當前佇列為空,則進入notEmpty等待佇列
        while(count == 0) {
            //只有呼叫add方法向有界佇列中新增元素,然後呼叫notEmpty.signal()方法後,才能從該方法返回。
            notEmpty.await();
        }
        Object x = items[removeIndex];
        if(++removeIndex == items.length) {
            removeIndex = 0;
        }
        --count;
        notFull.signal();
        return (T)x;
    } finally {
        lock.unlock();
    }
}
複製程式碼

這個有界佇列是一種特殊的佇列,當佇列為空時,佇列的獲取操作 將會阻塞獲取執行緒,直到佇列中有新增元素,當佇列已滿時,佇列的插入操作將會阻塞插入執行緒,直到佇列出現“空位”。

首先需要獲得鎖,目的是確保陣列修改的可見性和排他性。當陣列數量等於陣列長度時, 表示陣列已滿,則呼叫notFull.await(),當前執行緒隨之釋放鎖並進入等待狀態。如果陣列數量不等於陣列長度,表示陣列未滿,則新增元素到陣列中,同時通知等待在notEmpty上的執行緒,陣列中已經有新元素可以獲取。 在新增和刪除方法中使用while迴圈而非if判斷,目的是防止過早或意外的通知,只有條件 符合才能夠退出迴圈。回想之前提到的等待/通知的經典正規化,二者是非常類似的。

總結:在Object的監視器模型上,一個物件擁有一個同步佇列和等待佇列,而併發包中的 Lock(更確切地說是同步器)擁有一個同步佇列和多個等待佇列。

問題:為什麼每個併發包中的同步器會有多個等待佇列呢??

不同於synchronized同步佇列和等待佇列只有一個,AQS的等待佇列是有多個,因為AQS可以實現排他鎖(ReentrantLock)和非排他鎖(ReentrantReadWriteLock——讀寫鎖),讀寫鎖就是一個需要多個等待佇列的鎖。等待佇列(Condition)用來儲存被阻塞的執行緒的。因為讀寫鎖是一對鎖,所以需要兩個等待佇列來分別儲存被阻塞的讀鎖和被阻塞的寫鎖

深入condition

condition是一個介面,那它的實現類呢?它的實現類——ConditionObject定義在同步器AQS內部,因為condition的操作需要獲取相關聯的鎖,所以將其定義為同步器內部類也較為合理。

每個condition物件都包含著一個佇列——等待佇列,用來儲存著被阻塞的執行緒。隱式鎖synchronized配合著object物件的監視器方法wait()和notify()能夠實現等待/通知功能,當然顯式鎖(同步元件)配合著condition物件也可以實現等待/通知功能。

public class ConditionObject implements Condition, java.io.Serializable {
    private static final long serialVersionUID = 1173984872572414699L;
    //等待佇列的頭結點,transient的作用是不讓節點被序列化
    private transient Node firstWaiter;
    //等待佇列的尾結點,transient的作用是不讓節點被序列化
    private transient Node lastWaiter;
    ...    
}
複製程式碼

可以看到Node就是等待佇列中的節點。那麼這個Node節點是定義在哪裡的呢?這個Node節點是定義在同步器AQS中的。也就是說,同步佇列和等待佇列中的節點型別都是定義在同步器的靜態內部類AbstractQueuedSynchronized.Node。

對的你沒看錯,等待佇列和同步佇列都是定義在同步器中的,換句話說就是等待佇列定義在同步佇列中的。

等待——condition.await()

呼叫condition.await()方法會發生什麼是呢?當前執行緒會被構造成一個節點,然後從尾部加入到等待隊裡中並釋放當前執行緒持有的鎖,當前先被阻塞,然後喚醒同步佇列中的後繼節點,同時當前執行緒的狀態變為等待狀態(釋放同步狀態),然後就在等待佇列中一直等待著...等待著... 下一步會發生什麼?答案就在下面。

上面的過程可以這樣看待,就是將同步器中同步佇列的首節點(獲取了鎖的節點)的執行緒移動到了等待佇列的尾部。

下面來看看condition.await()方法的原始碼

public final void await() throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    //將當前執行緒構造成節點然後新增到condition等待佇列的尾部    
    Node node = addConditionWaiter();
    //釋放鎖(釋放同步狀態)
    int savedState = fullyRelease(node);
    int interruptMode = 0;
    //如果當前執行緒在同步佇列中,isOnSyncQueue(node)返回true
    while (!isOnSyncQueue(node)) {
        //阻塞當前執行緒
        LockSupport.park(this);
        if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
            break;
    }
    //呼叫同步器的acquireQueued()方法加入到獲取同步狀態的競爭中
    if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
        interruptMode = REINTERRUPT;
    if (node.nextWaiter != null) // clean up if cancelled
        unlinkCancelledWaiters();
    if (interruptMode != 0)
        reportInterruptAfterWait(interruptMode)
}
複製程式碼
LockSupport.park(this);
複製程式碼

LockSupport.park()方法很關鍵,讓當前佇列真正的阻塞起來。看LockSupport.park的原始碼可知是呼叫了native park()方法。

public native void park(boolean var1, long var2);
複製程式碼

下面用圖來展示一下上面說的稍微有點複雜但又很清晰的過程

問1:當前執行緒釋放的鎖被誰持有了?

答1:呼叫await()方法的執行緒獲得了鎖,然後該執行緒就會稱為同步佇列的首節點

特別注意:上述節點引用更新的過程(釋放同步狀態)並沒有使用CAS保證,原因在於呼叫await()方法的執行緒必定是獲取了鎖的執行緒,也就是說該過程是由鎖來保證執行緒安全的。

通知——condition.signal()

通知的結果,就相當於將等待佇列中等待時間最久的執行緒(首節點)移動到同步佇列中的隊尾

想了解更詳細的原理就接著往下看吧

先看看原始碼

//通知
public final void signal() {
    //isHeldExclusively()判斷當前是否是持有鎖的執行緒
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    //獲取等待佇列中的首節點
    Node first = firstWaiter;
    if (first != null)
        doSignal(first);
}

private void doSignal(Node first) {
    do {
        if ( (firstWaiter = first.nextWaiter) == null)
            lastWaiter = null;
        //將首節點斷開,然後呼叫transferForSignal(first)將首節點移動到同步佇列中    
        first.nextWaiter = null;
    } while (!transferForSignal(first) &&
             (first = firstWaiter) != null);
}

final boolean transferForSignal(Node node) {
    if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
        return false;
    //新增到同步佇列中
    Node p = enq(node);
    int ws = p.waitStatus;
    if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
        //呼叫unpark喚醒當前執行緒,然後就可以到同步佇列中去競爭鎖了
        LockSupport.unpark(node.thread);
    return true;
}
複製程式碼

結合上面的等待過程,此時的通知過程:當前持有鎖的執行緒呼叫signal(),先獲取等待佇列中的首節點並斷開首節點,然後新增到同步佇列的隊尾,最後呼叫LockSupport.unpark()方法來喚醒等待執行緒,將從await()方法中的while迴圈中退出(isOnSyncQueue(Node node)方法返回true,節點已經在同步佇列中),進而呼叫同步器的acquireQueued()方法加入到獲取同步狀態的競爭中。如果被喚醒的執行緒有幸競爭成功獲得了鎖(獲取同步狀態),被喚醒的執行緒將從先前呼叫的await()方法返回,此時該執行緒已經成功地獲取了鎖。

另外signalAll()方法,相當於對等待佇列中的每個節點均執行一次signal()方法,效 果就是將等待佇列中所有節點全部移動到同步佇列中,並喚醒每個節點的執行緒。

來一張圖整理一下整個通知過程,思路會更加清晰

相關文章