阿里社招二面:談談你對JUC 中 AQS的理解,用了什么設計模式?為什么它是鎖的靈魂?
信號量和管程
在并發編程領域有幾個核心概念:
- 互斥:只有一個線程能訪問臨界區。
- 臨界資源:多個線程可以共享系統中的資源,但是臨界資源在同一時刻只允許一個線程訪問。
- 臨界區:訪問臨界資源的代碼即臨界區。
管程和信號量是操作系統中實現并發編程的兩種重要技術。
- 信號量:是一種低級的同步工具,是一個計數器,用于控制對共享資源的訪問。信號量的值表示可用的資源數量。
主要包含共享變量 S、P 操作(申請資源)和 V 操作(釋放資源)。P 操作會使 S 值減一,當 S 值為負時,表示沒有資源可操作,此時要進入等待隊列;V 操作會使信號量的值加一,并喚醒等待隊列中的線程。
- 管程:為了解決信號量在臨界區的 PV 操作上的配對的麻煩而提出的并發編程方法,使用條件變量等同步機制來實現線程之間的協作。
MESA 模型的 wait()是進入條件變量的等待隊列,當被 notify()或者 notifyAll()喚醒,會從條件變量等待隊列進入入口等待隊列。
小白:等等,什么是條件變量等待隊列呀?
打個比方,你去醫院看病,就診過程醫生先讓你去拍個 CT,于是你就去拍 CT 的隊列(條件隊列)排隊了,這時醫生可以給其他病人(線程)就診,那當你拍完 CT 拿到結果后(滿足條件變量)回來給醫生看,不是立馬執行,而是需要先進入入口等待隊列里面,等待醫生給你看結果。
而這個場景下如果用信號量實現,那會比較復雜,而且如果用不好,還會有死鎖的問題。
在 Java 中,鎖大多都是通過管程來實現的,比如大家熟悉的 Synchronized、AQS。這里先通過信號量、管程的對比幫助大家開始了解 AQS 的設計。
AQS 實現原理
Java 并發編程核心在于 java.cocurrent.util 包,而 juc 里面大多同步器的實現都有共同的特點:等待隊列、條件隊列、獨占獲取、共享獲取等,那么這個場景很容易就讓我們想到用模板方法的設計模式來實現。
在 AQS 中實現了鎖的獲取釋放框架,實際邏輯由子類去實現,而核心的隊列入隊出隊操作在 AQS 父類抽象出來,正是基于這種抽象變與不變的思想,AQS 定義了一套多線程并發編程的抽象框架。
AQS 核心特性。
我們再來看下 AQS 的基本結構,它維護了一個共享資源 state 和一個 FIFO 的等待隊列,底層通過 CAS 機制保證了操作的原子性。
上文講過,AQS 是基于 MESA 模型實現的,所以在 AQS 中有兩種隊列:
- 同步等待隊列:AQS 的同步等待隊列也稱為 CLH 隊列,主要是 Craig、Landin、Hagersten 這三位大佬發明的一種基于雙向鏈表數據結構的隊列,是 FIFO 先入先出等待隊列。
- 條件等待隊列:Condition 是一個多線程間協調通信的工具,主要使某些線程一起等待某個條件,等具備該條件時,這些線程會被喚醒,從而進去等待隊列中爭奪鎖資源。
AQS 還定義了兩種資源獲取方式:
- Exclusive-獨占,只有一個線程能執行成功,如 ReentrantLock。
- Share-共享,多個線程可以同時執行成功,如 Semaphore/CountDownLatch,當然還有讀寫鎖的讀鎖,因為不涉及數據一致性問題,也是通過共享模式獲取資源。
在 AQS 中,不同場景下,不同的同步器爭搶資源的方式不同,但是不同的同步器只需要共享資源 state 的獲取和釋放方法即可,至于線程等待隊列的維護(比如入隊/喚醒出隊)在 AQS 頂層已實現好,如果你要自定義一個同步器,通常需要實現以下幾個方法:
- isHeldExclusively:該線程是否正在獨占資源
- tryAcquire(int):獨占方法。嘗試獲取資源,成功返回 true,失敗返回 false。
- tryRelease(int):獨占方法。嘗試釋放資源,成功返回 true,失敗返回 false。
- tryAcquireShared(int):獨占方法。嘗試獲取資源,負數表示失敗,大于等于 0 表示成功。
- tryReleaseShared(int):獨占方法。嘗試釋放資源,如果釋放后允許喚醒后續等待節點返回 true,否則返回 false。
那么,你知道 JUC 中不同鎖/同步器都是怎么實現的嗎?
AQS 源碼分析
ReentrantLock 是我們經常使用到一種鎖,下面我們以它為例子,分析它是如何實現獲取和釋放鎖資源的,一起來揭開 AQS 的神秘面紗。
我們都知道 ReentrantLock 是獨占鎖,有公平和非公平鎖兩種模式。
什么是公平和非公平鎖?
- 公平鎖:指多個線程按照申請鎖的順序來獲取鎖,即按照線程的先后順序排隊獲取鎖。當一個線程釋放鎖后,等待時間最長的線程會獲得鎖的訪問權,保證每個線程都有機會獲取到鎖,避免饑餓現象的發生。
- 非公平鎖:指多個線程獲取鎖的順序是不確定的,不按照申請鎖的順序排隊。一個線程在等待鎖時,有可能在其他線程釋放鎖后立即獲取鎖,允許某些線程相對于其他線程具有更高的獲取鎖的機會。
我們先來看下 ReentrantLock 相關核心類的關系。
FairSync 和 NoneFairSync 是 ReentrantLock 實現的內部類,ReentrantLock 公平鎖和非公平鎖就是通過它們來實現的。
lock
然后再來看下 lock()方法的流程。
由上面可看出,ReentrantLock 實現的公平鎖、非公平鎖唯一的區別在于,非公平鎖在一開始調用獲取資源方式時,就直接嘗試獲取鎖,不會判斷等待隊列是否有線程在等待,獲取不到時,再把線程添加到等待隊列中。
小白:我有個問題,把線程節點添加到隊列尾部后,為啥還要調用 acquireQueued 方法判斷是否要掛起呀?
這個問題提得好,我們先來思考下,假設在線程獲取鎖資源失敗把線程節點添加到隊列中直接就掛起阻塞,意味著線程運行狀態轉換為阻塞,會帶來 CPU 從用戶態與內核態之間轉換的兩次操作(阻塞和喚醒),特別在并發場景下,這種切換會帶來較大的性能開銷,所以 AQS 在入隊時首先會讓線程通過自旋的方式來等待競爭鎖。
小白:那么這里 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);
// 原頭節點的下節點指向設置為null,方便GC回收
p.next = null; // help GC
// 設置鎖資源獲取成功
failed = false;
return interrupted;
}
// 如果當前節點不是head的下一節點/獲取鎖資源失敗,嘗試掛起線程
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
通過源碼,我們發現它主要是根據上一節點的狀態來判斷是否需要掛起,那么我們先看下 Node 有哪幾個狀態。
- CANCELLED:1,線程已被取消。
- SIGNAL:-1,等待隊列中存在待被喚醒的掛起線程。
- CONDITION:-2,當前線程在 Condition 隊列中,未在 AQS 隊列中。
- PROPAGATE:-3,表示后續結點會傳播喚醒的操作,共享模式下起作用。
通過流程圖分析。
以上就是獲取鎖的全部流程啦,怎么樣,通過流程圖分析后是不是覺得很簡單呢。
小白:嗯嗯,我還有一個疑問,為什么 acquireQueued 方法里面還要判斷線程是否中斷呢?
嗯不錯,你看得很細,一般線程中斷可以按中斷時線程狀態分為兩種:1、運行時中斷;2、阻塞或等待線程中斷。一般有中斷時,運行時的線程會在某個取消點中斷執行,其實這也可以理解,因為如果立刻中斷,那么容易造成對象狀態不一致的情況發生。而阻塞或等待狀態的線程大多會立即響應中斷。
但是 JUC 中獲取獨占鎖的阻塞狀態不會立即響應中斷,這里在 acquireQueued 方法中對線程的中斷狀態判斷,如果中斷了返回 true,執行 selfInterrupt 方法進入中斷狀態,但注意是在獲取鎖之后,在獲取到鎖之前是不會做出響應的。
unLock
看完了 lock 方法,我們再來看下 unlock 釋放資源的實現,ReentrantLock 實際調用的是 AQS 的 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;
}
進去 release 方法,發現實際調用的還是 ReentrantLock 自己實現的 tryRelease 方法。
protected final boolean tryRelease(int releases) {
//修改AQS的state
int c = getState() - releases;
//當前線程不是持有鎖線程,拋出異常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
//是否完全釋放鎖資源標識
boolean free = false;
if (c == 0) {
//修改標識,表示完全釋放
free = true;
//將占用鎖資源的屬性設置為null
setExclusiveOwnerThread(null);
}
//設置state值
setState(c);
//為true表示當前線程完全釋放資源
//為false表示當前線程未完全釋放
return free;
}
以上就是釋放資源的實現原理。
好了,通過對 ReentrantLock 的實現分析完后,你對 AQS 底層的原理是不是了解得更多了呢?那么你知道怎么學習其他同步器都是如何實現的了嗎?
最后,我們再看來看一個問題,為什么 AQS 要使用雙向鏈表呢?
首先,我們來看下雙向鏈表的特點,雙向鏈表有兩個指針,一個指針指向前置節點,一個指針指向后繼節點,因此可以快速找到前置節點。雙向鏈表支持在兩端進行高效的操作,尾部添加新節點,頭部移除節點。可以保證先進先出的順序,實現一定的公平性。
AQS 在多個地方需要獲取前置節點的信息,比如在入隊時需要判斷前置節點的狀態來決定是否阻塞;
在線程自旋阻塞時,只有頭節點的下一節點才需要競爭鎖,否則全部都去爭搶會造成羊群效應,為了避免這個問題,加入到鏈表的節點在爭搶鎖之前需要判斷前置節點是否頭節點。
而在單向鏈表中,去查找前置節點的效率顯然比雙向鏈表低很多。
擴展:CountDownLatch 是如何實現的呢?