Java併發程式設計之鎖機制之Condition介面

AndyandJennifer發表於2018-11-12

前言

在前面的文章中,我曾提到過,整個Lock介面下實現的鎖機制中AQS(AbstractQueuedSynchronizer,下文都稱之為AQS)Condition才是真正的實現者。也就說Condition在整個同步元件的基礎框架中也起著非常重要的作用,既然它如此重要與犀利,那麼現在我們就一起去了解其內部的實際原理與具體邏輯。

在閱讀該文章之前,我由衷的建議先閱讀《Java併發程式設計之鎖機制之AQS》《Java併發程式設計之鎖機制之LockSupport工具》這兩篇文章。因為整個Condtion的內部機制與邏輯都離不開以上兩篇文章提到的知識點。

Condition介面方法介紹

在正式介紹Condtion之前,我們可以先了解其中宣告的方法。具體方法宣告,如下表所示:

condition方法.png

從該表中,我們可以看出其內部定義了等待(以await開頭系列方法)通知(以singal開頭的系列方法)兩種型別的方法,類似於Object物件的wait()notify()/NotifyAll()方法來對執行緒的阻塞與喚醒。

ConditionObject介紹

在實際使用中,Condition介面實現類是AQS中的內部類ConditionObject。在其內部維護了一個FIFO(first in first out)的佇列(這裡我們稱之為等待佇列,你也可以叫做阻塞佇列,看每個人的理解),通過與AQS中的同步佇列配合使用,來控制獲取共享資源的執行緒。

等待佇列

等待佇列是ConditionObjec中內部的一個FIFO(first in first out)的佇列,在佇列中的每個節點都包含了一個執行緒引用,且該執行緒就是在ConditionObject物件上阻塞的執行緒。需要注意的是,在等待佇列中的節點是複用了AQSNode類的定義。換句話說,在AQS中維護的同步佇列與ConditionObjec中維護的等待佇列中的節點型別都是AQS.Node型別。(關於AbstractQueuedSynchronizer.Node類的介紹,大家可以參看《Java併發程式設計之鎖機制之AQS》文章中的描述)。

在ConditionObject類中也分別定義了firstWaiterlastWaiter兩個指標,分別指向等待佇列中頭部與尾部。當實際執行緒呼叫其以await開頭的系列方法後。會將該執行緒構造為Node節點。新增等待佇列中的尾部。關於等待佇列的基本結構如下圖所示:

condition內部結構.png

對於等待佇列中節點新增的方式也很簡單,將上一尾節點的nextWaiter指向新新增的節點,同時使lastWaiter指向新新增的節點。

同步佇列與等待佇列的對應關係

上文提到了整個Lock鎖機制需要AQS中的同步佇列ConditionObject的等待佇列配合使用,其對應關係如下圖所示:

同步佇列與等待佇列的關係.png

在Lock鎖機制下,可以擁有一個同步佇列和多個等待佇列,與我們傳統的Object監視器模型上,一個物件擁有一個同步佇列和等待佇列不同。lock中的鎖可以伴有多個條件。

Condition的基本使用

為了大家能夠更好的理解同步佇列與等待佇列的關係。下面通過一個有界佇列BoundedBuffer來了解Condition的使用方式,該類是一個特殊的佇列,當佇列為空時,佇列的獲取操作將會阻塞當前"拿"執行緒,直到佇列中有新增的元素,當佇列已滿時,佇列的放入操作將會阻塞"放入"執行緒,直到佇列中出現空位。具體程式碼如下所示:

class BoundedBuffer {

    final Lock lock = new ReentrantLock();
    final Condition notFull = lock.newCondition();
    final Condition notEmpty = lock.newCondition();

    final Object[] items = new Object[100];
    
    //依次為,放入的角標、拿的角標、陣列中放入的物件總數
    int putptr, takeptr, count;

    /**
     * 新增一個元素
     * (1)如果當前陣列已滿,則把當前"放入"執行緒,加入到"放入"等待佇列中,並阻塞當前執行緒
     * (2)如果當前陣列未滿,則將x元素放入陣列中,喚醒"拿"執行緒中的等待執行緒。
     */
    public void put(Object x) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length)//如果已滿,則阻塞當前"放入"執行緒
                notFull.await();
            items[putptr] = x;
            if (++putptr == items.length) putptr = 0;
            ++count;
            notEmpty.signal();//喚醒"拿"執行緒
        } finally {
            lock.unlock();
        }
    }

    /**
     * 拿一個元素
     * (1)如果當前陣列已空,則把當前"拿"執行緒,加入到"拿"等待佇列中,並阻塞當前執行緒
     * (2)如果當前陣列不為空,則把喚醒"放入"等待佇列中的執行緒。
     */
    public Object take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0)//如果為空,則阻塞當前"拿"執行緒
                notEmpty.await();
            Object x = items[takeptr];
            if (++takeptr == items.length) takeptr = 0;
            --count;
            notFull.signal();//喚醒"放入"執行緒
            return x;
        } finally {
            lock.unlock();
        }
    }
}
複製程式碼

從程式碼中我們可以看出,在該類中我們建立了兩個等待佇列notFullnotEmpty。這兩個等待佇列的作用分別是,當請陣列已滿時,notFull用於儲存阻塞的"放入"執行緒,notEmpty用於儲存阻塞的"拿"執行緒。需要注意的是獲取一個Condition必須通過Lock的newCondition()方法。關於ReentrantLock,在後續的文章中,我們會進行介紹。

阻塞實現 await()

在瞭解了ConditionObject的內部基本結構和與AQS中內部的同步佇列的對應關係後,現在我們來看看其阻塞實現。呼叫ConditionObject的await()方法(或者以await開頭的方法),會使當前執行緒進入等待佇列,並釋放同步狀態,需要注意的是當該方法返回時,當前執行緒一定獲取了同步狀態(具體原因是當通過signal()等系列方法,執行緒才會從await()方法返回,而喚醒該執行緒後會加入同步佇列)。這裡我們以awati()方法為例,具體程式碼如下所示:

  public final void await() throws InterruptedException {
			//如果當前執行緒已經中斷,直接丟擲異常  
            if (Thread.interrupted())
                throw new InterruptedException();
            //(1)將當前執行緒加入等待佇列
            Node node = addConditionWaiter();
            //(2)釋放同步狀態(也就是釋放鎖),同時將執行緒節點從同步佇列中移除,並喚醒同步佇列中的下一節點
            int savedState = fullyRelease(node);
            int interruptMode = 0;
            //(3)判斷當前執行緒節點是否還在同步佇列中,如果不在則阻塞執行緒
            while (!isOnSyncQueue(node)) {
                LockSupport.park(this);
                if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
                    break;
            }
            //(4)當執行緒被喚醒後,重新在同步佇列中與其他執行緒競爭獲取同步狀態
            if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
                interruptMode = REINTERRUPT;
            if (node.nextWaiter != null) // clean up if cancelled
                unlinkCancelledWaiters();
            if (interruptMode != 0)
                reportInterruptAfterWait(interruptMode);
        }
複製程式碼

從程式碼整體來看,整個方法分為以下四個步驟:

  • (1)通過 addConditionWaiter()方法將執行緒節點加入到等待佇列中。
  • (2)通過fullyRelease(Node node)方法釋放同步狀態(也就是釋放鎖),同時將執行緒節點從同步佇列中移除,並喚醒同步佇列中的下一節點
  • (3)通過isOnSyncQueue(Node node)方法判斷當前執行緒節點是否在同步佇列中,如果不在,則通過LockSupport.park(this);阻塞當前執行緒。
  • (4)當執行緒被喚醒後,呼叫acquireQueued(node, savedState)方法,重新在同步佇列中與其他執行緒競爭獲取同步狀態

因為每個步驟涉及到的邏輯都稍微有一點複雜,這裡為了方便大家理解,分別對以上四個步驟涉及到的方法分別進行介紹。

addConditionWaiter()方法

該方法主要將同步佇列中的需要阻塞的執行緒節點加入到等待佇列中,關於addConditionWaiter()方法具體程式碼如下所示:

    private Node addConditionWaiter() {
            Node t = lastWaiter;
            // (1)如果當前尾節點中中對應的執行緒已經中斷,
            //則移除等待佇列中所有的已經中斷或已經釋放同步狀態的執行緒節點
            if (t != null && t.waitStatus != Node.CONDITION) {
                unlinkCancelledWaiters();
                t = lastWaiter;
            }
		    //(2)構建等待佇列中的節點
            Node node = new Node(Node.CONDITION);
			
			//(3)將該執行緒節點新增到佇列中,同時構建firstWaiter與lastWaiter的指向
            if (t == null)
                firstWaiter = node;
            else
                t.nextWaiter = node;
            lastWaiter = node;
            return node;
        }
複製程式碼

該方法的邏輯也比較簡單,分為以下三個步驟:

  • (1)獲取等待佇列中的尾節點,如果當前尾節點已經中斷,那麼則通過unlinkCancelledWaiters()方法移除等待佇列中所有的已經中斷已經釋放同步狀態(也就是釋放鎖)的執行緒節點
  • (2)構建等待佇列中的節點,注意,是通過New的形式,那麼就說明與同步佇列中的執行緒節點不是同一個。(對Node狀態列舉不清楚的小夥伴,可以參看Java併發程式設計之鎖機制之AQS文章下的Node狀態列舉介紹)。
  • (3)將該執行緒節點新增到等待佇列中去,同時構建firstWaiter與lastWaiter的指向,可以看出等待佇列總是以FIFO(first in first out )的形式新增執行緒節點。
unlinkCancelledWaiters()方法

因為在addConditionWaiter()方法的步驟(1)中,呼叫了unlinkCancelledWaiters移除了所有的已經中斷的執行緒節點,那我們看一個該方法的實現。如下所示:

  private void unlinkCancelledWaiters() {
			//獲取等待佇列中的頭節點
            Node t = firstWaiter;
            Node trail = null;
            //遍歷等待佇列,將已經中斷的執行緒節點從等待佇列中移除。
            while (t != null) {
                Node next = t.nextWaiter;
                if (t.waitStatus != Node.CONDITION) {
                    t.nextWaiter = null;
                    if (trail == null)
                        firstWaiter = next;
                    else
                        trail.nextWaiter = next;
                    if (next == null)//重新定義lastWaiter的指向
                        lastWaiter = trail;
                }
                else
                    trail = t;
                t = next;
            }
        }
複製程式碼

該方法具體流程如下圖所示:

condition.png

fullyRelease(Node node)

在將阻塞執行緒將入到等待佇列後,會將該執行緒節點從同步佇列中移除,釋放同步狀態(也就是釋放鎖),並喚醒同步佇列中的下一節點。具體程式碼如下所示:

  final int fullyRelease(Node node) {
        try {
            int savedState = getState();
            if (release(savedState))
                return savedState;
            throw new IllegalMonitorStateException();
        } catch (Throwable t) {
            node.waitStatus = Node.CANCELLED;
            throw t;
        }
    }
複製程式碼

release(int arg)方法會釋放當前執行緒的同步狀態, 並喚醒同步佇列中的下一執行緒節點,使其嘗試獲取同步狀態,因為該方法已經在Java併發程式設計之鎖機制之AQS文章下的unparkSuccessorNode node)方法的下分析過了,所以這裡就不再進行分析了。希望大家參考上面提到的文章進行理解。

isOnSyncQueue(Node node)

該方法主要用於判斷當前執行緒節點是否在同步佇列中。具體程式碼如下所示:

  final boolean isOnSyncQueue(Node node) {
        //判斷當前節點 waitStatus ==Node.CONDITION或者當前節點上一節點為空,則不在同步佇列中
        if (node.waitStatus == Node.CONDITION || node.prev == null)
            return false;
        //如果當前節點擁有下一個節點,則在同步佇列中。
        if (node.next != null) // If has successor, it must be on queue
            return true;
	    //如果以上條件都不滿足,則遍歷同步佇列。檢查是否在同步佇列中。
        return findNodeFromTail(node);
    }
複製程式碼

如果你還記得AQS中的同步佇列,那麼你應該知道同步佇列中的Node節點才會使用其內部的prenext欄位,那麼在同步佇列中因為只使用了nextWaiter欄位,所以我們就能很簡單的通過這兩個欄位是否為==null,來判斷是否在同步佇列中。當然也有可能有一種特殊情況。有可能需要阻塞的執行緒節點還沒有加入到同步佇列中,那麼這個時候我們需要遍歷同步佇列來判斷是否在該執行緒節點是否線上程中。具體程式碼如下所示:

 private boolean findNodeFromTail(Node node) {
        for (Node p = tail;;) {
            if (p == node)
                return true;
            if (p == null)
                return false;
            p = p.prev;
        }
    }
複製程式碼

這裡之所以使用同步佇列tail(尾節點)來遍歷,如果node.netx!=null,那麼就說明當前執行緒已經在同步佇列中。那麼我們需要處理的情況肯定是針對node.next==null的情況。所以需要從尾節點開始遍歷。

acquireQueued(final Node node, int arg)

當執行緒被喚醒後(具體原因是當通過signal()等系列方法,執行緒才會從await()方法返回)會呼叫該方法將該執行緒節點加入到同步佇列中。該方法我在《Java併發程式設計之鎖機制之AQS》中具體描述過了。這裡就不在進行過多的解析。

阻塞流程

在理解了整個阻塞的流程後,現在我們來歸納總結一下,整個阻塞的流程。具體流程如下圖所示:

阻塞流程.png

  • (1)將該執行緒節點從同步佇列中移除,並釋放其同步狀態。
  • (2)構造新的阻塞節點,加入到等待佇列中。

喚醒實現 signal()

當需要喚醒執行緒時,會呼叫ConditionObject中的singal開頭的系列方法,該系列方法會喚醒等待佇列中的首個執行緒節點,在喚醒該節點之前,會先講該節點移動到同步佇列中。這裡我們以singal()方法為例進行講解,具體程式碼如下:

  public final void signal() {
		    //(1)判斷當前執行緒是否獲取到了同步狀態(也就是鎖)
            if (!isHeldExclusively())
                throw new IllegalMonitorStateException();
            Node first = firstWaiter;
            //(2)獲取等待佇列中的首節點,然後將其移動到同步佇列,然後再喚醒該執行緒節點
            if (first != null)
                doSignal(first);
        }
複製程式碼

該方法主要邏輯分為以下兩個步驟:

  • (1)通過isHeldExclusively()方法,判斷當前執行緒是否獲取到了同步狀態(也就是鎖)。
  • (2)通過doSignal(Node first)方法,獲取等待佇列中的首節點,然後將其移動到同步佇列,然後再喚醒該執行緒節點。

下面我們會分別對上面涉及到的兩個方法進行描述。

isHeldExclusively()方法

isHeldExclusively()方法是AQS中的方法,預設交給其子類實現,主要用於判斷當前呼叫singal()方法的執行緒,是否在同步佇列中,且已經獲取了同步狀態。具體程式碼如下所示:

    protected boolean isHeldExclusively() {
        throw new UnsupportedOperationException();
    }
複製程式碼

doSignal(Node first)方法

那我們繼續跟蹤doSignal(Node first)方法,具體方法如下:

     private void doSignal(Node first) {
            do {
                //(1)將等待佇列中的首節點從等待佇列中移除,並重新制定firstWaiter的指向
                if ( (firstWaiter = first.nextWaiter) == null)
                    lastWaiter = null;
                first.nextWaiter = null;
            } while (!transferForSignal(first) &&
            //(2)將等待佇列中的首節點,加入同步佇列中,並重新喚醒該節點
                     (first = firstWaiter) != null);
        }
複製程式碼

該方法也很簡單,分為兩個步驟:

  • (1)將等待佇列中的首節點從等待佇列中移除,並設定firstWaiter的指向為首節點的下一個節點。 為了方便大家理解該步驟所描述的邏輯,這裡畫了具體的圖,具體情況如下圖所示:
    移除首節點.png
  • (2)通過 transferForSignal(Node node)方法,將等待佇列中的首節點,加入到同步佇列中去,然後重新喚醒該執行緒節點。
transferForSignal(Node node)方法

因為步驟(2)中transferForSignal(Node node)方法較為複雜,所以會對該方法進行詳細的講解。具體程式碼如下所示:

    final boolean transferForSignal(Node node) {
       
        //(1)將該執行緒節點的狀態設定為初始狀態,如果失敗則表示當前執行緒已經中斷了
        if (!node.compareAndSetWaitStatus(Node.CONDITION, 0))
            return false;
        //(2)將該節點放入同步佇列中,
        Node p = enq(node);
        int ws = p.waitStatus;
        //(3)獲取當前節點的狀態並判斷,嘗試將該執行緒節點狀態設定為Singal,如果失敗則喚醒執行緒
        if (ws > 0 || !p.compareAndSetWaitStatus(ws, Node.SIGNAL))
            LockSupport.unpark(node.thread);
        return true;
    }
複製程式碼

該方法分為三個步驟:

  • (1)將該執行緒節點的狀態設定為初始狀態,如果失敗則表示當前執行緒已經中斷了,直接返回。
  • (2)通過enq(Node node)方法,將該執行緒節點放入同步佇列中。
  • (3)當將該執行緒節點放入同步佇列後,獲取當前節點的狀態並判斷,如果該節點的waitStatus>0或者通過compareAndSetWaitStatus(ws, Node.SIGNAL)將該節點的狀態設定為Singal,如果失敗則通過LockSupport.unpark(node.thread)喚醒執行緒。

上述步驟中,著重講enq(Node node)方法,關於LockSupport.unPark(Thread thread)方法的理解,大家可以閱讀《Java併發程式設計之鎖機制之LockSupport工具》。下面我們就來分析enq(Node node)方法。具體程式碼如下所示:

   private Node enq(Node node) {
        for (;;) {
            //(1)獲取同步佇列的尾節點
            Node oldTail = tail;
            //(2)如果尾節點不為空,則將該執行緒節點加入到同步佇列中
            if (oldTail != null) {
	            //將當前節點的prev指向尾節點
                U.putObject(node, Node.PREV, oldTail);
                //將同步佇列中的tail指標,指向當前節點
                if (compareAndSetTail(oldTail, node)) {
                    oldTail.next = node;
                    return oldTail;
                }
            } else {
	            //(3)如果當前同步佇列為空,則構造同步佇列
                initializeSyncQueue();
            }
        }
    }
複製程式碼

觀察該方法,我們發現該方法通過死迴圈(當然你也可以叫做自旋)的方式來新增該節點到同步佇列中去。該方法分為以下步驟:

  • (1)獲取同步佇列的尾節點
  • (2)如果尾節點不為空,則將該執行緒節點加入到同步佇列中
  • (3)如果當前同步佇列為空,則通過initializeSyncQueue();構造同步佇列。

這裡對Node enq(Node node)中的步驟(2)補充一個知識點。我們來看一下呼叫U.putObject(node, Node.PREV, oldTail);語句,內部是如何將當前的節點的prev指向尾節點的。在AQS(AbstractQueuedSynchronizer)中的Node類中有如下靜態變數和語句。這裡我省略了一下不重要的程式碼。具體程式碼如下所示:

private static final sun.misc.Unsafe U = sun.misc.Unsafe.getUnsafe();
//省略部分程式碼
static final long PREV;
    static {
         try {
		    //省略部分程式碼
            PREV = U.objectFieldOffset
               (Node.class.getDeclaredField("prev"));
              } catch (ReflectiveOperationException e) {
               throw new Error(e);
            }
        }
    }
複製程式碼

其中Node.class.getDeclaredField("prev")語句很好理解,就是獲取Node類中pre欄位,如果有則返回相應Field欄位,反之丟擲NoSuchFieldException異常。關於Unfase中的objectFieldOffset(Field f)方法,我曾經在《Java併發程式設計之鎖機制之LockSupport工具》描述過類似的情況。這裡我簡單的再解釋一遍。該方法用於獲取某個欄位相對 Java物件的“起始地址”的偏移量,也就是說每個欄位在類對應的記憶體中儲存是有“角標”的,那麼也就是說我們現在的PREV靜態變數就代表著Node中prev欄位在記憶體中的“角標”。

當獲取到"角標"後,我們再通過U.putObject(node, Node.PREV, oldTail);該方法第一個引數是操作物件,第二個引數是操作的記憶體“角標”,第三個引數是期望值。那麼最後,也就完成了將當前節點的prev欄位指向同步佇列的尾節點。

當理解了該知識點後,剩下的將同步佇列中的tail指標,指向當前節點如果當前同步佇列為空,則構造同步佇列這兩個操作就非常好理解了。由於篇幅的限制,在這裡我就不在進行描述了。希望讀者朋友們,能閱讀原始碼,舉一反三。關於這兩個方法的程式碼如下所示:

   private static final sun.misc.Unsafe U = sun.misc.Unsafe.getUnsafe();
    private static final long STATE;
    private static final long HEAD;
    private static final long TAIL;
    static {
        try {
            STATE = U.objectFieldOffset
                (AbstractQueuedSynchronizer.class.getDeclaredField("state"));
            HEAD = U.objectFieldOffset
                (AbstractQueuedSynchronizer.class.getDeclaredField("head"));
            TAIL = U.objectFieldOffset
                (AbstractQueuedSynchronizer.class.getDeclaredField("tail"));
        } catch (ReflectiveOperationException e) {
            throw new Error(e);
        }
        Class<?> ensureLoaded = LockSupport.class;
    }

    private final void initializeSyncQueue() {
        Node h;
        if (U.compareAndSwapObject(this, HEAD, null, (h = new Node())))
            tail = h;
    }

    private final boolean compareAndSetTail(Node expect, Node update) {
        return U.compareAndSwapObject(this, TAIL, expect, update);
    }
複製程式碼

喚醒流程

在理解了喚醒的具體邏輯後,現在來總結一下,喚醒的具體流程。具體如下圖所示:

喚醒流程.png

  • 將等待佇列中的節點執行緒,移動到同步佇列中。
  • 當移動到同步佇列中後。喚醒該執行緒。是該執行緒參與同步狀態的競爭。

整體流程其實不算太複雜,大家只需要注意,當我們將等待佇列中的執行緒節點加入到同步佇列之後,才會喚醒執行緒

最後

該文章參考以下圖書,站在巨人的肩膀上。可以看得更遠。

  • 《Java併發程式設計的藝術》

推薦閱讀

相關文章