【Java併發編程】AQS(2)——獨佔鎖的獲取

今天是4月4日,清明節第一天,互聯網一片灰白,大家都在緬懷逝者,致敬英烈。所以今天我也沒有過多的娛樂,一天都在鼓搗這篇文章。今天這篇主要說說AQS獨佔鎖的獲取。

 

AQS中對獨佔鎖的獲取一共有三個方法,今天主要說第一個

  1. acquire:不響應中斷獲取獨佔鎖

  2. acquireInterruptibly:響應中斷獲取獨佔鎖

  3. tryAcquireNanos:響應中斷+超時獲取獨佔鎖

 

acquire方法,即在獨佔模式下獲取鎖,並且忽略中斷。它至少調用一次tryAcquire方法去獲取鎖,如果成功則直接返回,否則線程將被包裝成節點(即AQS內部類Node) 進入同步隊列,並且其可能反覆阻塞和解除阻塞,並調用tryAcquire去獲取鎖,直到最後成功。

 

上面這段話詳細介紹了acquire方法的執行過程,如果不理解沒關係,等看完下面的源碼解讀後,一切就清晰了


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

 

上面的源碼不太直觀,也不方便我後續講解,所以在不改變邏輯的前提下我將源碼改寫如下:

public final void acquire(int arg) {
   if (tryAcquire(arg)) { return; }
   Node node = addWaiter((Node.EXCLUSIVE), arg)
   if (acquireQueued(node)
      selfInterrupt();
}

 

我們可以看到,就四個方法,看上去是不是很簡單,所以接下來我們就將依次解讀上面四個方法

 

 

1  tryAcquire

我在"併發三板斧"已經說過,tryAcquire方法是AQS中的模板方法,是需要子類重寫實現的,其主要功能就是獲取鎖。當我們獲取到鎖時,返回true,acquire方法就直接return結束了;如果沒拿到鎖,返回false,調用入隊方法addWaiter。下面是tryAcquire的源碼

protected boolean tryAcquire(int arg) {
   throw new UnsupportedOperationException();
}

 

我們需要注意的是tryAcquire並不是一個抽象方法,而是拋了一個不支持運行的異常,這是爲什麼呢?其實很簡單,還記得"併發三板斧"文章中說的,不同的模式只需要重寫特定的方法嗎?繼承AQS的子類並不是所有的基本方法都需要重寫,而是按需重寫,如果基本方法都定義成抽象方法,則我們在實現AQS子類時就要重寫一些並不需要用到的方法

 

 

2  addWaiter

當獲取鎖失敗後,我們就會調用此方法,此方法主要功能是將獲取鎖失敗的線程包裝成Node放入等待隊列中,即入隊操作(ps:等待隊列是FIFO隊列,出隊在head端,入隊在tail端),下面是源碼

private Node addWaiter(Node mode) {
   Node node = new Node(Thread.currentThread(), mode);
   // Try the fast path of enq; backup to full enq on failure
   Node pred = tail;
   if (pred != null) {
       node.prev = pred;
       if (compareAndSetTail(pred, node)) {
           pred.next = node;
           return node;
       }
   }
   enq(node);
   return node;
}

 

我們首先會將當前線程和Node.EXCLUSIVE標誌位構造成一個Node。然後將pre指向尾結點tail,然後判斷pre是否爲null

 

如果爲null,說明同步隊列未初始化,則此時是沒有Node(準確的說是Node裏的線程,後面統一用Node來表示)在等待鎖,則我們會調用enq方法進行隊列初始化,然後重新入隊

 

如果不爲null,說明隊列不爲空,則我們就會進入第一個if分支內,嘗試將節點放入同步隊列的隊尾

 

首先,node.prev = pred 我們將要入隊的Node的前驅Node指向原來的尾結點pred,此時的隊列結構圖如下:

 

 

真的是如上圖所示嗎?不一定哈,上圖是在沒有併發的情況下,如果在併發的情況下,則可能如下圖所示:

 

此時可能有幾個節點都在進行入隊,且都走到了node.prev = pred 這一步,將自己的前驅節點指向了尾結點pred,所以下面就到了CAS發揮作用的時候啦

compareAndSetTail(pred, node)

 

此時只有一個節點會操作成功,我們假設中間的Node成功執行了這個操作,則此時變爲

 

則中間這個Node持有的線程會進入到第二個if分支內,完成Node入隊的剩餘操作,即pred的後繼Node指向tail,然後返回此Node。最上和最下這兩個操作失敗的節點則不會進入if分支內,而是調用enq方法,進行重新入隊操作

 

 

2.1  enq

在addWaiter方法解讀中我們看到了,有兩種情況我們會進入enq方法,第一種是同步隊列未初始化,第二種是在併發情況下入隊失敗。我們來看源碼

private Node enq(final Node node) {
   for (;;) {
       Node t = tail;
       // 隊列爲空, 初始化隊列操作,即將head和tail指向一個空節點
       if (t == null) { // Must initialize
           if (compareAndSetHead(new Node()))
               tail = head;
       } else {
       // 隊列不爲空
       // 併發下,cas操作可能會失敗,所以通過for循環不斷j進行入隊,直到成功爲止
           node.prev = t;
           if (compareAndSetTail(t, node)) {
               t.next = node;
               return t;
           }
       }
   }
}

 

if分支是處理隊列爲空的情況,即初始化隊列

 

else分支是處理入隊失敗的情況,這個入隊和addWaiter中的入隊是一模一樣的,所以也是會失敗的,但是我們可以看到,這裏有個for循環自旋,所以當我們入隊失敗後會再次嘗試,一直到入隊成功

 

這裏還需要特別注意的是,enq返回的是入隊Node的前驅節點,這裏大家有個印象就行了,這裏並沒涉及這個注意點,因爲addAwaiter方法中調用enq是沒有接受返回值的

 

 

3  acquireQueued

我們通過addWaiter入隊成功後,就會調用此方法,此方法的主要功能是掛起剛入隊Node中的線程,然後等待被喚醒再去獲取鎖。但需要注意的是,在掛起線程之前,如果滿足一定條件,此線程還會再次去獲取鎖,失敗後才掛起線程。滿足的是什麼條件呢?我們通過源碼來分析


final boolean acquireQueued(final Node node, int arg) {
   boolean failed = true;
   try {
       boolean interrupted = false;
       for (;;) {
           final Node p = node.predecessor();
           if (p == head && tryAcquire(arg)) {
               setHead(node);
               p.next = null; // help GC
               failed = false;
               return interrupted;
           }
           if (shouldParkAfterFailedAcquire(p, node) &&
               parkAndCheckInterrupt())
               interrupted = true;
       }
   } finally {
       if (failed)
           cancelAcquire(node);
   }
}

acquireQueued方法中有兩個標誌變量:failed和interrupted,他們分別代表拿鎖失敗標誌和線程被中斷標誌,兩者爲true時分別代表拿鎖失敗和中斷成功。

 

我們首先會將這兩個標誌位初始化,然後我們就會進入for循環自旋。首先我們會找到入隊Node的前驅Node,然後進入第一個if判斷:判斷前驅Node是否是頭結點head,如果是,則說明入隊的Node在同步隊列的第一個,前面沒有等待的Node了,此時我們會調用tryAcquire方法來再一次獲取鎖,獲取成功後,我們進入第一個if分支中(現在大家應該知道在什麼情況下會再次去嘗試拿鎖了吧)

 

第一個if分支中的主要操作是更新head,即將head指向此時的Node,然後將Node中的前驅Node和線程置爲null,使得此Node變爲一個虛擬頭結點 ,最後再更新兩個標誌位並返回interrupted

 

如果此入隊Node的前驅Node不是head或者在第一個if判斷中拿鎖失敗,我們就會進入第二個if判斷,第二個if判斷中我們會先執行shouldParkAfterFailedAcquire方法

 

 

3.1  shouldParkAfterFailedAcquire

看這個方法名也能明白其功能:檢查Node中的線程是否需要被掛起,如果返回true則說明需要掛起,然後執行後續掛起方法parkAndCheckInterrupt,否則重新自旋。我們需要注意兩點

  1. 走到這個方法的線程,都已經調用tryAcquire一次或多次失敗了

  2. 此方法不僅僅判斷線程能否被掛起,它還有將同步隊列中屬性爲CANCELLED的Node移除隊列的功能

 

我們看源碼:

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
   int ws = pred.waitStatus;
   if (ws == Node.SIGNAL)
       return true;
   if (ws > 0) {
       do {
           node.prev = pred = pred.prev;
       } while (pred.waitStatus > 0);
       pred.next = node;
   } else {
       compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
   }
   return false;
}

 

"併發三板斧"中我們說過Node一共有五種狀態,其中獨佔模式下會使用三種狀態:CANCELLED、SIGNAL、初始狀態0,我們可以看到上面的代碼也是三段分支。首先我們會拿到此節點的前驅節點狀態,爲什麼是前驅Node的狀態?因爲在獨佔模式下,Node是否能夠被掛起的依據是它前驅節點是否爲SIGNAL,爲SIGNAL時才能被掛起

  1. 前驅節點狀態爲SIGNAL,直接返回true

  2. 前驅節點狀態爲CANCELLED(ws>0),則移除這些節點,返回false

  3. 其他情況則將前驅節點的狀態改爲SIGNAL,返回false

 

我們看到,只有當前驅節點爲SIGNAL才返回true;其餘情況都返回false,然後回到acquireQueued方法中自旋重新執行

 

 

3.2 parkAndCheckInterrupt

如果shouldParkAfterFailedAcquire返回ture,我們則通過parkAndCheckInterrupt方法來掛起線程

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

 

這裏我們需要注意,當執行完LockSupport.park(this)後,此線程就被掛起了,除非當其他線程調用LockSupport.unpark喚醒當前線程或者當前線程被中斷,否則後面代碼是不會執行的

 

假設此時線程被喚醒了,我們此時是不知道線程是被unpark方法還是中斷喚醒的,所以我們需要通過Thread類的interrupted方法來判斷。interrupted方法會返回給我們當前線程的中斷標誌位,並將中斷標誌位復位,即置爲false。如果我們是中斷喚醒的,則返回true,然後會進入acquireQueued的第二個If分支中將interrupted置爲true。然後再次進入for循環自旋,看是獲取鎖還是又被掛起。

 

最後acquiredQueued方法只會存在兩種情況,第一種是獲取鎖然後返回interrupted標誌位,第二種出現異常,執行finally中if分支的cancelAcquire方法(注意,獲取鎖成功是不會執行cancelAcquire的,因爲failed標誌位爲false)

 

 

4  selfInterrupt

我們最後返回到acquire方法,如果acquire返回的是true,說明Node是被中斷喚醒的,則會調用selfInterrupt方法再一次調用中斷

static void selfInterrupt() {
    Thread.currentThread().interrupt();
}

爲啥還要執行中斷呢?還記得文章開頭我們是怎麼介紹acquire這個方法的嗎?acquire方法是在獨佔模式下不響應中斷獲取鎖的方法。如果在parkAndCheckInterrupt方法中線程是被中斷喚醒的,我們還是會繼續回到acquiredQueued中去搶鎖然後執行

 

當然interrupte這個方法也只是將當先線程的中斷標誌置爲true,至於會不會被中斷,我們也不知道

 

獨佔鎖的獲取我們就講完了,最後我再將文章的脈絡梳理下:

 

(未完)

歡迎大家關注我的公衆號 “程序員進階之路”,裏面記錄了一個非科班程序員的成長之路

                                                               

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章