15 張圖帶你徹底理解 java AQS

java 中 AQS 是 AbstractQueuedSynchronizer 類,AQS 依賴 FIFO 隊列來提供一個框架,這個框架用於實現鎖以及鎖相關的同步器,比如信號量、事件等。

在 AQS 中,主要有兩部分功能,一部分是操作 state 變量,第二部分是實現排隊和阻塞機制。

注意,AQS 並沒有實現任何同步接口,它只是提供了類似 acquireInterruptible 的方法,調用這些方法可以實現鎖和同步器。

管程模型

java 使用 MESA 管程模型來管理類的成員變量和方法,讓這個類的成員變量和方法的操作是線程安全的。下圖是 MESA 管程模型,裏面除了定義共享變量外,還定義了條件變量和條件變量等待隊列:

java 中的 MESA 模型有一點改進,就是管程內部只有一個條件變量和一個等待隊列。下圖是 AQS 的管程模型:

AQS 的管程模型依賴 AQS 中的 FIFO 隊列實現入口等待隊列,而 ConditionObject 則實現了條件隊列,這個隊列可以創建多個。本文主要講解入口等待隊列獲取鎖的幾種方式。參考 1[1]

獲取獨佔鎖

獨佔, 忽略 interrupts

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

這裏的 tryAcquire 是抽象方法,有 AQS 的子類來實現,因爲每個子類實現的鎖是不一樣的。

入隊

上面的代碼可以看到,獲取鎖失敗後,會先執行 addWaiter 方法加入隊列,然後執行 acquireQueued 方法自旋地獲取鎖直到成功。

addWaiter 代碼邏輯如下圖,簡單說就是把 node 入隊,入隊後返回 node 參數給 acquireQueued 方法:

這裏有一個點需要注意,如果隊列爲空,則新建一個 Node 作爲隊頭。

入隊後獲取鎖

acquireQueued 自旋獲取鎖邏輯如下圖:

這裏有幾個細節:

1.waitStatus

  1. 獲取鎖失敗後掛起

如果前置節點不是頭節點,或者前置節點是頭節點但當前節點獲取鎖失敗,這時當前節點需要掛起,分三種情況,

前置節點 waitStatus=-1,如下圖:

前置節點 waitStatus > 0,如下圖:

前置節點 waitStatus < 0 但不等於 -1,如下圖:

  1. 取消獲取鎖

如果獲取鎖拋出異常,則取消獲取鎖,如果當前節點是 tail 節點,分兩種情況如下圖:

如果當前節點不是 tail 節點,也分兩種情況,如下圖:

  1. 對中斷狀態忽略

  2. 如果前置節點的狀態是 0 或 PROPAGATE,會被當前節點自旋過程中更新成 - 1,以便之後通知當前節點。

獨佔 + 響應中斷

對應方法 acquireInterruptibly(int arg)。

跟忽略中斷 (acquire 方法) 不同的是要響應中斷,下面兩個地方響應中斷:

如果檢查到當前線程已經中斷,則拋出 InterruptedException,當前線程退出。

獨佔 + 響應中斷 + 考慮超時

對應方法 tryAcquireNanos(int arg, long nanosTimeout)。

這個方法具備了獨佔 + 響應中斷 + 超時的功能,下面 2 個地方要判斷是否超時:

另外,park 線程的操作使用 parkNanos 傳入阻塞時間。

釋放獨佔鎖

獨佔鎖釋放分兩步:釋放鎖,喚醒後繼節點。

釋放鎖的方法 tryRelease 是抽象的,由子類去實現。

我們看一下喚醒後繼節點的邏輯,首先需要滿足兩個條件:

這裏有兩種情況 (在方法 unparkSuccessor):

獲取共享鎖

之前我們講了獨佔鎖,這一小節我們談共享鎖,有什麼不同呢?

共享,忽略 interrupts

對應方法 acquireShared,代碼如下:

public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

tryAcquireShared

這裏獲取鎖使用的方法是 tryAcquireShared,獲取的是共享鎖。獲取共享鎖跟獲取獨佔鎖不同的是,會返回一個整數值,說明如下:

tryAcquireShared 獲取鎖失敗後 (返回負數),就需要入隊後自旋獲取,也就是執行方法 doAcquireShared。

doAcquireShared

怎麼判斷隊列中等待節點是在等待共享鎖呢?nextWaiter == SHARED,這個參數值是入隊新建節點的時候構造函數傳入的。

自旋過程中,如果獲取鎖成功 (返回正數),首先把自己設置成新的 head 節點,然後把通知傳播下去。如下圖:net/anlian523/article/details/106319294/

之後會喚醒後面節點並保證喚醒操作可以傳播下去。但是需要滿足四個條件中的一個:

這裏會造成不必要的喚醒,因爲喚醒後獲取不到鎖只能繼續入隊等待

喚醒後面節點的操作,其實就是釋放共享鎖,對應方法是 doReleaseShared,見釋放共享鎖一節。

共享 + 響應中斷

對應方法 acquireSharedInterruptibly(int arg)。

跟共享忽略中斷 (acquireShared 方法) 不同的是要響應中斷,下面兩個地方響應中斷:

如果檢查到當前線程已經中斷,則拋出 InterruptedException,當前線程退出。

共享 + 響應中斷 + 考慮超時

對應方法 tryAcquireSharedNanos(int arg, long nanosTimeout)。

這個方法具備了共享 + 響應中斷 + 超時的功能,下面 2 個地方要判斷是否超時:

另外,park 線程的操作使用 parkNanos 傳入阻塞時間。

釋放共享鎖

釋放共享鎖代碼如下:

public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        doReleaseShared();
        return true;
    }
    return false;
}

首先嚐試釋放共享鎖,tryReleaseShared 代碼由子類來實現。釋放成功後執行 AQS 中的 doReleaseShared 方法,是一個自旋操作。

自旋的條件是隊列中至少有兩個節點,這裏分三種情況。

情況一:當前節點 waitStatus 是 - 1,如下圖:

情況二:當前節點 waitStatus 是 0(被其他線程更 xin 新成了中間狀態),如下圖:

情況三:當前節點 waitStatus 是 - 3,爲什麼會這樣呢?**需要解釋一下,head 節點喚醒後繼節點之前 waitStatus 已經被更新中間態 0 了,喚醒後繼節點動作還沒有執行,又被其他線程更成了 - 3,也就是其他線程釋放鎖執行了上面情況二。**這時需要先把 waitStatus 再更成 0(在方法 unparkSuccessor),如下圖:

抽象方法

上面的講解可以看出,如果要基於 AQS 來實現併發鎖,可以根據需求重寫下面四個方法來實現,這四個方法在 AQS 中沒有具體實現:

AQS 的子類需要重寫上面的方法來修改 state 值,並且定義獲取鎖或者釋放鎖時 state 值的變化。子類也可以定義自己的 state 變量,但是隻有更新 AQS 中的 state 變量纔會對同步起作用。

還有一個判斷當前線程是否持有獨佔鎖的方法 isHeldExclusively,也可以供子類重寫後使用。

獲取 / 釋放鎖的具體實現放到下篇文章講解。

總結

AQS 使用 FIFO 隊列實現了一個鎖相關的併發器模板,可以基於這個模板來實現各種鎖,包括獨佔鎖、共享鎖、信號量等。

AQS 中,有一個核心狀態是 waitStatus,這個代表節點的狀態,決定了當前節點的後續操作,比如是否等待喚醒,是否要喚醒後繼節點。

參考資料

[1]

參考 1: https://blog.csdn.net/it_lihongmin/article/details/109609023

[2]

參考 2: https://mp.weixin.qq.com/s/u_0fAgTUtC0YUz-SIxlL7Q

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/CcCC22B7CcjdApk3dpRfEA