面試被問到什麼是AQS,這樣答滿分

码农谈IT發表於2024-03-07

來源:碼農本農

本篇內容基本已經涵蓋了AQS的全部核心內容,本篇相比於上一篇補充了“中斷”。

前置思考

實現鎖應該考慮的問題

  1. 如何獲取資源(鎖)?
  2. 獲取不到資源的執行緒如何處理?
  3. 如何釋放資源?
  4. 資源釋放後如何讓其他執行緒獲取資源?

由此可以得出實現一把鎖,應該具備哪些邏輯

  • 鎖的標識

    需要有個標識或者狀態來表示鎖是否已經被佔用。

  • 執行緒搶鎖的邏輯

    多個執行緒如何搶鎖,如何才算搶到鎖,已經搶到鎖的執行緒再次搶鎖如何處理等等。

  • 執行緒掛起的邏輯

    執行緒如果搶到鎖自然順利往下執行了,而那些沒有搶到鎖的執行緒怎麼處理呢?如果一直處於活躍狀態,cpu肯定是吃不消,那就需要掛起。具體又如何掛起呢?

  • 執行緒儲存機制

    沒有搶到鎖的執行緒就掛起了,而且被掛起的執行緒可能有很多個,這些執行緒總要放在某個地方儲存起來等待喚醒,然而這麼多被掛起的執行緒,要喚醒哪一個呢?這就需要一套儲存機制來支撐喚醒邏輯。

  • 執行緒釋放鎖的邏輯

    執行緒在執行完後就要釋放鎖,跟搶鎖邏輯是對應的,其實也是操作鎖標識。

  • 執行緒喚醒的邏輯

    鎖釋放後,就要去喚醒被阻塞的執行緒,這就要考慮喚醒誰,如何喚醒,喚醒後的執行緒做什麼事情。

帶著上面的思考,我們來看看AQS是怎麼處理的

AQS由來

在最早期java中的同步機制是透過關鍵字synchronized實現,這個鎖是java原生的,jvm層面實現的。在1.6之前synchronized的效能比較低,是一把純重量級鎖。

後來,Doug Lea開發並引入了java.util.concurrent包,這個包基本涵蓋了java併發操作的半壁江山,該包內的併發工具類基本是以AQS為基礎的,AQS提高了同步操作的效能,在效能上遠超當時的synchronized,後來synchronized做了最佳化,java1.6及之後兩者的效能就差不多了。

AQS是什麼

AQS的全稱為AbstractQueuedSynchronizer

AQS其實是一個抽象類,它實現了執行緒掛起的邏輯,實現了執行緒儲存機制,實現了鎖的狀態邏輯,實現了執行緒喚醒的邏輯,卻只定義了執行緒搶鎖和釋放鎖的抽象,這樣做的目的是將搶鎖和釋放鎖的邏輯交給子類來實現,這樣有助於實現各種不同特性的鎖,比如共享鎖,獨佔鎖,公平鎖,非公平鎖,可重入等。並且以模板方法模式將上述上鎖流程和釋放鎖流程封裝為固定模板方法。所以AQS就是一個多執行緒訪問共享資源的同步器框架

AQS實現同步機制有兩種模式,一種是獨佔模式,一種是共享模式。兩種模式分別提供提供兩個模板方法實現。四個模板方法為acquire,release,acquireShared,releaseShared。

獨佔模式的鎖是隻允許一個執行緒持有鎖

共享模式的鎖是允許多餘一個的執行緒持有鎖

接下來分別介紹這四個方法的邏輯

acquire方法解析

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

acquire方法是獨佔模式上鎖的整個邏輯,這個方法是一個模板方法,其中的tryAcquire是獲取鎖的邏輯,這個方法是一個抽象方法,由具體的子類實現,如何獲取鎖,怎樣才算獲取到鎖這些問題子類自己決定,AQS不做處理。

addWaiter方法負責是執行緒儲存的邏輯,aqs裡面儲存機制的核心是兩個佇列,等待佇列和條件佇列,它們用來儲存被阻塞的執行緒,在這個方法中透過cas+自旋的方式將執行緒新增到等待佇列中。

先來介紹等待佇列,等待佇列的結構如下:

面試被問到什麼是AQS,這樣答滿分

等待佇列是一個雙向連結串列,每個節點就是一個node物件,node是aqs類中的一個靜態內部類,它的屬性如下:

node{thread;prev;next;nextWaiter;waitStatus;}

thread是當前node節點所繫結的執行緒;

prev是前置節點的引用;

next是後置節點的引用;

nextWaiter如果是等待佇列節點就標示獨佔模式節點還是共享模式,如果是條件佇列節點就作為後置節點指標;

waitStatus是節點的狀態,其狀態值如下:

  • static final int CANCELLED = 1; 出現異常
  • static final int SIGNAL = -1;可被喚醒
  • static final int CONDITION = -2; 條件等待
  • static final int PROPAGATE = -3;傳播

AQS類自身也有幾個比較重要的屬性

//正在持有鎖的執行緒
private transient Thread exclusiveOwnerThread;
//等待佇列的頭節點
private transient volatile Node head;
//等待佇列的尾節點
private transient volatile Node tail;
//鎖標識欄位
private volatile int state;

瞭解了等待佇列,接下來具體看看addWaiter方法的邏輯

  1. 首先如果佇列還沒有初始化會先初始化佇列,初始化就是先建立一個空的node節點,把aqs裡面的head和tail屬性指向這個空的node,初始化完成;

面試被問到什麼是AQS,這樣答滿分

  1. 先建立一個node節點,預設屬性如下:

node{ thread=當前執行緒t1;prev;next;nextWaiter=獨佔模式;waitStatus=0}

開始入隊操作,入隊就是cas+自旋的方式將tail指標指向新加入的node節點,並且把新加入的node和head建立雙向指標。

面試被問到什麼是AQS,這樣答滿分

cas是保證原子性的,多執行緒操作的情況下,當前執行緒可能會操作失敗,自旋是為了失敗重試,保證一定能夠入隊成功。

入隊成功後,就要掛起執行緒了,acquireQueued方法就是掛起操作。

這個方法比較核心,執行緒掛起的邏輯和執行緒喚醒後的邏輯都在此方法中,原始碼如下:

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);
}
}

邏輯解析:

  1. 開啟for迴圈,讓執行緒處於迴圈中
  2. node節點已經入隊,先拿到node節點的前置節點,然後做如下判斷
if (p == head && tryAcquire(arg))

上面介紹了等待佇列,等待佇列的head節點永遠是一個不繫結執行緒的節點,所以拿到前置節點後判斷是否為head節點,如果為head節點才有資格再次獲取鎖,可以發現如果佇列中已經有其他執行緒處於阻塞等待狀態,新入隊執行緒是在這個判斷中永遠會返回fasle。

這個判斷加在這裡有什麼用處呢?

有兩個用處:第一個是入隊後掛起前這個時間段中,可能鎖已經被釋放了,所以這裡再次嘗試獲取鎖,這樣就不用阻塞掛起了;第二個用處是,這個判斷處於迴圈中,阻塞掛起的動作也是在迴圈中,當被喚醒後,執行緒會從被掛起的點繼續執行,會再次進入這個判斷,從而實現被喚醒的執行緒再次嘗試換取鎖的邏輯。

  1. 如果沒有獲取到鎖,那接下來就會進入這個方法shouldParkAfterFailedAcquire,這個方法的原始碼如下
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
return true;
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}

程式碼邏輯為:獲取node節點的前置節點的waitStatus屬性;

如果waitStatus為-1返回true;

如果waitStatus>0,根據waitStatus狀態可知,大於0的只有1,1代表執行緒被取消或者執行緒異常,所以這裡的做法是將異常的node節點從佇列中移除,採用的方式為從尾節點開始向前遍歷判斷移除,直到遇到一個非異常節點。返回false。

如果waitStatus小於-1,那就把waitStatus透過cas改為-1,返回false。

如果此方法返回false,因為當前處在迴圈中,所以會再次進入此方法,此時一定會返回true。

只有將當前node節點的前置節點設定為-1後,此方法才會返回true,從而會進入後面的parkAndCheckInterrupt()方法,這個方法就很簡單了,就是呼叫LockSupport類的park方法將執行緒阻塞掛起。

 private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}

為什麼在阻塞前一定要將當前node節點的前置節點置為-1?

waitStatus為-1代表可喚醒狀態,獨佔模式下,AQS在喚醒被阻塞執行緒的時候,總是透過判斷head節點的waitStatus狀態,如果為可喚醒狀態代表head後面的節點可以被喚醒,否則不允許喚醒。

這樣做的好處是,當head節點後面執行緒獲取到鎖並出隊後,可以直接將head指標移動到第一個執行緒節點,然後將此節點上的前置指標刪除,將執行緒屬性刪除,作為新的head節點。

面試被問到什麼是AQS,這樣答滿分

當執行緒呼叫park方法後,執行緒就阻塞在這裡,當被喚醒後,執行緒也是從這個點繼續往下進行,此時依然處在迴圈中,這個時候會開始新一輪迴圈,從而再次進入嘗試獲取鎖的判斷,如果獲取到鎖,就出隊,否則再次進入阻塞掛起的方法進行掛起操作。

這裡的設計是先搶鎖,搶到鎖後再出隊,避免在沒有搶到鎖的情況下不用再次入隊造成的時間消耗。

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是釋放鎖的方法,它是抽象方法,具體由子類來實現。

釋放成功後就要喚醒被阻塞的執行緒,核心邏輯在下面這個方法中,原始碼如下:

private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);

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;
}
if (s != null)
LockSupport.unpark(s.thread);
}

先看下整體邏輯,這兩段程式碼的邏輯其實很簡單:

  1. head節點的waitStatus屬性為-1,才能進入unparkSuccessor進行喚醒邏輯

  2. 在unparkSuccessor方法中首先會將head節點的waitStatus改為0

  3. 取head節點的下一個節點next,要判斷next節點的waitStatus屬性是否大於0,如果大於0表示此節點異常或者被取消屬於非正常節點,從尾節點向前遍歷直到找到最靠近head節點的正常節點,即為要喚醒的執行緒。

  4. 最後呼叫LockSupport.unpark方法喚醒執行緒。

邏輯很容能看懂,但是這裡有個問題,為什麼前面有這段程式碼

if (h != null && h.waitStatus != 0) 
unparkSuccessor(h);

後面unparkSuccessor方法又有這一段程式碼

 if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);

不難看出邏輯是waitStatus不為0進入unparkSuccessor方法,進入方法馬上把waitStatus改為0,這是在阻止後續的執行緒再進來。

那真正的用意是什麼呢?

透過上面程式碼可以知道釋放鎖邏輯和喚醒邏輯是分開的,看下面的時間抽

  1. 執行緒1搶到鎖
  2. 執行緒1釋放鎖
  3. 執行緒2搶到鎖
  4. 執行緒1判斷head節點waitStatus狀態為-1後,進入unparkSuccessor方法執行喚醒操作,該方法第一步是將waitStatus狀態改為0
  5. 執行緒2釋放鎖
  6. 執行緒2判斷head節點waitStatus狀態為0後,不會進入unparkSuccessor方法

上面這個場景是非公平鎖的場景,公平鎖說的是所有執行緒都要按照順序排隊獲取鎖,而非公平鎖說的是新進來的執行緒可以和剛被喚醒的執行緒搶鎖。

在非公平鎖的場景中,如果程式碼塊中的邏輯執行的足夠快就有可能發生上面的情況,執行緒1和執行緒2都是都去喚醒同一個執行緒,所以這裡透過將head節點的waitStatus改為0的方式將其他執行緒拒之門外,這樣就保證在head節點後面的執行緒只會由一個執行緒去喚醒。

acquireShared方法解析

//共享模式的鎖呼叫的上鎖邏輯   
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}

此方法同樣是一個模板方法,tryAcquireShared方法是抽象方法,供子類實現搶鎖的邏輯,doAcquireShared方法則是實現阻塞掛起和入隊,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) {
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);
}
}
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head;
setHead(node);

if (propagate > 0 || h == null || h.waitStatus < 0 || (h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}

透過原始碼會發現doAcquireShared這個方法合併了入隊和掛起兩個步驟,整體的邏輯基本和獨佔模式一樣,接下來只介紹不同的地方。

第一個不同,入隊的時候建立的node節點為共享模式節點,即nextWaiter屬性的值不同。

第二個不同,獨佔模式下執行緒被喚醒重新獲取到鎖後,就要出隊了,而共享模式下除了出隊,還會判斷是否資源充足,如果充足就喚醒下一個節點。

releaseShared方法解析

//共享模式的鎖呼叫的釋放鎖的邏輯   
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}

同樣此方法也是模板方法,tryReleaseShared方法是交給子類實現的釋放鎖的邏輯,doReleaseShared方法則是aqs自己實現的喚醒邏輯,喚醒邏輯和獨佔模式下的喚醒邏輯大同小異,都是喚醒head節點的下一個節點繫結的執行緒,不再過多贅述。

總結一下獨佔和共享模式在aqs中實現的最大不同是被喚醒的執行緒出隊後會在資源充足的情況下順便喚醒其後面節點的執行緒。

AQS中的Condition

上面說過,AQS有兩個佇列,等待佇列和條件佇列,上面介紹了等待佇列,但是條件佇列一直未提,那麼條件佇列是做什麼的呢?

先說下條件佇列的結構

AQS內部有一個內部類ConditionObject,其內部維護了一個單向連結串列(先進先出),這個內部類內有兩個屬性:firstWaiter和lastWaiter分別指向單向連結串列的頭結點和尾節點,這個單向連結串列就是條件佇列,和等待佇列的不同處是它的頭節點是繫結執行緒的,條件佇列的結構如下

面試被問到什麼是AQS,這樣答滿分

這個內部類主要的方法是如下三個,這裡直接說每個方法的底層邏輯,原始碼就不展示了,可以自己去查閱原始碼

首先先說下Condition整體的思維邏輯

  1. 入隊,包括初始化條件佇列,佇列的節點依然是node物件,利用nextWaiter屬性指向下一個節點,waitStatus屬性的值預設為-2,代表等待
  2. 釋放鎖,在入隊後就要釋放鎖了
  3. 阻塞
  4. 條件達成後換隊
  5. 阻塞被喚醒後,按照獨佔鎖的方式去再次嘗試搶鎖嗎,這裡和獨佔模式下的喚醒邏輯是一樣的
  • await()的邏輯
  1. 入隊,包括初始化條件佇列,佇列的節點依然是node物件,利用nextWaiter屬性指向下一個節點,waitStatus屬性的值預設為-2,代表等待
  2. 釋放鎖,在入隊後就要釋放鎖了
  3. 阻塞
  • signal()的邏輯
  1. 條件達成後換隊
  2. 阻塞被喚醒後,按照獨佔鎖的方式去再次嘗試搶鎖嗎,這裡和獨佔模式下的喚醒邏輯是一樣的

條件達成後換隊的意思就是將條件隊裡的頭節點移動到獨佔模式的等待佇列中去,入隊的方式和獨佔模式下入隊方式一樣,入隊之後會將當前節點的前一個節點的waitStatus置為-1,代表可喚醒。

  • signalAll()的邏輯

這個方法和上面的方法一樣,不同點就是此方法是將條件佇列的節點一個一個全部移動到等待佇列上去。

看的出來Condition中的條件佇列依賴等待佇列,具體使用可以參考ReentrantLock。你會發現在ReentrantLock鎖裡面使用Condition,就相當於在synchronized程式碼塊中使用object類的wait方法和nottfyf。

為了更好的理解Condition,一起看下ArrayBlockingQueue的實現,它是一個陣列實現的先進先出的有界阻塞佇列,佇列滿,入隊者等待,佇列空,出隊者等待。

這個佇列有兩個重要的特點:先進先出和佇列有界。

為保證先進先出,需要加鎖處理,獲取到鎖的執行緒才有資格向佇列中放資料或者取出資料。

那如何保證佇列有界的情況下等待處理呢?這個時候就用到Condition了,它的邏輯是這樣的,所有想向佇列新增資料的和所有想從佇列取資料的執行緒一起競爭鎖,拿到鎖的那個執行緒才有資格操作,ArrayBlockingQueue維護裡兩個Condition物件,也就相當於維護兩個條件佇列,如果是新增資料的某個執行緒搶到了鎖,在操作新增的時候,發現佇列已滿,此時該執行緒無法將資料插進去,需要等待有一個資料被取走後才能做新增操作,但是該執行緒佔有鎖資源,取資料的執行緒進不來,所以就無法進行下去,ArrayBlockingQueue的做法是將該執行緒放入條件佇列阻塞掛起,等到有一個資料被取走後,再把條件佇列中的掛起的執行緒搬運到鎖的等待隊裡上去,從而再次獲取排隊搶鎖的資格。

之所以維護兩個Condition條件佇列是為了將新增資料的執行緒和取資料的執行緒分開,根據不同的條件操作不同的條件佇列。

有沒有發現,這不就是synchronized程式碼塊中的object類的wait方法嗎

但是不同點是呼叫object類的wait方法阻塞的執行緒,要麼只有一個被釋放,要麼全部釋放。

而Condition就不同了,因為你可以宣告多個Condition物件,將不同條件下阻塞的執行緒放入不同的Condition物件,釋放的時候也按照條件釋放,這就真正意義上實現了按條件釋放。

我說的釋放是重新獲取排隊搶奪資源的資格。

AQS中的中斷

不可中斷說的是阻塞狀態不能被終止。

我們知道synchronized是不可中斷的鎖,當執行緒因為競爭資源失敗而進入阻塞狀態後,唯一能讓該執行緒結束阻塞的方式就是持有鎖資源的執行緒處理完成後,被阻塞的執行緒被喚醒。

synchronized中的阻塞狀態不可中斷是因為執行緒的阻塞喚醒是由作業系統來管理,而AQS中的阻塞之所以支援中斷是因為上鎖是透過LockSupport類的park方法來實現的,當執行緒呼叫park方法阻塞後,如果呼叫此執行緒interrupt方法,阻塞狀態就會中斷,也就是阻塞中的執行緒會被喚醒。

但是呼叫acquire上鎖的時候如果沒有獲取到鎖就會被阻塞,此時如果呼叫被阻塞執行緒的interrupt方法就會喚醒這個執行緒,但是此時被喚醒的執行緒處於迴圈之中,會重新去搶鎖,如果獲取不到依然會再次阻塞,也就是說acquire方法中被阻塞的執行緒被中斷後只不過會讓執行緒提前加入搶鎖,但是並不會增加搶到鎖的機率,因為只有阻塞佇列的頭節點才有資格搶鎖。

這裡介紹一個知識點:常見的可中斷方法sleep,wait,park方法,這三個方法都會使得執行緒處於靜止狀態,此時呼叫interrupt方法,會中斷其靜止狀態,執行緒從而處於重新被啟用的狀態,不同的是被啟用後的執行緒的中斷狀態是不一樣的,sleep和wait方法被啟用後,執行緒的中斷狀態為false,而park方法被啟用後,執行緒的中斷狀態為true,這是需要注意的

按照上面的說法AQS雖然支援中斷,但是似乎沒什麼用,其實AQS還有一個相對於acquire方法不那麼常用的方法tryAcquireNanos方法。

跟一下這個方法進入doAcquireNanos方法,主要邏輯就在這個方法中,其實和tryAcquireNanos和acquire一樣,都是搶鎖,入隊,阻塞,喚醒那一套邏輯。

不同的是tryAcquireNanos方法還具備兩個技能:

  1. 支援指定阻塞時間,一定時間後執行緒將會自動喚醒,自動喚醒後的執行緒的中斷狀態為false。
  2. 支援被中斷後丟擲異常InterruptedException。
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);
}
}

上面的程式碼可以清楚的看到阻塞操作是透過這段程式碼實現:

LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(10));

parkNanos方法相對與park方法的區別就是parkNanos方法可以指定阻塞時間。

而下面這段程式碼實現的就是阻塞被中斷的時候主動丟擲InterruptedException異常,可以讓方法外部捕獲到這個異常,從而達到真正的阻塞中斷。

 if (Thread.interrupted())
throw new InterruptedException();


來自 “ ITPUB部落格 ” ,連結:https://blog.itpub.net/70024924/viewspace-3008324/,如需轉載,請註明出處,否則將追究法律責任。

相關文章