解讀 JUC —— AQS 獨佔模式

梁山boy發表於2019-05-07

1. 前言

說起 JUC,我們常常會想起其中的執行緒池(ExecutorService)。然而,我們今天來看看另一個核心模組 AQS。

AQS 是 AbstractQueuedSynchronizer 的簡稱,在 JUC 中作為各種同步器的基石。舉個例子,常見的 ReentrantLock 就是由它實現的。

2. 如何實現一個鎖?

我們知道,java 有一個關鍵字 synchronized 來給一段程式碼加鎖,可是這是 JVM 層面的事情。那麼問題來了,如何在 java 程式碼層面來實現模擬一個鎖?

即實現這樣一個介面:

package java.util.concurrent.locks;

public interface Lock {
    void lock();

    void unlock();
}
複製程式碼

自旋鎖

一個簡單的想法是:讓所有執行緒去競爭一個變數owner,確保只有一個執行緒成功,並設定自己為owner,其他執行緒陷入死迴圈等待。這便是所謂的自旋鎖

一個簡單的程式碼實現:

import java.util.concurrent.atomic.AtomicReference;

public class SpinLock {
   private AtomicReference<Thread> owner = new AtomicReference<Thread>();

   public void lock() {
       Thread currentThread = Thread.currentThread();

       // 如果鎖未被佔用,則設定當前執行緒為鎖的擁有者
       while (!owner.compareAndSet(null, currentThread)) {
       }
   }

   public void unlock() {
       Thread currentThread = Thread.currentThread();

       // 只有鎖的擁有者才能釋放鎖
       owner.compareAndSet(currentThread, null);
   }
}
複製程式碼

扯這個自旋鎖,主要是為了引出 AQS 背後的演算法 CLH鎖

關於CLH鎖更多細節可以參考篇文章:

自旋鎖、排隊自旋鎖、MCS鎖、CLH鎖

3. AQS 的實現

CLH鎖的思想,簡單的說就是:一群人去ATM取錢,頭一個人拿到鎖,在裡面用銀行卡取錢,其餘的人在後面排隊等待;前一個人取完錢出來,喚醒下一個人進去取錢。

關鍵部分翻譯成程式碼就是:

  • 排隊 -> 佇列
  • 等待/喚醒 -> wait()/notify() 或者別的什麼 api

3.1 同步佇列

AQS 使用節點為 Node 的雙向連結串列作為同步佇列。拿到鎖的執行緒可以繼續執行程式碼,沒拿到的執行緒就進入這個佇列排隊。

public abstract class AbstractQueuedSynchronizer ... {
    // 佇列頭
    private transient volatile Node head;
    // 佇列尾
    private transient volatile Node tail;

    static final class Node {
        /** 共享模式,可用於實現 CountDownLatch */
        static final Node SHARED = new Node();
        /** 獨佔模式,可用於實現 ReentrantLock */
        static final Node EXCLUSIVE = null;
	
        /** 取消 */
        static final int CANCELLED =  1;
        /** 意味著它的後繼節點的執行緒在排隊,等待被喚醒 */
        static final int SIGNAL    = -1;
        /** 等待在條件上(與Condition相關,暫不解釋) */
        static final int CONDITION = -2;
        /**
         * 與共享模式相關,暫不解釋
         */
        static final int PROPAGATE = -3;
	
        // 可取值:CANCELLED, 0, SIGNAL, CONDITION, PROPAGATE
        volatile int waitStatus;
	
        volatile Node prev;
	
        volatile Node next;
	
        volatile Thread thread;
	    
        Node nextWaiter;
    }
}
複製程式碼

這個佇列大體上長這樣:圖片來源

sync queue

條件佇列是為了支援 Lock.newCondition() 這個功能,暫時不care,先跳過。

3.2 獨佔模式的 api

AQS 支援獨佔鎖(Exclusive)和共享鎖(Share)兩種模式:

  • 獨佔鎖:只能被一個執行緒獲取到 (ReentrantLock);
  • 共享鎖:可以被多個執行緒同時獲取 (CountDownLatch、ReadWriteLock 的讀鎖)。

這邊我們只看獨佔模式,它對外提供一套 api:

  • acquire(int n):獲取n個資源(鎖)
  • release(int n):釋放n個資源(鎖)

簡單看一眼怎麼用的 (ReentrantLock 的例子):

public class ReentrantLock implements Lock, java.io.Serializable {
    private final Sync sync;
    
    abstract static class Sync extends AbstractQueuedSynchronizer {...}

    public void lock() {
        sync.acquire(1);
    }

    public void unlock() {
        sync.release(1);
    }
}
複製程式碼

可以看到,AQS 封裝了排隊、阻塞、喚醒之類的操作,使得實現一個鎖變的如此簡潔。

3.2.1 acquire(int)

獲取資源

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

protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}
複製程式碼

這個函式很短,其中 tryAcquire(int) 為模板方法,留給子類實現。類似 Activity.onCreate()。

根據 tryAcquire(arg) 的結果,分兩種情況:

  • 返回 true: 該執行緒拿到鎖,由於短路,直接跳出 if,該執行緒可以往下執行自己的業務程式碼。
  • 返回 false: 該執行緒沒有拿到鎖,會繼續走 acquireQueued(),執行排隊等待邏輯。

3.2.1.1 addWaiter(Node)

這一步把當前執行緒(Thread.currentThread())作為一個Node節點,加入同步佇列的尾部,並標記為獨佔模式。

當然,加入佇列這個動作,要保證執行緒安全

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    // 1處嘗試更快地 enq(), 成功的話直接 return。失敗的話, 在2處退化為完整版的 enq(),相對更慢些
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) { // 1
            pred.next = node;
            return node;
        }
    }
    enq(node); // 2
    return node;
}

private Node enq(final Node node) {
    // 神奇的死迴圈 + CAS
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}
複製程式碼

可以看到,這邊有一個死迴圈 + CAS的神奇操作,這是非阻塞演算法的經典操作,可自行查閱相關資料。簡單的說,非阻塞演算法就是在多執行緒的情況下,不加鎖同時保證某個變數(本例中為雙向連結串列)的執行緒安全,而且通常比 synchronized 的效率要高。

3.2.1.2 acquireQueued(Node,int)

這個函式主要做兩件事:

  • 檢視prev的waitStatus,看是不是需要阻塞,需要的話阻塞該執行緒
  • 排在隊首的傢伙呼叫了release(),會喚醒老二。老二嘗試去獲得鎖,成功的話自己變成隊首,跳出迴圈。

結合這張圖來看,每次出隊完需要確保 head 始終指向佔用資源的執行緒:

sync queue

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        // 這又是一個死迴圈 + CAS,這次CAS比較隱蔽,在 shouldParkAfterFailedAcquire()裡邊
        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);
    }
}
複製程式碼

這邊的 interrupted 主要是保證這樣一個功能。執行緒在排隊的時候不響應中斷,直到出來以後,如果等待的過程中被中斷過,作為彌補,立即相應中斷(即呼叫selfInterrupt())。

shouldParkAfterFailedAcquire()

檢視prev的waitStatus,看是不是需要阻塞。可以預見的是,經過幾次死迴圈,全部都會變成SIGNAL狀態。之後全部陷入阻塞。

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus; // 檢視前驅節點的狀態
    if (ws == Node.SIGNAL) // SIGNAL: 可以安全的阻塞
        return true;
    if (ws > 0) { // CANCEL: 取消排隊的節點,直接從佇列中清除。
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else { // 0 or PROPAGATE: 需要變成 SIGNAL,但不能立即阻塞,需要重走外層的死迴圈二次確認。
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}
複製程式碼

值得一提的是,阻塞和喚醒沒有使用常說的 wait()/notify(),而是使用了 LockSupport.park()/unpark()。這應該是出於效率上的考慮。

3.2.2 release(int)

釋放資源

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

private void unparkSuccessor(Node node) {
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);

    /*
     * 喚醒next節點對應的執行緒,通常就是老二(直接後繼)。
     * 如果是null,或者是cancel狀態(出現異常如執行緒遇到空指標掛掉了),
     * 那麼跳過cancel節點,找到後繼節點。
     */
    Node s = node.next;
    if (s == null || s.waitStatus > 0) {
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    // 喚醒 node.next
    if (s != null)
        LockSupport.unpark(s.thread);
}
複製程式碼

釋放的邏輯比較簡單。注意一點,對於 next 節點 unpark(),相當於在把 next 節點從 acquireQueued() 中的死迴圈中解放出來。

回到 ATM 的例子,相當於,他取完錢,輪到後一個人取錢了。這樣邏輯全部都串起來了。

4. 總結

這樣,順著獨佔鎖這條線,AQS 的獨佔模式就分析完了。其他還有用於實現閉鎖的共享模式,用於實現 Condition 的條件佇列就不展開了。

5. 參考

Java併發程式設計實戰(chapter_4)(AQS原始碼分析)

JUC原始碼分析—AQS

自旋鎖、排隊自旋鎖、MCS鎖、CLH鎖

《JAVA併發程式設計實踐》

相關文章