為了帶你精通 Java AQS,我畫了 40 張圖,從管程模型講起!
大家好,我是君哥。
Java中 AQS 是 AbstractQueuedSynchronizer 類,AQS 依賴 FIFO 佇列來提供一個框架,這個框架用於實現鎖以及鎖相關的同步器,比如訊號量、事件等。
在 AQS 中,主要有兩部分功能,一部分是操作 state 變數,第二部分是實現排隊和阻塞機制。
注意,AQS 並沒有實現任何同步介面,它只是提供了類似 acquireInterruptible 的方法,呼叫這些方法可以實現鎖和同步器。
1 管程模型
Java 使用 MESA 管程模型來管理類的成員變數和方法,讓這個類的成員變數和方法的操作是執行緒安全的。下圖是 MESA 管程模型,裡面除了定義共享變數外,還定義了條件變數和條件變數等待佇列:
上圖中有三個知識點:
MESA 管程模型封裝了共享變數和對共享變數的操作,執行緒要進入管程內部,必須獲取到鎖,如果獲取鎖失敗就進入入口等待佇列阻塞等待。 如果執行緒獲取到鎖,就進入到管程內部。但是進入到管程內部,也不一定能立刻操作共享變數,而是要看條件變數是否滿足,如果不滿足,只能進入條件變數等待佇列阻塞等待。 在條件變數等待佇列中,如果被其他執行緒喚醒,也不一定能立刻操作共享變數,而是需要去入口等待佇列重新排隊等待獲取鎖。
Java 中的 MESA 管程模型有一點改進,就是管程內部只有一個條件變數和一個等待佇列。下圖是 AQS 的管程模型:
AQS 的管程模型依賴 AQS 中的 FIFO 佇列實現入口等待佇列,要進入管程內部,就由各種併發鎖的限制。而 ConditionObject 則實現了條件佇列,這個佇列可以建立多個。
下面就從入口等待佇列、併發鎖、條件等待佇列三個方面來帶你徹底理解 AQS。
2 入口等待佇列
2.1 獲取獨佔鎖
獨佔, 忽略 interrupts
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
這裡的 tryAcquire 是抽象方法,由 AQS 的子類來實現,因為每個子類實現的鎖是不一樣的。
2.1.1 入隊
上面的程式碼可以看到,獲取鎖失敗後,會先執行 addWaiter 方法加入佇列,然後執行 acquireQueued 方法自旋地獲取鎖直到成功。
addWaiter 程式碼邏輯如下圖,簡單說就是把 node 入隊,入隊後返回 node 引數給 acquireQueued 方法:
這裡有一個點需要注意,如果佇列為空,則新建一個 Node 作為隊頭。
2.1.2 入隊後獲取鎖
acquireQueued 自旋獲取鎖邏輯如下圖:
這裡有幾個細節:
1.waitStatus
CANCELLED(1):當前節點取消獲取鎖。當等待超時或被中斷(響應中斷),會觸發變更為此狀態,進入該狀態後節點狀態不再變化; SIGNAL(-1):後面節點等待當前節點喚醒; CONDITION(-2):Condition 中使用,當前執行緒阻塞在 Condition,如果其他執行緒呼叫了 Condition 的 signal 方法,這個結點將從等待佇列轉移到同步佇列隊尾,等待獲取同步鎖; PROPAGATE(-3):共享模式,前置節點喚醒後面節點後,喚醒操作無條件傳播下去; 0:中間狀態,當前節點後面的節點已經喚醒,但是當前節點執行緒還沒有執行完成。
2.獲取鎖失敗後掛起
如果前置節點不是頭節點,或者前置節點是頭節點但當前節點獲取鎖失敗,這時當前節點需要掛起,分三種情況:
前置節點 waitStatus=-1,如下圖:
前置節點 waitStatus > 0,如下圖:
前置節點 waitStatus < 0 但不等於 -1,如下圖:
3.取消獲取鎖
如果獲取鎖丟擲異常,則取消獲取鎖,如果當前節點是 tail 節點,分兩種情況如下圖:
如果當前節點不是 tail 節點,也分兩種情況,如下圖:
4.對中斷狀態忽略
5.如果前置節點的狀態是 0 或 PROPAGATE,會被當前節點自旋過程中更新成 -1,以便之後通知當前節點。
2.1.3 獨佔 + 響應中斷
對應方法 acquireInterruptibly(int arg)。
跟忽略中斷(acquire方法)不同的是要響應中斷,下面兩個地方響應中斷:
獲取鎖之前會檢查當前執行緒是否中斷。 獲取鎖失敗入隊,在佇列中自旋獲取鎖的過程中也會檢查當前執行緒是否中斷。如果檢查到當前執行緒已經中斷,則丟擲 InterruptedException,當前執行緒退出。
2.1.4 獨佔 + 響應中斷 + 考慮超時
對應方法 tryAcquireNanos(int arg, long nanosTimeout)。
這個方法具備了獨佔 + 響應中斷 + 超時的功能,下面2個地方要判斷是否超時:
自旋獲取鎖的過程中每次獲取鎖失敗都要判斷是否超時; 獲取鎖失敗 park 之前要判斷超時時間是否大於自旋的閾值時間 (spinForTimeoutThreshold = 1ns) 另外,park 執行緒的操作使用 parkNanos 傳入阻塞時間。
2.2 釋放獨佔鎖
獨佔鎖釋放分兩步:釋放鎖,喚醒後繼節點。
釋放鎖的方法 tryRelease 是抽象的,由子類去實現。
我們看一下喚醒後繼節點的邏輯,首先需要滿足兩個條件:
head 節點不等於 null; head 節點 waitStatus 不等於 0。這裡有兩種情況(在方法 unparkSuccessor):
情況一,後繼節點 waitStatus <= 0,直接喚醒後繼節點,如下圖:
情況二:後繼節點為空或者 waitStatus > 0,從後往前查詢最接近當前節點的節點進行喚醒,如下圖:
2.3 獲取共享鎖
之前我們講了獨佔鎖,這一小節我們談共享鎖,有什麼不同呢?
2.3.1 共享,忽略 interrupts
對應方法 acquireShared,程式碼如下:
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
2.3.2 tryAcquireShared
這裡獲取鎖使用的方法是 tryAcquireShared,獲取的是共享鎖。獲取共享鎖跟獲取獨佔鎖不同的是,會返回一個整數值,說明如下:
返回負數:獲取鎖失敗。 返回 0:獲取鎖成功但是之後再由執行緒來獲取共享鎖時就會失敗。 返回正數:獲取鎖成功而且之後再有執行緒來獲取共享鎖時也可能會成功。所以需要把喚醒操作傳播下去。tryAcquireShared 獲取鎖失敗後(返回負數),就需要入隊後自旋獲取,也就是執行方法 doAcquireShared。
2.3.3 doAcquireShared
怎麼判斷佇列中等待節點是在等待共享鎖呢?nextWaiter == SHARED,這個引數值是入隊新建節點的時候建構函式傳入的。
自旋過程中,如果獲取鎖成功(返回正數),首先把自己設定成新的 head 節點,然後把通知傳播下去。如下圖:
之後會喚醒後面節點並保證喚醒操作可以傳播下去。但是需要滿足四個條件中的一個:
tryAcquireShared 返回值大於0,有多餘的鎖,可以繼續喚醒後繼節點。 舊的 head 節點 waitStatus < 0,應該是其他執行緒釋放共享鎖過程中把它的狀態更新成了 -3。 新的 hade 節點 waitStatus < 0,只要不是 tail 節點,就可能是 -1。這裡會造成不必要的喚醒,因為喚醒後獲取不到鎖只能繼續入隊等待。 當前節點的後繼節點是空或者非空但正在等待共享鎖。
喚醒後面節點的操作,其實就是釋放共享鎖,對應方法是 doReleaseShared,見釋放共享鎖一節。
2.3.4 共享 + 響應中斷
對應方法 acquireSharedInterruptibly(int arg)。
跟共享忽略中斷(acquireShared 方法)不同的是要響應中斷,下面兩個地方響應中斷:
獲取鎖之前會檢查當前執行緒是否中斷。 獲取鎖失敗入隊,在佇列中自旋獲取鎖的過程中也會檢查當前執行緒是否中斷。
如果檢查到當前執行緒已經中斷,則丟擲 InterruptedException,當前執行緒退出。
2.3.5 共享 + 響應中斷 + 考慮超時
對應方法 tryAcquireSharedNanos(int arg, long nanosTimeout)。
這個方法具備了共享 + 響應中斷 + 超時的功能,下面兩個個地方要判斷是否超時:
自旋獲取鎖的過程中每次獲取鎖失敗都要判斷是否超時。 獲取鎖失敗 park 之前要判斷超時時間是否大於自旋的閾值時間(spinForTimeoutThreshold = 1ns)。
另外,park 執行緒的操作使用 parkNanos 傳入阻塞時間。
2.4 釋放共享鎖
釋放共享鎖程式碼如下:
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
首先嚐試釋放共享鎖,tryReleaseShared 程式碼由子類來實現。釋放成功後執行AQS中的 doReleaseShared 方法,是一個自旋操作。
自旋的條件是佇列中至少有兩個節點,這裡分三種情況。
情況一:當前節點 waitStatus 是 -1,如下圖:
情況二:當前節點 waitStatus 是 0(被其他執行緒更新新成了中間狀態),如下圖:
情況三:當前節點 waitStatus 是 -3,為什麼會這樣呢?需要解釋一下,head節點喚醒後繼節點之前 waitStatus 已經被更新中間態 0 了,喚醒後繼節點動作還沒有執行,又被其他執行緒更成了 -3,也就是其他執行緒釋放鎖執行了上面情況二。這時需要先把 waitStatus 再更成 0 (在方法 unparkSuccessor),如下圖:
2.5 抽象方法
上面的講解可以看出,如果要基於 AQS 來實現併發鎖,可以根據需求重寫下面四個方法來實現,這四個方法在 AQS 中沒有具體實現:
tryAcquire(int arg):獲取獨佔鎖 tryRelease(int arg):釋放獨佔鎖 tryAcquireShared(int arg):獲取共享鎖 tryReleaseShared(int arg):釋放共享鎖
AQS 的子類需要重寫上面的方法來修改 state 值,並且定義獲取鎖或者釋放鎖時 state 值的變化。子類也可以定義自己的 state 變數,但是隻有更新 AQS 中的 state變數才會對同步起作用。
還有一個判斷當前執行緒是否持有獨佔鎖的方法 isHeldExclusively,也可以供子類重寫後使用。
獲取/釋放鎖的具體實現放到下篇文章講解。
2.6 總結
AQS 使用 FIFO 佇列實現了一個鎖相關的併發器模板,可以基於這個模板來實現各種鎖,包括獨佔鎖、共享鎖、訊號量等。
AQS 中,有一個核心狀態是 waitStatus,這個代表節點的狀態,決定了當前節點的後續操作,比如是否等待喚醒,是否要喚醒後繼節點。
3 併發鎖
這一章節講解 Java AQS 中的併發鎖。其實 Java AQS 中的併發鎖主要是基於 state 這個變數值來實現的。
3.1 ReentrantLock
我們先來看一下 UML 類圖:
從圖中可以看到,ReentrantLock 使用抽象內部類 Sync 來實現了 AQS 的方法,然後基於 Sync 這個同步器實現了公平鎖和非公平鎖。主要實現了下面 3 個方法:
tryAcquire(int arg):獲取獨佔鎖 tryRelease(int arg):釋放獨佔鎖 isHeldExclusively:當前執行緒是否佔有獨佔鎖。ReentrantLock 預設實現的是非公平鎖,可以在建構函式指定。
從實現的方法可以看到,ReentrantLock 中獲取的鎖是獨佔鎖,我們再來看一下獲取和釋放獨佔鎖的程式碼:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
獨佔鎖的特點是呼叫上面 acquire 方法,傳入的引數是 1。
3.1.1 獲取公平鎖
獲取鎖首先判斷同步狀態(state)的值。
3.1.1.1 state 等於 0
這說明沒有執行緒佔用鎖,當前執行緒如果符合下面兩個條件,就可以獲取到鎖:
沒有前任節點,如下圖:
CAS 的方式更新 state 值(把 0 更新成 1)成功。如果獲取獨佔鎖成功,會更新 AQS 中 exclusiveOwnerThread 為當前執行緒,這個很容易理解。
3.1.1.2 state 不等於 0
這說明已經有執行緒佔有鎖,判斷佔有鎖的執行緒是不是當前執行緒,如下圖:
state += 1 值如果小於 0,會丟擲異常。
如果獲取鎖失敗,則進入 AQS 佇列等待喚醒。
3.1.2 獲取非公平鎖
跟公平鎖相比,非公平鎖的唯一不同是如果判斷到 state 等於 0,不用判斷有沒有前任節點,只要 CAS 設定 state 值(把 0 更新成 1)成功,就獲取到了鎖。
3.1.3 釋放鎖
公平鎖和非公平鎖,釋放邏輯完全一樣,都是在內部類 Sync 中實現的。釋放鎖需要注意兩點,如下圖:
為什麼 state 會大於 1,因為是可以重入的,佔有鎖的執行緒可以多次獲取鎖。
3.1.4 總結
公平鎖的特點是每個執行緒都要進行排隊,不用擔心執行緒永遠獲取不到鎖,但有個缺點是每個執行緒入隊後都需要阻塞和被喚醒,這一定程度上影響了效率。非公平鎖的特點是每個執行緒入隊前都會先嚐試獲取鎖,如果獲取成功就不會入隊了,這比公平鎖效率高。但也有一個缺點,佇列中的執行緒有可能等待很長時間,高併發下甚至可能永遠獲取不到鎖。
3.2 ReentrantReadWriteLock
我們先來看一下 UML 類圖:
從圖中可以看到,ReentrantReadWriteLock 使用抽象內部類Sync來實現了 AQS 的方法,然後基於 Sync 這個同步器實現了公平鎖和非公平鎖。主要實現了下面 3 個方法:
tryAcquire(int arg):獲取獨佔鎖 tryRelease(int arg):釋放獨佔鎖 tryAcquireShared(int arg):獲取共享鎖 tryReleaseShared(int arg):釋放共享鎖 isHeldExclusively:當前執行緒是否佔有獨佔鎖 可見ReentrantReadWriteLock裡面同時用到了共享鎖和獨佔鎖。
下圖是定義的幾個常用變數:
下面這 2 個方法使用者獲取共享鎖和獨佔鎖的數量:
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
從sharedCount 可以看到,共享鎖的數量要右移 16 位獲取,也就是說共享鎖佔了高 16 位。從上圖 EXCLUSIVE_MASK 的定義看到,跟 EXCLUSIVE_MASK 進行與運算,得到的是低 16 位的值,所以獨佔鎖佔了低 16 位。如下圖:
這樣上面獲取鎖數量的方法就很好理解了。
3.2.1 讀鎖
讀鎖的實現對應內部類 ReadLock。
3.2.1.1 獲取讀鎖
獲取讀鎖實際上是 ReadLock 呼叫了 AQS 的下面方法,傳入引數是 1:
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
ReentrantReadWriteLock 內部類 Sync 實現了 tryAcquireShared 方法,主要包括如下三種情況:
使用 exclusiveCount 方法檢視 state 中是否有獨佔鎖,如果有並且獨佔執行緒不是當前執行緒,返回 -1,獲取失敗; 使用 sharedCount 檢視 state 中共享鎖數量,如果讀鎖數量小於最大值(MAX_COUNT=65535),則再滿足下面 3 個條件就可以獲取成功並返回 1:
a.當前執行緒不需要阻塞(readerShouldBlock)。在公平鎖中,需要判斷是否有前置節點,如下圖就需要阻塞:
在非公平鎖中,則是判斷第一個節點是不是有獨佔鎖,如下圖就需要阻塞:
b.使用 CAS 把 state 的值加 SHARED_UNIT(65536)。這裡是不是就更理解讀鎖佔高位的說法了,獲取一個讀鎖,state 的值就要加 SHARED_UNIT 這麼多個。
c.給當前執行緒的 holdCount 加 1。
如果 2 失敗,自旋,重複上面的步驟直到獲取到鎖。tryAcquireShared (獲取共享鎖)會返回一個整數,如下:
返回負數:獲取鎖失敗。 返回 0:獲取鎖成功但是之後再由執行緒來獲取共享鎖時就會失敗。 返回正數:獲取鎖成功而且之後再有執行緒來獲取共享鎖時也可能會成功。
3.2.1.2 釋放讀鎖
ReentrantReadWriteLock 釋放讀鎖是在 ReadLock 中呼叫了 AQS 下面方法,傳入的引數是1:
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
ReentrantReadWriteLock 內部類 Sync 實現了 releaseShared 方法,具體邏輯分為下面兩步:
當前執行緒 holdCounter 值減 1。 CAS的方式將 state 的值減去 SHARED_UNIT。
3.2.2 寫鎖
寫鎖的實現對應內部類 WriteLock。
3.2.2.1 獲取寫鎖
ReentrantReadWriteLock 獲取寫鎖其實是在 WriteLock 中呼叫了 AQS 的下面方法,傳入引數 1:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
在ReentrantReadWriteLock 內部類 Sync 實現了 tryAcquire 方法,首先獲取 state 值和獨佔鎖數量(exclusiveCount),之後分如下兩種情況,如下圖:
1.state 不等於 0:
獨佔鎖數量等於 0,這時說明有執行緒佔用了共享鎖,如果當前執行緒不是獨佔執行緒,獲取鎖失敗。 獨佔鎖數量不等於 0,獨佔鎖數量加 1 後大於 MAX_COUNT,獲取鎖失敗。 上面 2 種情況不符合,獲取鎖成功,state 值加 1。2.state 等於 0,判斷當前執行緒是否需要阻塞(writerShouldBlock)。在公平鎖中,跟 readerShouldBlock 的邏輯完全一樣,就是判斷佇列中 head 節點的後繼節點是不是當前執行緒。在非公平鎖中,直接返回 false,即可以直接嘗試獲取鎖。
如果當前執行緒不需要阻塞,並且給 state 賦值成功,使用 CAS 方式把 state 值加 1,把獨佔執行緒置為當前執行緒。
3.2.2.2 釋放寫鎖
ReentrantReadWriteLock 釋放寫鎖其實是在 WriteLock 中呼叫了 AQS 的下面方法,傳入引數 1:
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
ReentrantReadWriteLock 在 Sync 中實現了 tryRelease(arg) 方法,邏輯如下:
判斷當前執行緒是不是獨佔執行緒,如果不是,丟擲異常。 state值減1後,用新state值判斷獨佔鎖數量是否等於0
如果等於0,則把獨佔執行緒置為空,返回true,這樣上面的程式碼就可以喚醒佇列中的後置節點了 如果不等於0,返回false,不喚醒後繼節點。
3.3 CountDownLatch
我們先來看一下UML類圖:
從上面的圖中看出,CountDownLatch 的內部類 Sync 實現了獲取共享鎖和釋放共享鎖的邏輯。
使用 CountDownLatch 時,建構函式會傳入一個 int 型別的引數 count,表示調動 count 次的 countDown 後主執行緒才可以被喚醒。
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
this.sync = new Sync(count);
}
上面的 Sync(count) 就是將 AQS 中的 state 賦值為 count。
3.3.1 await
CountDownLatch 的 await 方法呼叫了 AQS 中的 acquireSharedInterruptibly(int arg),傳入引數 1,不過這個引數並沒有用。程式碼如下:
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
if (tryAcquireShared(arg) < 0)
doAcquireSharedInterruptibly(arg);
}
Sync 中實現了 tryAcquireShared 方法,await 邏輯如下圖:
上面的自旋過程就是等待 state 的值不斷減小,只有 state 值成為 0 的時候,主執行緒才會跳出自旋執行之後的邏輯。
3.3.2 countDown
CountDownLatch 的 countDown 方法呼叫了 AQS 的 releaseShared(int arg),傳入引數 1,不過這個引數並沒有用。內部類 Sync 實現了 tryReleaseShared 方法,邏輯如下圖:
3.3.3 總結
CountDownLatch 的建構函式入參值會賦值給 state 變數,入隊操作是主執行緒入隊,每個子執行緒呼叫了countDown 後 state 值減 1,當 state 值成為 0 後喚醒主執行緒。
3.4 Semaphore
Semaphore 是一個訊號量,用來保護共享資源。如果執行緒要訪問共享資源,首先從 Semaphore 獲取鎖(訊號量),如果訊號量的計數器等於 0,則當前執行緒進入 AQS 佇列阻塞等待。否則,執行緒獲取鎖成功,訊號量減 1。使用完共享資源後,釋放鎖(訊號量加 1)。
Semaphore 跟管程模型不一樣的是,允許多個(建構函式的 permits)執行緒進入管程內部,因此也常用它來做限流。
UML 類圖如下:
Semaphore的建構函式會傳入一個int型別引數,用來初始化state的值。
3.4.1 acquire
獲取鎖的操作呼叫了 AQS 中的 acquireSharedInterruptibly 方法,傳入引數 1,程式碼見 CountDownLatch 中 await 小節。Semaphore 在公平鎖和非公平鎖中分別實現了 tryAcquireShared 方法。
3.4.1.1 公平鎖
Semaphore 預設使用非公平鎖,如果使用公平鎖,需要在建構函式指定。獲取公平鎖邏輯比較簡單,如下圖:
3.4.1.2 非公平鎖
acquire 在非公平的鎖唯一的區別就是不會判斷 AQS 佇列是否有前置節點(hasQueuedPredecessors),而是直接嘗試獲取鎖。
除了 acquire 方法外,還有其他幾個獲取鎖的方法,原理類似,只是呼叫了 AQS 中的不同方法。
3.4.2 release
釋放鎖的操作呼叫了 AQS 中的 releaseShared(int arg) 方法,傳入引數 1,在內部類 Sync 中實現了 tryReleaseShared 方法,邏輯很簡單:使用 CAS 的方式將 state 的值加 1,之後喚醒佇列中的後繼節點。
3.5 ThreadPoolExecutor
ThreadPoolExecutor 中也用到了 AQS,看下面的 UML 類圖:
Worker 主要在 ThreadPoolExecutor 中斷執行緒的時候使用。Worker 自己實現了獨佔鎖,在中斷執行緒時首先進行加鎖,中斷操作後釋放鎖。按照官方說法,這裡不直接使用 ReentrantLock 的原因是防止呼叫控制執行緒池的方法(類似 setCorePoolSize)時能夠重新獲取到鎖,
3.5.1 tryAcquire
使用 CAS 的方式把 AQS 中 state 從 0 改為 1,把當前執行緒置為獨佔執行緒。
3.5.2 tryRelease
把獨佔執行緒置為空,把 AQS 中 state 改為 0。
Worker 初始化的時候會把 state 置為 -1,這樣是不能獲取鎖成功的。只有呼叫了 runWorker 方法,才會透過釋放鎖操作把 state 更為 0。這樣保證了只中斷執行中的執行緒,而不會中斷等待中的執行緒。
3.6 總結
AQS 基於雙向佇列實現了入口等待佇列,基於 state 變數實現了各種併發鎖,上篇文章講了入口等待佇列,而這篇文章主要講了基於 AQS 的併發鎖原理。
4 條件變數等待佇列
本章節主要講解管程模型中條件變數等待佇列。
4.1 官方示例
首先我們看一下官方給出的示例程式碼:
public class BoundedBuffer {
final Lock lock = new ReentrantLock();
final Condition notFull = lock.newCondition();
final Condition notEmpty = lock.newCondition();
final Object[] items = new Object[100];
int putptr, takeptr, count;
public void put(Object x) throws InterruptedException {
lock.lock();
try {
while (count == items.length)
notFull.await();
items[putptr] = x;
if (++putptr == items.length) putptr = 0;
++count;
notEmpty.signal();
} finally {
lock.unlock();
}
}
public Object take() throws InterruptedException {
lock.lock();
try {
while (count == 0)
notEmpty.await();
Object x = items[takeptr];
if (++takeptr == items.length) takeptr = 0;
--count;
notFull.signal();
return x;
} finally {
lock.unlock();
}
}
}
這個程式碼定義了兩個條件變數,notFull 和 notEmpty,說明如下:
如果 items 陣列已經滿了,則 notFull 變數不滿足,執行緒需要進入 notFull 條件等待佇列進行等待。當 take 方法取走一個陣列元素時,notFull 條件滿足了,喚醒 notFull 條件等待佇列中等待執行緒。 如果 items 陣列為空,則 notEmpty 變數不滿足,執行緒需要進入 notEmpty 條件等待佇列進行等待。當 put 方法加入一個陣列元素時,notEmpty 條件滿足了,喚醒 notEmpty 條件等待佇列中等待執行緒。 條件變數是繫結在 Lock 上的,示例程式碼使用了 ReentrantLock。在執行 await 和 signal 方法時首先要獲取到鎖。
4.2 原理簡介
Java AQS 的條件變數等待佇列是基於介面 Condition 和 ConditionObject 來實現的,URM 類圖如下:
Condition 介面主要定義了下面3個方法:
await:進入條件等待佇列 signal:喚醒條件等待佇列中的元素 signalAll:喚醒條件等待佇列中的所有元素
4.3 await
條件等待佇列跟入口等待佇列有兩個不同:
雖然二者共用了 Node 類,但是條件等待佇列是單向佇列,入口等待佇列是雙向佇列,條件佇列中下一個節點的引用是 nextWaiter,入口等待佇列中下一個節點的引用是 next。 條件等待佇列中元素的 waitStatus 必須是 -2。await 方法的流程如下圖:
4.3.1 進入條件等待佇列
入隊方法對應方法 addConditionWaiter,這裡有三種情況:
佇列為空,則新建一個節點,如下圖:
佇列非空,最後一個元素的 waitStatus 是 -2,如下圖:
佇列非空,最後一個元素的 waitStatus 不是 -2,如下圖:
可以看到,這種情況會從佇列第一個元素開始檢查 waitStatus 不是 -2 的元素,並從佇列中移除。
4.3.2 釋放鎖
AQS 的併發鎖是基於 state 變數實現的,執行緒進入條件等待佇列後,要釋放鎖,即 state 會變為 0,釋放操作會喚醒入口等待佇列中的執行緒。對應方法 fullyRelease,返回值是釋放鎖減掉的 state 值 savedState。
4.3.3 阻塞等待
釋放鎖後,執行緒阻塞,自旋等待被喚醒。
4.3.4 喚醒之後
喚醒之後,當前執行緒主要有四個動作:
轉入入口等待佇列,並把 waitStatus 改為 0。waitStatus 等於 0 表示中間狀態,當前節點後面的節點已經喚醒,但是當前節點執行緒還沒有執行完成。
重新獲取鎖,如果獲取成功,則當前執行緒成為入口等待佇列頭結點,interruptMode 置為 1。
如果當前節點在條件等待佇列中有後繼節點,則剔除條件等待佇列中 waitStatus!=-2 的節點,即佇列中狀態為取消的節點。
interruptMode 如果不等於 0,則處理中斷。
4.3.5 一個細節
上面提到了 interruptMode,這個屬性有三個值:
0:沒有被中斷 -1:中斷後丟擲 InterruptedException,這種情況是當前執行緒阻塞,沒有被 signal 之前發生了中斷 1:重新進入中斷狀態,這種情況是指當前執行緒阻塞,被 signal 之後發生了中斷
4.3.6 擴充套件
AQS 還提供了其他幾個 await 方法,如下:
awaitUninterruptibly:不用處理中斷。 awaitNanos:自旋等待喚醒過程中有超時時間限制,超時則轉入入口等待佇列。 awaitUntil:自旋等待喚醒過程中有截止時間,時間到則轉入入口等待佇列。
4.4 signal
喚醒條件等待佇列中的元素,首先判斷當前執行緒是否持有獨佔鎖,如果沒有,丟擲異常。
喚醒條件佇列中的元素,會從第一個元素也就是 firstWaiter 開始,根據 firstWaiter 的 waitStatus 是不是 -2,分兩種情況。
4.4.1 waitStatus==-2
條件佇列第一個節點進入入口等待佇列,等待獲取鎖,如下圖:
這裡有兩個注意點:
如果入口等待佇列中 tail 節點的 waitStatus 小於等於 0,則 firstWaiter 加入後需要把舊 tail 節點置為 -1 (表示後面節點等待當前節點喚醒),如下圖:
如果入口等待佇列中 tail 節點的 waitStatus 大於 0,則 unpark 節點 firstWaiter。
4.4.2 waitStatus!=-2
如果 firstWaiter 的 waitStatus 不等於 -2,則查詢 firstWaiter 的 nextWaiter,直到找到一個 waitStatus 等於 -2 的節點,然後將這個節點加入入口等待佇列隊尾,如下圖:
4.4.3 waitStatus 修改
上面的兩種情況無論哪種,進入入口等待佇列之前都要用 CAS 的方式把 waitStatus 改為 0。
4.5 signalAll
理解了 signal 的邏輯,signalAll 的邏輯就非常容易理解了。首先判斷當前執行緒是否持有獨佔鎖,如果沒有,丟擲異常。
將條件等待佇列中的所有節點依次加入入口等待佇列。如下圖:
4.6 使用案例
4.6.1 示例程式碼
Java 併發包下有很多類使用到了 AQS 中的 Condition,如下圖:
這裡我們以 CyclicBarrier 為例來講解。CyclicBarrier 是讓一組執行緒相互等待共同達到一個屏障點。從 Cyclic 可以看出 Barrier 可以迴圈利用,也就是當執行緒釋放之後可以繼續使用。
看下面這段示例程式碼:
public static void main(String[] args) {
CyclicBarrier cyclicBarrier = new CyclicBarrier(2, () -> {
System.out.println("柵欄中的執行緒執行完成");
});
ExecutorService executorService = Executors.newFixedThreadPool(2);
executorService.submit(() -> {
try {
System.out.println("執行緒1:" + Thread.currentThread().getName());
cyclicBarrier.await();
} catch (Exception e) {
e.printStackTrace();
}
});
executorService.submit(() -> {
try {
System.out.println("執行緒2:" + Thread.currentThread().getName());
cyclicBarrier.await();
} catch (Exception e) {
e.printStackTrace();
}
});
executorService.shutdown();
}
執行結果:
執行緒1:pool-1-thread-1
執行緒2:pool-1-thread-2
柵欄中的執行緒執行完成
4.6.2 原理講解
CyclicBarrier 初始化的時候,會指定執行緒的數量 count,每個執行緒執行完邏輯後,呼叫 CyclicBarrier 的 await 方法,這個方法首先將 count 減 1,然後呼叫 Condition的 await,讓當前執行緒進入條件等待佇列。當最後一個執行緒將 count 減 1 後,count 數量等於 0,這時就會呼叫 Condition 的 signalAll 方法喚醒所有執行緒。
4.7 總結
Java 的管程模型使用了 MESA 模型,基於 AQS 實現的 MESA 模型中,使用雙向佇列實現了入口等待佇列,使用變數 state 實現了併發鎖,使用 Condition 實現了條件等待佇列。
在 AQS 的實現中,使用同步佇列這個術語來表示雙向佇列,本文中使用入口等待佇列來描述是為了更好的配合管程模型來講解。
AQS 的 Condition 中,使用 await 方法將當前執行緒放入條件變數等待佇列阻塞等待,使用 notify 來喚醒條件等待佇列中的執行緒,被喚醒之後,執行緒並不能立刻執行,而是進入入口等待佇列等待獲取鎖。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024923/viewspace-2929460/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 我畫了 40 張圖就是為了讓你搞懂計算機網路層計算機網路
- 為了拿捏 Redis 資料結構,我畫了 40 張圖(完整版)Redis資料結構
- 我畫了13張圖,用最通俗易懂的話講HTTPS,拿下!HTTP
- 40 張圖帶你搞懂 TCP 和 UDPTCPUDP
- 求你了,再問你Java記憶體模型的時候別再給我講堆疊方法區了…Java記憶體模型
- 這才是圖文並茂:我寫了1萬多字,就是為了讓你瞭解AQS是怎麼執行的AQS
- VUE diff 演算法:為了直觀展示,畫了一張圖來直觀展示Vue演算法
- 敲了幾萬行原始碼後,我給Mybatis畫了張“全地圖”原始碼MyBatis地圖
- 頭禿了,二十三張圖帶你從原始碼瞭解Spring Boot 的啟動流程~原始碼Spring Boot
- 帶你了從零瞭解DockerDocker
- 理解這幾張圖,你就是js小牛了JS
- 關於ChatGPT的一些資訊,我畫了一張思維導圖ChatGPT
- 你演講(分享)是為了什麼?
- 有答案了!一張圖告訴你到底學Python還是Java!你咋看?PythonJava
- 2w字 + 40張圖帶你參透併發程式設計!程式設計
- 為了擺脫谷歌的桎梏,Uber決心“畫”一張自己的世界地圖谷歌地圖
- 老闆畫了個餅,我信了
- 為了學好Java,我嘗試了這 6 個方法Java
- 僅用40張圖片就能訓練視覺模型:CVPR 2019伯克利新論文說了什麼?視覺模型
- 從逃離到成為遊戲開發,40歲了我才學會程式設計遊戲開發程式設計
- 47 張圖帶你 MySQL 進階!!!MySql
- 35 張圖帶你 MySQL 調優MySql
- 炸裂!MySQL 82 張圖帶你飛MySql
- 2萬字|30張圖帶你領略glibc記憶體管理精髓(因為OOM導致了上千萬損失)記憶體OOM
- 我有一篇Java Stream使用手冊,學了就是你的了!Java
- Git讓你從入門到精通,看這一篇就夠了!Git
- Java 多執行緒共享模型之管程(上)Java執行緒模型
- PostgreSQL從入門到精通 - 第40講:資料庫不完全恢復SQL資料庫
- Java入門到精通完整教程,學Java先收藏了!Java
- 為了弄清起點小說如何算字扣錢,我特意註冊了作家賬號
- “我怎麼就被一張照片出賣了?”
- 沒了IDE,你的Java專案還能Run起來嗎~IDEJava
- 42 張圖帶你擼完 MySQL 優化MySql優化
- 一張圖帶你搞懂Node事件迴圈事件
- JAVA的字串這篇講清楚了Java字串
- 你以為你懂php了?PHP
- Java校招入職華為,半年後我跑路了Java
- 別再問我們用什麼畫圖的了!問就是excalidraw