- 從 acquire 方法開始 —— 獲取
- 為什麼 AQS 需要一個虛擬 head 節點
- reelase 方法如何釋放鎖
- 總結
前言
AQS 是 JUC 中的核心,其中封裝了資源的獲取和釋放,在我們之前的 併發程式設計之 AQS 原始碼剖析 文章中,我們已經從 ReentranLock 那裡分析了鎖的獲取和釋放。但我有必要再次解釋 AQS 的核心 CLH 鎖。
這裡引用一下別人對於 CLH 的解釋:
CLH CLH(Craig, Landin, and Hagersten locks): 是一個自旋鎖,能確保無飢餓性,提供先來先服務的公平性。
CLH鎖也是一種基於連結串列的可擴充套件、高效能、公平的自旋鎖,申請執行緒只在本地變數上自旋,它不斷輪詢前驅的狀態,如果發現前驅釋放了鎖就結束自旋。
Java AQS 的設計對 CLH 鎖進行了優化或者說變體。
我們還是從程式碼開始說起吧。
1. 從 acquire 方法開始 —— 獲取
acquire 方法是獲取鎖的常用方法。程式碼如下:
public final void acquireQueued(int arg) {
// 當 tryAcquire 返回 true 就說明獲取到鎖了,直接結束。
// 反之,返回 false 的話,就需要執行後面的方法。
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
複製程式碼
只要子類的 tryAcquire 方法返回 false,那麼就說明獲取鎖事變,就需要將自己加入佇列。
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;
// 如果 tail 節點不是 null,就將新節點的 pred 節點設定為 tail 節點。
// 並且將新節點設定成 tail 節點。
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 如果 tail 節點是 null,或者 CAS 設定 tail 失敗。
// 在 enq 方法中處理
enq(node);
return node;
}
複製程式碼
將自己加入了尾部,並更新了 tail 節點。
private Node enq(final Node node) {
for (;;) {
Node t = tail;
// 如果 tail 是 null,就建立一個虛擬節點,同時指向 head 和 tail,稱為 初始化。
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {// 如果不是 null
// 和 上個方法邏輯一樣,將新節點追加到 tail 節點後面,並更新佇列的 tail 為新節點。
// 只不過這裡是死迴圈的,失敗了還可以再來 。
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
複製程式碼
enq 方法的邏輯是什麼呢?當 tail 是 null(沒有初始化佇列),就需要初始化佇列了。CAS 設定 tail 失敗,也會走這裡,需要在 enq 方法中迴圈設定 tail。直到成功。
注意:這裡會建立一個虛擬節點。
2. 為什麼 AQS 需要一個虛擬 head 節點
為什麼要建立一個虛擬節點呢?
事情要從 Node 類的 waitStatus 變數說起,簡稱 ws。每個節點都有一個 ws 變數,用於這個節點狀態的一些標誌。初始狀態是 0。如果被取消了,節點就是 1,那麼他就會被 AQS 清理。
還有一個重要的狀態:SIGNAL —— -1,表示:噹噹前節點釋放鎖的時候,需要喚醒下一個節點。
所有,每個節點在休眠前,都需要將前置節點的 ws 設定成 SIGNAL。否則自己永遠無法被喚醒。
而為什麼需要這麼一個 ws 呢?—— 防止重複操作。假設,當一個節點已經被釋放了,而此時另一個執行緒不知道,再次釋放。這時候就錯誤了。
所以,需要一個變數來保證這個節點的狀態。而且修改這個節點,必須通過 CAS 操作保證執行緒安全。
So,回到我們之前的問題:為什麼要建立一個虛擬節點呢?
每個節點都必須設定前置節點的 ws 狀態為 SIGNAL,所以必須要一個前置節點,而這個前置節點,實際上就是當前持有鎖的節點。
問題在於有個邊界問題:**第一個節點怎麼辦?**他是沒有前置節點的。
那就建立一個假的。
這就是為什麼要建立一個虛擬節點的原因。
總結下來就是:每個節點都需要設定前置節點的 ws 狀態(這個狀態為是為了保證資料一致性),而第一個節點是沒有前置節點的,所以需要建立一個虛擬節點。
回到我們的 acquireQueued 方法證實一下:
// 這裡返回的節點是新建立的節點,arg 是請求的數量
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
// 找上一個節點
final Node p = node.predecessor();
// 如果上一個節點是 head ,就嘗試獲取鎖
// 如果 獲取成功,就將當前節點設定為 head,注意 head 節點是永遠不會喚醒的。
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 在獲取鎖失敗後,就需要阻塞了。
// shouldParkAfterFailedAcquire ---> 檢查上一個節點的狀態,如果是 SIGNAL 就阻塞,否則就改成 SIGNAL。
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
複製程式碼
這個方法有 2 個邏輯:
- 如何將自己掛起?
- 被喚醒之後做什麼?
先回答第二個問題: 被喚醒之後做什麼?
嘗試拿鎖,成功之後,將自己設定為 head,斷開和 next 的連線。
再看第二個問題:如何將自己掛起?
注意:掛起自己之前,需要將前置節點的 ws 狀態設定成 SIGNAL,告訴他:你釋放鎖的時候記得喚醒我。
具體邏輯在 shouldParkAfterFailedAcquire 方法中:
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
// 如果他的上一個節點的 ws 是 SIGNAL,他就需要阻塞。
if (ws == Node.SIGNAL)
// 阻塞
return true;
// 前任被取消。 跳過前任並重試。
if (ws > 0) {
do {
// 將前任的前任 賦值給 當前的前任
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
// 將前任的前任的 next 賦值為 當前節點
pred.next = node;
} else {
// 如果沒有取消 || 0 || CONDITION || PROPAGATE,那麼就將前任的 ws 設定成 SIGNAL.
// 為什麼必須是 SIGNAL 呢?
// 答:希望自己的上一個節點在釋放鎖的時候,通知自己(讓自己獲取鎖)
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
// 重來
return false;
}
複製程式碼
該方法的主要邏輯就是將前置節點的狀態修改成 SIGNAL。其中如果前置節點被取消了,就跳過他。
那麼肯定,在前置節點釋放鎖的時候,肯定會喚醒這個節點。看看釋放的邏輯吧。
3. reelase 方法如何釋放鎖
先來一波程式碼:
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
// 所有的節點在將自己掛起之前,都會將前置節點設定成 SIGNAL,希望前置節點釋放的時候,喚醒自己。
// 如果前置節點是 0 ,說明前置節點已經釋放過了。不能重複釋放了,後面將會看到釋放後會將 ws 修改成0.
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
複製程式碼
從這個方法的判斷就可以看出,head 必須不等於 0。為什麼呢?當一個節點嘗試掛起自己之前,都會將前置節點設定成 SIGNAL -1,就算是第一個加入佇列的節點,在獲取鎖失敗後,也會將虛擬節點設定的 ws 設定成 SIGNAL。
而這個判斷也是防止多執行緒重複釋放。
那麼肯定,在釋放鎖之後,肯定會將 ws 狀態設定成 0。防止重複操作。
程式碼如下:
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
// 將 head 節點的 ws 改成 0,清除訊號。表示,他已經釋放過了。不能重複釋放。
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
// 如果 next 是 null,或者 next 被取消了。就從 tail 開始向上找節點。
if (s == null || s.waitStatus > 0) {
s = null;
// 從尾部開始,向前尋找未被取消的節點,直到這個節點是 null,或者是 head。
// 也就是說,如果 head 的 next 是 null,那麼就從尾部開始尋找,直到不是 null 為止,找到這個 head 就不管了。
// 如果是 head 的 next 不是 null,但是被取消了,那這個節點也會被略過。
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
// 喚醒 head.next 這個節點。
// 通常這個節點是 head 的 next。
// 但如果 head.next 被取消了,就會從尾部開始找。
if (s != null)
LockSupport.unpark(s.thread);
}
複製程式碼
如果 ws 小於 0,我們假設是 SIGNAL,就修改成 0. 證實了我們的想法。
如果他的 next 是 null,說明 next 取消了,那麼就從尾部開始向上尋找(不從尾部也沒辦法)。當然找的過程中,也跳過了失效的節點。
最後,喚醒他。
喚醒之後的邏輯是什麼樣子的還記得嗎?
複習一下:拿鎖,設定自己為 head,斷開前任 head 和自己的連線。
4. 總結
AQS 使用的 CLH 鎖,需要一個虛擬 head 節點,這個節點的作用是防止重複釋放鎖。當第一個進入佇列的節點沒有前置節點的時候,就會建立一個虛擬的。
來一幅圖嘗試解釋 AQS 吧:
-
新增節點時
-
更新 tail
- 喚醒節點時,之前的 head 取消了