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