Lock 介面與 AQS 同步器

TimberLiu發表於2019-04-12

Lock 介面

Java5 之前,只能使用 synchronized 關鍵字來實現鎖。它使用起來比較簡單,但是有一些侷限性:

  • 無法中斷一個正在等待獲取鎖的執行緒;
  • 無法在請求獲取一個鎖時等待一段時間。

而在 Java5 中,併發包中增加了 Lock 介面及其實現類,它的功能與 synchronized 類似,需要進行顯示地獲取和釋放鎖,但是卻提供了很多 synchronized 不具有的特性。舉一個例子:

Lock lock = new ReentrantLock();
lock.lock();
try {
    //
} finally {
    lock.unlock();
}
複製程式碼

注意的是獲取鎖的 lock 方法應該寫在 try 塊之外,因為如果寫在 try 塊中,獲取鎖時發生了異常,丟擲異常的同時也會導致鎖無故釋放,而不是等到執行 finally 語句時才釋放鎖。

Lock 介面中,定義了鎖獲取和釋放的基本操作,包括可中斷的獲取鎖、超時獲取鎖等特性:

public interface Lock {

    // 獲取鎖
    void lock();

    // 可中斷地獲取鎖,即獲取鎖時,其他執行緒可以中斷當前執行緒
    void lockInterruptibly() throws InterruptedException;

    // 嘗試獲取鎖,呼叫後會立即返回,能獲取就返回 true,否則返回 false
    boolean tryLock();

    // 在給定時間內可中斷地嘗試獲取鎖
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

    // 釋放鎖
    void unlock();

    // 返回一個繫結到該 Lock 例項上的 Condition
    // 只有當前執行緒持有了鎖,才能呼叫 await 方法,await 方法的呼叫將會自動釋放鎖
    Condition newCondition();
}
複製程式碼

Lock 介面的主要實現就是 ReentrantLock。而 Lock 介面的實現基本都是通過內部實現了一個同步器 AQS 的子類來實現執行緒訪問控制的。

AQS

同步器 AbstractQueuedSynchronizer,是用來構建鎖或其他同步元件的基礎框架。它使用一個 int 成員變數表示同步狀態,通過內建的 FIFO 同步佇列來完成執行緒獲取資源時的排隊等待工作。

在自定義同步元件時,推薦定義一個靜態內部類,使其繼承自同步器 AQS 並實現它的抽象方法來管理同步狀態,在實現抽象方法時,對同步狀態的管理可以使用同步器提供的三個方法。

private volatile int state;

// 獲取當前同步狀態
protected final int getState() {
    return state;
}

// 設定當前同步狀態
protected final void setState(int newState) {
    state = newState;
}

// 使用 CAS 設定當前狀態,保證原子性
protected final boolean compareAndSetState(int expect, int update) {
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
複製程式碼

同步器是實現同步元件的關,它們二者的關係如下:

  • 同步元件是面向使用者的,定義了使用者與同步元件互動的介面,隱藏了實現細節;
  • 同步器面向的是同步元件的實現者,它簡化了同步元件的實現方式。

同步器的介面

同步器是基於模板方法模式的。使用者需要繼承同步器並重寫指定的方法。而可重寫的方法主要有:

方法名 描述
tryAcquire 獨佔式獲取同步狀態
tryRelease 獨佔式釋放同步狀態
tryAcquireShared 共享式獲取同步狀態
tryReleaseShared 共享式釋放同步狀態
isHeldExclusively 判斷同步器是否被執行緒獨佔

隨後將同步器組合到自定義同步元件的實現中,並呼叫同步器提供的模板方法,而這些模板方法會呼叫使用者重寫的方法。

可呼叫的模板方法主要有三類:獨佔式獲取與釋放同步狀態、共享式獲取與釋放狀態、以及查詢同步佇列中的等待執行緒情況。下文會介紹它們,並簡單分析其實現原理。

同步佇列

同步器內部使用一個 FIFO 同步佇列來管理同步狀態,線上程獲取同步狀態失敗時,同步器會將當前執行緒與等待狀態等資訊構造成一個節點,將其加入到同步佇列中,同時會阻塞當前執行緒。當釋放同步狀態時,則會喚醒佇列中首節點的執行緒,使其再次嘗試獲取同步狀態。

同步佇列中的節點的主要屬性有:

static final class Node {
    // 等待狀態
    volatile int waitStatus;

    // 前驅節點,在入隊時被賦值
    volatile Node prev;

    // 後繼節點,
    volatile Node next;

    // 加入節點的執行緒,該執行緒獲取到同步狀態
    volatile Thread thread;
}
複製程式碼

等待狀態 waitStatus 的取值主要有:

// 同步佇列中等待的執行緒等待超時或被中斷,需要取消等待,之後節點的狀態將不會再改變
static final int CANCELLED =  1;

// 後繼節點的執行緒處於等待狀態
// 當前節點的執行緒釋放或取消同步狀態時,會喚醒它的後繼節點
static final int SIGNAL    = -1;
    
// 節點目前在等待佇列中
// 當節點被喚醒時,從等待佇列轉移到同步佇列中,嘗試獲取同步狀態
static final int CONDITION = -2;

// 共享式同步狀態被傳播給其他節點
static final int PROPAGATE = -3;

//初始化 waitStatus 值為 0
複製程式碼

同步器中包含兩個引用,分別指向同步佇列的首節點和尾節點:

// 頭節點,惰性初始化
private transient volatile Node head;

// 尾節點,惰性初始化
private transient volatile Node tail;
複製程式碼

當執行緒無法獲取同步狀態,會將該執行緒構造成一個節點加入同步佇列中,使用 addWaiter 方法:

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    // 快速嘗試
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}
複製程式碼

如果快速嘗試新增尾節點失敗,則呼叫 enq 方法通過死迴圈來保證節點的正確新增:

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) { // 如果未初始化,則會先初始化,再繼續嘗試
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}
複製程式碼

而這個過程可能會有多個執行緒同時執行,所以必須要保證執行緒安全,提供了基於 CAS 的設定尾節點的方法:

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

同步佇列中,首節點是獲取同步狀態成功的節點,執行緒在釋放同步狀態時,會喚醒後繼節點,後繼節點成功獲取同步狀態時將自己設定為首節點,由於只有一個執行緒能獲取到同步狀態,所以設定頭節點的方法不需要 CAS 方法保證:

private void setHead(Node node) {
    head = node;
    node.thread = null;
    node.prev = null;
}
複製程式碼

獨佔式獲取與釋放

獨佔式獲取與釋放同步狀態主要有四個模板方法,分別是:

方法名 描述
void acquire(int arg) 獨佔式獲取同步狀態
void acquireInterruptibly(int arg) 可響應中斷的獨佔式獲取同步狀態
boolean tryAcquireNanos(int arg, long nanos) 可響應中斷的獨佔式超時獲取同步狀態
boolean release(int arg) 獨佔式釋放同步狀態

獨佔式獲取

acquire 方法可以獲取同步狀態,該方法為獨佔式獲取,不可中斷,也就是如果執行緒獲取同步狀態失敗,加入到同步佇列中,後續對執行緒進行中斷操作,執行緒並不會被移除。

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

acquire 方法中,首先呼叫 tryAcquire 方法嘗試獲取同步狀態,該方法由自定義元件自己實現。如果獲取失敗,呼叫 addWaiter 方法將當前執行緒加入到同步佇列末尾。最後呼叫 acquiredQueued 方法通過死迴圈的方式來獲取同步狀態:

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);
    }
}
複製程式碼

該方法中,通過死迴圈的方式來獲取同步狀態,並且只有前驅節點是頭節點時,才能夠嘗試獲取同步狀態,這樣做就是為了保持 FIFO 同步佇列原則,即先加入到同步佇列中的執行緒先嚐試獲取同步狀態。

另外,在自旋時首先會呼叫 shouldParkAfterFailedAcquire 方法判斷是否應該被阻塞:

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        // 前驅節點狀態為 SIGNAL ,則當前節點可以被阻塞
        return true;
    if (ws > 0) {
        // 前驅節點處於取消狀態,也就是超時或被中斷,需要從同步佇列中刪除
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // 否則,將當前節點設定為 SIGNAL,不會阻塞
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}
複製程式碼

該方法主要是根據前驅節點的 waitStatus 來判斷當前節點的執行緒,如果當前節點應該被阻塞,則會呼叫 parkAndCheckInterrupt 方法阻塞:

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

該方法呼叫 LockSupport.park() 方法阻塞當前執行緒,並返回當前執行緒的中斷狀態。

可中斷式獲取

acquireInterruptibly 方法以可響應中斷的方式獲取同步狀態,其中呼叫 tryAcquire 方法失敗後,會呼叫 doAcquireInterruptibly 方法自旋式獲取。

public final void acquireInterruptibly(int arg)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    if (!tryAcquire(arg))
        doAcquireInterruptibly(arg);
}
複製程式碼

doAcquireInterruptibly 方法與普通地獨佔式獲取同步狀態非常類似,只是不再使用 interrupt 標誌,而是直接丟擲 InterruptedException 異常。

超時可中斷式獲取

tryAcquireNanos 方法可以超時獲取同步狀態,即在指定時間內可中斷地獲取同步狀態。

public final boolean tryAcquireNanos(int arg, long nanosTimeout)
            throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    return tryAcquire(arg) ||
        doAcquireNanos(arg, nanosTimeout);
}
複製程式碼

該方法首先呼叫 tryAcquire 方法嘗試獲取同步狀態,如果獲取失敗,則會呼叫 doAcquireNanos 方法:

private boolean doAcquireNanos(int arg, long nanosTimeout)
            throws InterruptedException {
    if (nanosTimeout <= 0L)
        return false;
    // 計算總的超時時間
    final long deadline = System.nanoTime() + nanosTimeout;
    final Node node = addWaiter(Node.EXCLUSIVE);
    boolean failed = true;
    try {
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return true;
            }
            // 剩餘的超時時間
            nanosTimeout = deadline - System.nanoTime();
            if (nanosTimeout <= 0L)
                return false;
            if (shouldParkAfterFailedAcquire(p, node) &&
                nanosTimeout > spinForTimeoutThreshold)
                // 如果超時時間大於 臨界值,則會阻塞執行緒,否則快速自旋
                LockSupport.parkNanos(this, nanosTimeout);
            if (Thread.interrupted())
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}
複製程式碼

該方法中,首先計算出超時的最終時間,然後將當前節點加入到同步佇列中。

然後自旋進行判斷,如果當前節點為頭節點,則會呼叫 tryAcquire 方法嘗試獲取同步狀態;否則重新計算超時時間,如果 nanosTimeout 小於 0,則獲取失敗。否則繼續判斷超時時間是否大於 spinForTimeoutThreshold 臨界值,如果大於表示時間較長,呼叫 LockSupport.parkNanos 使執行緒阻塞。

如果時間較短,則直接進入自旋過程,繼續判斷。另外,還會判斷執行緒是否被中斷。

獨佔式釋放

release 方法用來釋放同步狀態,該方法釋放了同步狀態後,會喚醒後繼節點,使其重新嘗試獲取同步狀態。

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

該方法中,首先呼叫 tryRelease 方法嘗試釋放同步狀態,該方法由自定義同步元件自己實現。然後呼叫 unparkSuccessor 方法來喚醒後繼節點:

private void unparkSuccessor(Node node) {

    int ws = node.waitStatus;
    if (ws < 0) // 節點狀態設定為 0
        compareAndSetWaitStatus(node, ws, 0);

    Node s = node.next;
    // 如果後繼節點超時或者被中斷
    if (s == null || s.waitStatus > 0) {
        s = null;
        // 從 tail 向前,找最靠近 head 的可用節點
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    if (s != null)
        LockSupport.unpark(s.thread);
}
複製程式碼

該方法首先找到一個可用的 waitStatus 值大於 0 的節點,然後呼叫 LockSupport.unpark 方法喚醒該執行緒。

共享式獲取與釋放

共享式與獨佔式最大的區別就是同一時刻有多個執行緒同時獲取到同步狀態。

共享式獲取與釋放同步狀態主要有四個模板方法,分別是:

方法名 描述
acquireShared(int arg) 共享式獲取同步狀態
acquireSharedInterruptibly(int arg) 可響應中斷的共享式獲取同步狀態
tryAcquireSharedNanos(int arg, long anos) 可響應中斷的共享式超時獲取同步狀態
releaseShared(int arg) 共享式釋放同步狀態

共享式獲取

acquireShared 方法可以共享式地獲取同步狀態:

public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}
複製程式碼

該方法中,首先呼叫 tryAcquireShared 方法嘗試獲取同步狀態,如果返回值大於等於 0,則表示獲取成功。否則獲取失敗,則會呼叫 doAcquireShared 方法:

private void doAcquireShared(int arg) {
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            // 獲取前驅節點
            final Node p = node.predecessor();
            if (p == head) {
                int r = tryAcquireShared(arg);
                if (r >= 0) {
                    // 大於等於 0,表示獲取成功
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    if (interrupted)
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}
複製程式碼

首先以共享節點加入到等待佇列中,然後以死迴圈的方式進行判斷,如果當前節點的前驅節點為頭節點,則呼叫 doAcquireShared 方法嘗試獲取同步狀態,直到其返回值大於等於 0

可響應中斷、超時獲取的共享式獲取同步狀態與之前類似,這裡也就不多介紹。

共享式釋放

releaseShared 方法用於共享式釋放同步狀態,

public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        doReleaseShared();
        return true;
    }
    return false;
}
複製程式碼

該方法首先呼叫 tryReleaseShared 嘗試釋放同步狀態,如果釋放失敗,則會呼叫 doReleaseShared 方法;

private void doReleaseShared() {
    for (;;) {
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                unparkSuccessor(h);
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        if (h == head)                   // loop if head changed
            break;
    }
}
複製程式碼

該方法中在釋放同步狀態時,由於有多個執行緒,需要保證執行緒安全。首先,如果後繼節點的執行緒需要喚醒,則將當前節點的狀態設定為 0,然後呼叫 unparkSuccessor 方法喚醒後繼節點。

參考資料

相關文章