再談AbstractQueuedSynchronizer1:獨佔模式

五月的倉頡發表於2017-06-23

關於AbstractQueuedSynchronizer

JDK1.5之後引入了併發包java.util.concurrent,大大提高了Java程式的併發效能。關於java.util.concurrent包我總結如下:

  • AbstractQueuedSynchronizer是併發類諸如ReentrantLock、CountDownLatch、Semphore的核心
  • CAS演算法是AbstractQueuedSynchronizer的核心

可以說AbstractQueuedSynchronizer是併發類的重中之重。其實之前在ReentrantLock實現原理深入探究一文中已經有結合ReentrantLock詳細解讀過AbstractQueuedSynchronizer,但限於當時水平原因,回看一年半前的此文,感覺對於AbstractQueuedSynchronizer的解讀理解還不夠深,因此這裡更新一篇文章,再次解讀AbstractQueuedSynchronizer的資料結構即相關原始碼實現,本文基於JDK1.7版本。

 

AbstactQueuedSynchronizer的基本資料結構

AbstractQueuedSynchronizer的基本資料結構為Node,關於Node,JDK作者寫了詳細的註釋,這裡我大致總結幾點:

  1. AbstractQueuedSynchronizer的等待佇列是CLH佇列的變種,CLH佇列通常用於自旋鎖,AbstractQueuedSynchronizer的等待佇列用於阻塞同步器
  2. 每個節點中持有一個名為"status"的欄位用於是否一條執行緒應當阻塞的追蹤,但是status欄位並不保證加鎖
  3. 一條執行緒如果它處於佇列頭的下一個節點,那麼它會嘗試去acquire,但是acquire並不保證成功,它只是有權利去競爭
  4. 要進入佇列,你只需要自動將它拼接在佇列尾部即可;要從佇列中移除,你只需要設定header欄位

下面我用一張表格總結一下Node中持有哪些變數且每個變數的含義:

關於SIGNAL、CANCELLED、CONDITION、PROPAGATE四個狀態,JDK原始碼的註釋中同樣有了詳細的解讀,再用一張表格總結一下:

 

AbstractQueuedSynchronizer供子類實現的方法

AbstractQueuedSynchzonizer是基於模板模式的實現,不過它的模板模式寫法有點特別,整個類中沒有任何一個abstract的抽象方法,取而代之的是,需要子類去實現的那些方法通過一個方法體丟擲UnsupportedOperationException異常來讓子類知道。

AbstractQueuedSynchronizer類中一共有五處方法供子類實現,用表格總結一下:

這裡的acquire不好翻譯,所以就直接原詞放上來了,因為acquire是一個動詞,後面並沒有帶賓語,因此不知道具體acquire的是什麼。按照我個人理解,acquire的意思應當是根據狀態欄位state去獲取一個執行當前動作的資格

比如ReentrantLock的lock()方法最終會呼叫acquire方法,那麼:

  1. 執行緒1去lock(),執行acquire,發現state=0,因此有資格執行lock()的動作,將state設定為1,返回true
  2. 執行緒2去lock(),執行acquire,發現state=1,因此沒有資格執行lock()的動作,返回false

這種理解我認為應當是比較準確的。

 

獨佔模式acquire實現流程

有了上面的這些基礎,我們看一下獨佔式acquire的實現流程,主要是線上程acquire失敗後,是如何構建資料結構的,先看理論,之後再用一個例子畫圖說明。

看一下AbstractQuueuedSynchronizer的acquire方法實現流程,acquire方法是用於獨佔模式下進行操作的:

 1 public final void acquire(int arg) {
 2     if (!tryAcquire(arg) &&
 3         acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
 4         selfInterrupt();
 5 }

tryAcquire方法前面說過了,是子類實現的一個方法,如果tryAcquire返回的是true(成功),即表明當前執行緒獲得了一個執行當前動作的資格,自然也就不需要構建資料結構進行阻塞等待。

如果tryAcquire方法返回的是false,那麼當前執行緒沒有獲得執行當前動作的資格,接著執行"acquireQueued(addWaiter(Node.EXCLUSIVE), arg))"這句程式碼,這句話很明顯,它是由兩步構成的:

  1. addWaiter,新增一個等待者
  2. acquireQueued,嘗試從等待佇列中去獲取執行一次acquire動作

分別看一下每一步做了什麼。

 

addWaiter

先看第一步,addWaiter做了什麼,從傳入的引數Node.EXCLUSIVE我們知道這是獨佔模式的:

 1 private Node addWaiter(Node mode) {
 2     Node node = new Node(Thread.currentThread(), mode);
 3     // Try the fast path of enq; backup to full enq on failure
 4     Node prev = tail;
 5     if (prev != null) {
 6         node.prev = prev;
 7         if (compareAndSetTail(prev, node)) {
 8             prev.next = node;
 9             return node;
10         }
11     }
12     enq(node);
13     return node;
14 }

首先看第4行~第11行的程式碼,獲得當前資料結構中的尾節點,如果有尾節點,那麼先獲取這個節點認為它是前驅節點prev,然後:

  1. 新生成的Node的前驅節點指向prev
  2. 併發下只有一條執行緒可以通過CAS演算法讓自己的Node成為尾節點,此時將此prev的next指向該執行緒對應的Node

因此在資料結構中有節點的情況下,所有新增節點都是作為尾節點插入資料結構。從註釋上來看,這段邏輯的存在的意義是以最短路徑O(1)的效果完成快速入隊,以最大化減小開銷。

假如當前節點沒有被設定為尾節點,那麼執行enq方法:

 1 private Node enq(final Node node) {
 2     for (;;) {
 3         Node t = tail;
 4         if (t == null) { // Must initialize
 5             if (compareAndSetHead(new Node()))
 6                 tail = head;
 7         } else {
 8             node.prev = t;
 9             if (compareAndSetTail(t, node)) {
10                 t.next = node;
11                 return t;
12             }
13         }
14     }
15 }

這段程式碼的邏輯為:

  1. 如果尾節點為空,即當前資料結構中沒有節點,那麼new一個不帶任何狀態的Node作為頭節點
  2. 如果尾節點不為空,那麼併發下使用CAS演算法將當前Node追加成為尾節點,由於是一個for(;;)迴圈,因此所有沒有成功acquire的Node最終都會被追加到資料結構中

看完了程式碼,用一張圖表示一下AbstractQueuedSynchronizer的整體資料結構(比較簡單,就不自己畫了,網上隨便找了一張圖):

 

acquireQueued

佇列構建好了,下一步就是在必要的時候從佇列裡面拿出一個Node了,這就是acquireQueued方法,顧名思義,從佇列裡面acquire。看下acquireQueued方法的實現:

 1 final boolean acquireQueued(final Node node, int arg) {
 2     boolean failed = true;
 3     try {
 4         boolean interrupted = false;
 5         for (;;) {
 6             final Node p = node.prevecessor();
 7             if (p == head && tryAcquire(arg)) {
 8                 setHead(node);
 9                 p.next = null; // help GC
10                 failed = false;
11                 return interrupted;
12             }
13             if (shouldParkAfterFailedAcquire(p, node) &&
14                 parkAndCheckInterrupt())
15                 interrupted = true;
16         }
17     } finally {
18         if (failed)
19             cancelAcquire(node);
20     }
21 }

這段程式碼描述了幾件事:

  1. 從第6行的程式碼獲取節點的前驅節點p,第7行的程式碼判斷p是前驅節點並tryAcquire我們知道,只有當前第一個持有Thread的節點才會嘗試acquire,如果節點acquire成功,那麼setHead方法,將當前節點作為head、將當前節點中的thread設定為null、將當前節點的prev設定為null,這保證了資料結構中頭結點永遠是一個不帶Thread的空節點
  2. 如果當前節點不是前驅節點或者tryAcquire失敗,那麼執行第13行~第15行的程式碼,做了兩步操作,首先判斷在acquie失敗後是否應該park,其次park並檢查中斷狀態

看一下第一步shouldParkAfterFailedAcquire程式碼做了什麼:

 1 private static boolean shouldParkAfterFailedAcquire(Node prev, Node node) {
 2     int ws = prev.waitStatus;
 3     if (ws == Node.SIGNAL)
 4         /*
 5          * This node has already set status asking a release
 6          * to signal it, so it can safely park.
 7          */
 8         return true;
 9     if (ws > 0) {
10         /*
11          * prevecessor was cancelled. Skip over prevecessors and
12          * indicate retry.
13          */
14         do {
15             node.prev = prev = prev.prev;
16         } while (prev.waitStatus > 0);
17         prev.next = node;
18     } else {
19         /*
20          * waitStatus must be 0 or PROPAGATE.  Indicate that we
21          * need a signal, but don't park yet.  Caller will need to
22          * retry to make sure it cannot acquire before parking.
23          */
24         compareAndSetWaitStatus(prev, ws, Node.SIGNAL);
25     }
26     return false;
27 }

這裡每個節點判斷它前驅節點的狀態,如果:

  1. 它的前驅節點是SIGNAL狀態的,返回true,表示當前節點應當park
  2. 它的前驅節點的waitStatus>0,相當於CANCELLED(因為狀態值裡面只有CANCELLED是大於0的),那麼CANCELLED的節點作廢,當前節點不斷向前找並重新連線為雙向佇列,直到找到一個前驅節點waitStats不是CANCELLED的為止
  3. 它的前驅節點不是SIGNAL狀態且waitStatus<=0,此時執行第24行程式碼,利用CAS機制,如果waitStatus的前驅節點是0那麼更新為SIGNAL狀態

如果判斷判斷應當park,那麼parkAndCheckInterrupt方法:

 1 private final boolean parkAndCheckInterrupt() {
 2     LockSupport.park(this);
 3     return Thread.interrupted();
 4 }

利用LockSupport的park方法讓當前執行緒阻塞。

 

獨佔模式release流程

上面整理了獨佔模式的acquire流程,看到了等待的Node是如何構建成一個資料結構的,下面看一下釋放的時候做了什麼,release方法的實現為:

1 public final boolean release(int arg) {
2     if (tryRelease(arg)) {
3         Node h = head;
4         if (h != null && h.waitStatus != 0)
5             unparkSuccessor(h);
6         return true;
7     }
8     return false;
9 }

tryRelease同樣是子類去實現的,表示當前動作我執行完了,要釋放我執行當前動作的資格,講這個資格讓給其它執行緒,然後tryRelease釋放成功,獲取到head節點,如果head節點的waitStatus不為0的話,執行unparkSuccessor方法,顧名思義unparkSuccessor意為unpark頭結點的繼承者,方法實現為:

 1 private void unparkSuccessor(Node node) {
 2         /*
 3          * If status is negative (i.e., possibly needing signal) try
 4          * to clear in anticipation of signalling.  It is OK if this
 5          * fails or if status is changed by waiting thread.
 6          */
 7         int ws = node.waitStatus;
 8         if (ws < 0)
 9             compareAndSetWaitStatus(node, ws, 0);
10 
11         /*
12          * Thread to unpark is held in successor, which is normally
13          * just the next node.  But if cancelled or apparently null,
14          * traverse backwards from tail to find the actual
15          * non-cancelled successor.
16          */
17         Node s = node.next;
18         if (s == null || s.waitStatus > 0) {
19             s = null;
20             for (Node t = tail; t != null && t != node; t = t.prev)
21                 if (t.waitStatus <= 0)
22                     s = t;
23         }
24         if (s != null)
25             LockSupport.unpark(s.thread);
26 }

這段程式碼比較好理解,整理一下流程:

  1. 頭節點的waitStatus<0,將頭節點的waitStatus設定為0
  2. 拿到頭節點的下一個節點s,如果s==null或者s的waitStatus>0(被取消了),那麼從佇列尾巴開始向前尋找一個waitStatus<=0的節點作為後繼要喚醒的節點

最後,如果拿到了一個不等於null的節點s,就利用LockSupport的unpark方法讓它取消阻塞。

 

實戰舉例:資料結構構建

上面的例子講解地過於理論,下面利用ReentrantLock舉個例子,但是這裡不講ReentrantLock實現原理,只是利用ReentrantLock研究AbstractQueuedSynchronizer的acquire和release。示例程式碼為:

 1 /**
 2  * @author 五月的倉頡http://www.cnblogs.com/xrq730/p/7056614.html
 3  */
 4 public class AbstractQueuedSynchronizerTest {
 5 
 6     @Test
 7     public void testAbstractQueuedSynchronizer() {
 8         Lock lock = new ReentrantLock();
 9         
10         Runnable runnable0 = new ReentrantLockThread(lock);
11         Thread thread0 = new Thread(runnable0);
12         thread0.setName("執行緒0");
13         
14         Runnable runnable1 = new ReentrantLockThread(lock);
15         Thread thread1 = new Thread(runnable1);
16         thread1.setName("執行緒1");
17         
18         Runnable runnable2 = new ReentrantLockThread(lock);
19         Thread thread2 = new Thread(runnable2);
20         thread2.setName("執行緒2");
21         
22         thread0.start();
23         thread1.start();
24         thread2.start();
25         
26         for (;;);
27     }
28     
29     private class ReentrantLockThread implements Runnable {
30         
31         private Lock lock;
32         
33         public ReentrantLockThread(Lock lock) {
34             this.lock = lock;
35         }
36         
37         @Override
38         public void run() {
39             try {
40                 lock.lock();
41                 for (;;);
42             } finally {
43                 lock.unlock();
44             }
45         }
46         
47     }
48     
49 }

全部是死迴圈,相當於第一條執行緒(執行緒0)acquire成功之後,後兩條執行緒(執行緒1、執行緒2)阻塞,下面的程式碼就不考慮後兩條執行緒誰先誰後的問題,就一條執行緒(執行緒1)流程執行到底、另一條執行緒(執行緒2)流程執行到底這麼分析了。

這裡再把addWaiter和enq兩個方法原始碼貼一下:

 1 private Node addWaiter(Node mode) {
 2     Node node = new Node(Thread.currentThread(), mode);
 3     // Try the fast path of enq; backup to full enq on failure
 4     Node prev = tail;
 5     if (prev != null) {
 6         node.prev = prev;
 7         if (compareAndSetTail(prev, node)) {
 8             prev.next = node;
 9             return node;
10         }
11     }
12     enq(node);
13     return node;
14 }
 1 private Node enq(final Node node) {
 2     for (;;) {
 3         Node t = tail;
 4         if (t == null) { // Must initialize
 5             if (compareAndSetHead(new Node()))
 6                 tail = head;
 7         } else {
 8             node.prev = t;
 9             if (compareAndSetTail(t, node)) {
10                 t.next = node;
11                 return t;
12             }
13         }
14     }
15 }

首先第一個acquire失敗的執行緒1,由於此時整個資料結構中麼沒有任何資料,因此addWaiter方法第4行中拿到的prev=tail為空,執行enq方法,首先第3行獲取tail,第4行判斷到tail是null,因此頭結點new一個Node出來通過CAS演算法設定為資料結構的head,tail同樣也是這個Node,此時資料結構為:

為了方便描述,prev和next,我給每個Node隨便加了一個地址。接著繼續enq,因為enq內是一個死迴圈,所以繼續第3行獲取tail,new了一個空的Node之後tail就有了,執行else判斷,通過第8行~第10行程式碼將當前執行緒對應的Node追加到資料結構尾部,那麼當前構建的資料結構為:

這樣,執行緒1對應的Node被加入資料結構,成為資料結構的tail,而資料結構的head是一個什麼都沒有的空Node。

接著執行緒2也acquire失敗了,執行緒2既然acquire失敗,那也要準備被加入資料結構中,繼續先執行addWaiter方法,由於此時已經有了tail,因此不需要執行enq方法,可以直接將當前Node新增到資料結構尾部,那麼當前構建的資料結構為:

至此,兩個阻塞的執行緒構建的三個Node已經全部歸位。

 

實戰舉例:執行緒阻塞

上述流程只是描述了構建資料結構的過程,並沒有描述執行緒1、執行緒2阻塞的流程,因此接著繼續用實際例子看一下執行緒1、執行緒2如何阻塞。貼一下acquireQueued、shouldParkAfterFailedAcquire兩個方法原始碼:

 1 final boolean acquireQueued(final Node node, int arg) {
 2     boolean failed = true;
 3     try {
 4         boolean interrupted = false;
 5         for (;;) {
 6             final Node p = node.prevecessor();
 7             if (p == head && tryAcquire(arg)) {
 8                 setHead(node);
 9                 p.next = null; // help GC
10                 failed = false;
11                 return interrupted;
12             }
13             if (shouldParkAfterFailedAcquire(p, node) &&
14                 parkAndCheckInterrupt())
15                 interrupted = true;
16         }
17     } finally {
18         if (failed)
19             cancelAcquire(node);
20     }
21 }
 1 private static boolean shouldParkAfterFailedAcquire(Node prev, Node node) {
 2     int ws = prev.waitStatus;
 3     if (ws == Node.SIGNAL)
 4         /*
 5          * This node has already set status asking a release
 6          * to signal it, so it can safely park.
 7          */
 8         return true;
 9     if (ws > 0) {
10         /*
11          * prevecessor was cancelled. Skip over prevecessors and
12          * indicate retry.
13          */
14         do {
15             node.prev = prev = prev.prev;
16         } while (prev.waitStatus > 0);
17         prev.next = node;
18     } else {
19         /*
20          * waitStatus must be 0 or PROPAGATE.  Indicate that we
21          * need a signal, but don't park yet.  Caller will need to
22          * retry to make sure it cannot acquire before parking.
23          */
24         compareAndSetWaitStatus(prev, ws, Node.SIGNAL);
25     }
26     return false;
27 }

首先是執行緒1,它的前驅節點是head節點,在它tryAcquire成功的情況下,執行第8行~第11行的程式碼。做幾件事情:

  1. head為執行緒1對應的Node
  2. 執行緒1對應的Node的thread置空
  3. 執行緒1對應的Node的prev置空
  4. 原head的next置空,這樣原head中的prev、next、thread都為空,物件內沒有引用指向其他地方,GC可以認為這個Node是垃圾,對這個Node進行回收,註釋"Help GC"就是這個意思
  5. failed=false表示沒有失敗

因此,如果執行緒1執行tryAcquire成功,那麼資料結構將變為:

從上述流程可以總結到:只有前驅節點為head的節點會嘗試tryAcquire,其餘都不會,結合後面的release選繼承者的方式,保證了先acquire失敗的執行緒會優先從阻塞狀態中解除去重新acquire。這是一種公平的acquire方式,因為它遵循"先到先得"原則,但是我們可以動動手腳讓這種公平變為非公平,比如ReentrantLock預設的非公平模式,這個留在後面說。

那如果執行緒1執行tryAcquire失敗,那麼要執行shouldParkAfterFailedAcquire方法了,shouldParkAfterFailedAcquire拿執行緒1的前驅節點也就是head節點的waitStatus做了一個判斷,因為waitStatus=0,因此執行第18行~第20行的邏輯,將head的waitStatus設定為SIGNAL即-1,然後方法返回false,資料結構變為:

看到這裡就一個變化:head的waitStatus從0變成了-1。既然shouldParkAfterFailedAcquire返回false,acquireQueued的第13行~第14行的判斷自然不通過,繼續走for(;;)迴圈,如果tryAcquire失敗顯然又來到了shouldParkAfterFailedAcquire方法,此時執行緒1對應的Node的前驅節點head節點的waitStatus已經變為了SIGNAL即-1,因此執行第4行~第8行的程式碼,直接返回true出去。

shouldParkAfterFailedAcquire返回true,parkAndCheckInterrupt直接呼叫LockSupport的park方法:

 1 private final boolean parkAndCheckInterrupt() {
 2     LockSupport.park(this);
 3     return Thread.interrupted();
 4 }

至此執行緒1阻塞,執行緒2阻塞的流程與執行緒1阻塞的流程相同,可以自己分析一下。

另外再提一個問題,不知道大家會不會想:

  1. 為什麼執行緒1對應的Node構建完畢不直接呼叫LockSupport的park方法進行阻塞?
  2. 為什麼不直接把head的waitStatus直接設定為Signal而要從0設定為Signal?

我認為這是AbstractQueuedSynchronizer開發人員做了類似自旋的操作。因為很多時候獲取acquire進行操作的時間很短,阻塞會引起上下文的切換,而很短時間就從阻塞狀態解除,這樣相對會比較耗費效能。

因此我們看到執行緒1自構建完畢Node加入資料結構到阻塞,一共嘗試了兩次tryAcquire,如果其中有一次成功,那麼執行緒1就沒有必要被阻塞,提升了效能。

相關文章