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鎖
更多細節可以參考篇文章:
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;
}
}
複製程式碼
這個佇列大體上長這樣:圖片來源
條件佇列是為了支援 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 始終指向佔用資源的執行緒:
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原始碼分析)
《JAVA併發程式設計實踐》