前言
AbstractQueuedSynchronizer即AQS隊列同步器,可能有不少朋友聽說過這個名詞,但是不瞭解它的用途、原理,也可能聽都沒有聽說過這個類…(面壁吧…) 但要提到ReentrantLock,那大家應該就不會陌生了。
沒錯!ReentrantLock就是基於AQS實現的,不僅僅是ReentrantLock,還有諸如Lock、Semaphore、CountDownLatch的底層都是靠它實現的,也許平時在使用的時候,直接一個lock()
鎖住,然後執行完業務代碼後,一個unlock()
解鎖,相當方便!但優秀的你絕對不會錯過了解它們的底層實現原理的機會吧!
很好,如果有興趣就接着往下看唄~
透過ReentrantLock看AQS
既然大家更加熟悉ReentrantLock,那麼以ReentrantLock爲切入點來講解AQS再好不過了~
ReentrantLock被稱之爲可重入鎖,屬於悲觀鎖的一種(關於什麼是樂觀鎖,什麼是悲觀鎖這裏就不細講啦)。本文主要會從線程獲取鎖的流程,獲取鎖失敗後的入隊、出隊流程,隊列結構,以及鎖的釋放等方面做詳細的分析。
ReentrantLock與Synchronized的對比
ReentrantLock通常用來和Synchronized作比較,其實在性能方面,Synchronized和ReentrantLock基本上是持平的,但是相比Synchronized,後者的功能更加的豐富,操作更加靈活,因此比較適合複雜的併發場景。
ReentrantLock | Synchronized | |
---|---|---|
支持的鎖類型 | 非公平鎖&公平鎖 | 非公平鎖 |
靈活性 | 相對靈活,支持響應中斷、超時以及嘗試獲取鎖操作 | 不靈活 |
可重入性 | 可重入 | 可重入 |
釋放鎖 | 需手動調用unlock()方法 | 自動釋放 |
鎖實現機制 | 基於AQS實現 | 監視器模式 |
條件隊列 | 可關聯多個 | 只關聯一個 |
通過下面的代碼,大家可以對比下
// ---------------------Synchronized------------------------------------
public static void testSynchronized() {
for (int i = 0; i < 100; i++) {
// 可重入,可用於方法或者代碼塊
synchronized (LockTest.class) {
LOGGER.info("get syn lock[{}]",i);
}
}
}
// ---------------------ReentrantLock-----------------------------------
public static void testReentrantLock() throws InterruptedException{
// 初始化可重入鎖並選擇鎖類型 true:公平鎖 false:非公平鎖
ReentrantLock lock = new ReentrantLock(true);
for (int i = 0; i < 100; i++) {
// 手動上鎖
lock.lock();
try {
try {
// 嘗試獲取鎖,等待時間1000毫秒(可支持多種加鎖方式,比較靈活; 具有可重入特性)
if (lock.tryLock(1000, TimeUnit.MILLISECONDS)) {
LOGGER.info("get reentrant lock[{}]",i);
}
} finally {
// 手動釋放鎖
lock.unlock();
}
} finally {
lock.unlock();
}
}
}
很明顯可以看出,當使用ReentrantLock時,在lock()和unlock()之間,可以穿插tryLock()等方法,根據業務需求靈活多變的處理。以上代碼執行完之後的結果如下,可以看到性能方面都差不多的…
ReentrantLock的公平鎖和非公平鎖
AQS,AbstractQueuedSynchronizer,即隊列同步器。它是構建鎖或者其他同步組件的基礎框架(如ReentrantLock、ReentrantReadWriteLock、Semaphore等),JUC併發包的作者(Doug Lea)期望它能夠成爲實現大部分同步需求的基礎。它是JUC併發包中的核心基礎組件。
ReentrantLock相比Synchronized,除了支持非公平鎖,還支持公平鎖,公平鎖是指當鎖可用時,在該鎖上等待時間最長的線程將獲得鎖的使用權(先來後到)。而非公平鎖則隨機分配這種使用權。很顯然,使用隨機的方案帶來的好處是性能更高。以下是創建公平鎖和非公平鎖的方式。
// 創建公平鎖
ReentrantLock lock = new ReentrantLock(true);
// 創建非公平鎖
ReentrantLock lock = new ReentrantLock(false);
具體的構造函數源碼如下,不難看出fair的值會控制ReentrantLock的類型
public ReentrantLock(boolean fair) {
// 通過fair值,來創建不同的鎖對象
sync = fair ? new FairSync() : new NonfairSync();
}
我們可以做個小實驗,看看它們兩者的區別。先來使用公平鎖測試,同時創建5個線程去獲取鎖,並且給每個線程兩次獲取鎖的機會,注意觀察線程獲取鎖的規則。
public class ReentrantLockTest {
// 創建公平鎖
static Lock lock = new ReentrantLock(true);
public static void main(String[] args) {
// 創建五個線程執行任務
for (int i = 0; i < 5; i++) {
new Thread(new ThreadDemo(i)).start();
}
}
static class ThreadDemo implements Runnable {
private static final Logger LOGGER = LoggerFactory.getLogger(ThreadDemo.class);
private Integer id;
public ThreadDemo(Integer id) {
this.id = id;
}
@Override
public void run() {
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0; i < 2; i++) {
lock.lock();
LOGGER.info("線程[{}]獲得鎖", id);
lock.unlock();
}
}
}
}
通過運行結果不難看出,這5個線程每個線程都獲取釋放鎖兩次,並且是輪流有序的獲取到鎖。我們再將參數fair改爲false測試一下非公平鎖,看看效果。
此時的線程會重複獲取鎖。假設此時有很多個線程去獲取鎖,並且同一個線程獲取鎖的機會更多,那麼可能會造成某些線程長時間得不到鎖,就好比你去KFC點餐,可能你前面的那個人隨同的夥伴有很多,本來他已經把他自己的餐點好了,結果他的小夥伴說,夥計幫我也點一份,雖然他剛買完單,但是位置還是排在第一個,那麼他就可以繼續點了,當然後面還在排隊的人肯定會不滿,畢竟這跟插隊沒兩樣… 還可能導致糾紛…
這就是非公平鎖的“飢餓”問題所在。
ReentrantLock與AQS的關聯
說了這麼多ReentrantLock的公平鎖和非公平鎖,主要是爲了研究AQS原理做個鋪墊,通過FairSync和NonfairSync的實現,我們可以更深入的瞭解ReentrantLock和AQS的關係,以及AQS的底層原理。其實在剛剛的實驗中,我們已經發現了公平鎖和非公平鎖的不同之處。既然ReentrantLock的底層是由AQS來實現的,那麼公平鎖和非公平鎖是如何通過AQS實現的呢?我們不得不打開ReentrantLock的源碼一探究竟啦!
非公平鎖源碼中的加鎖流程如下
final void lock() {
// 若通過CAS設置變量State,成功則設置線程爲獨佔鎖
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
// 失敗則調用acquire方法,進入後續的鎖獲取候選流程
acquire(1);
}
通過CAS(Compare And Swap)方式設置變量State(同步狀態),若成功則設置當前線程爲獨佔鎖,若失敗,則調用acquire方法,進入後續的鎖獲取候選流程。獲取獨佔鎖,顧名思義就是鎖被這個線程獨佔了,其他的線程得等到我釋放鎖之後才能去獲取,這個很好理解。那麼獲取失敗後的線程將何去何從呢?
其實我們試着猜想一下,也能得出結論。無非就兩種情形,要麼獲取鎖失敗之後,將狀態位設置爲失敗,或者放棄,並結束鎖獲取流程。但是這種設計顯然不符合我們的設計理念,極大的降低系統併發,並極有可能造成大量的請求無法正常處理並返回結果。
顯然,爲了解決這樣的問題,就一定會存在某種隊列結構,來接收穫取鎖失敗的線程。如果真的是如我們所猜想有這麼個結構存在,那麼在隊列中的線程何時可以在此獲取到鎖呢?如果再次獲取失敗該如何處理呢?假設一直失敗一直重入隊列等待,會不會造成死循環問題?
這所有謎團的答案都在acquire(1)
方法中,那麼帶着這些疑問我們再來看公平鎖源碼。
方法名稱 | 解釋 |
---|---|
tryAcquire | 嘗試獲取獨佔鎖 |
tryRelease | 嘗試釋放獨佔鎖 |
tryAcquireShared | 嘗試獲取共享鎖 |
tryReleaseShared | 嘗試釋放共享鎖 |
isHeldExclusively | 判斷當前線程是否獲得了獨佔鎖 |
公平鎖源碼中的加鎖流程如下
發現公平鎖中的lock()
加鎖,並沒有CAS判斷,而是直接調用acquire(1)
方法?
好啦,就不賣關子了,我們直接分析acquire(1)
的源碼。
acquire(1)
中會先嚐試獲取獨佔鎖,調用tryAcquire方法,如果獲取成功則直接返回,如果未獲取成功,則將當前線程包裝成Node,並加入同步隊列等待(在隊列中會檢測是否前驅爲HEAD,並嘗試獲取鎖,如果獲取失敗,則會通過LockSupport阻塞當前線程,直至被釋放鎖的線程喚醒或者被中斷,隨後再次嘗試獲取鎖,並反覆執行該流程)其實通過這個方法就更加印證了我們之前關於等候隊列的猜想是正確的。selfInterrupt()
意爲產生一箇中斷,如果在acquireQueued()中當前線程被中斷過,則需要產生一箇中斷(畫個圈,後面會重點說明爲什麼這個地方需要手動去中斷線程)。爲了更好的理解源碼中的方法,我會通過繪圖的方式來加深大家的理解。
AQS的隊列結構及狀態位說明
通過上面的分析,我們已經知道獲取鎖失敗的線程會加入到隊列中,這樣做的好處是避免鎖被釋放的瞬間,所有的線程都去爭搶資源,減少併發衝突,避免了"驚羣效應",假設有10000個線程等待獲取鎖,當鎖被釋放後,只會通知隊列中的第一個線程去競爭鎖。那麼這個隊列到底是個什麼結構呢?
CLH隊列變體
CLH:Craig、Landin and Hagersten隊列,是單向鏈表,AQS中的隊列是CLH變體的虛擬雙向隊列(FIFO),當多線程爭用資源被阻塞時會進入此隊列,AQS是通過將每條請求共享資源的線程封裝成一個節點來實現鎖的分配。
爲什麼說AQS中的隊列是CLH變體的虛擬雙向隊列呢?因爲AQS中的隊列不存在隊列實例,僅存在結點之間的關聯關係,原始CLH隊列,一般用於實現自旋鎖,而在變種CLH隊列中,獲取不到鎖的線程,一般會時而阻塞,時而喚醒。AQS還維護了一個volatile修飾的State同步狀態位(代表共享資源),對每一個線程都是可見的,並通過內置的FIFO隊列來完成資源獲取的排隊工作,通過CAS完成對State值的修改(CAS操作必須得依靠volatile修飾的變量來實現同步)。
還有一個需要關注的點就是隊列中的Node(即CLH變體隊列中的節點),它實質上是對線程的一個封裝。
屬性或方法名稱 | 解釋 |
---|---|
prev | 前驅指針地址 |
next | 後繼指針地址 |
thread | 被封裝爲節點的線程 |
waitStatus | 線程在隊列中的等待狀態 |
nextWaiter | 指向下一個處於CONDITION狀態的節點 |
predecessor | 返回前驅節點,若節點不存在則拋出空指針異常 |
當然,細心的各位也應該發現了addWaiter()
方法中傳入的Node爲Node.EXCLUSIVE
,它表示線程正在以獨佔的方式等待鎖,當然除了獨佔鎖,還有一種模式爲Node.SHARED
,表示線程以共享的模式等待鎖。線程在隊列的等待狀態waitStatus
在AQS中也是非常重要的概念,直接決定了哪些線程可以獲取鎖,哪些線程需要繼續等待,哪些線程要被cancel並淘汰。
枚舉值 | 解釋 |
---|---|
CANCELLED | 爲1,表示線程獲取鎖的請求已經取消了 |
SIGNAL | 爲-1,表示線程已經準備好了,就等資源釋放了 |
CONDITION | 爲-2,表示節點在等待隊列中,節點線程等待喚醒 |
PROPAGATE | 爲-3,當前線程鎖模式處在SHARED共享情況下,該字段纔會使用 |
0 | Node被初始化的時候的默認值 |
狀態位State
剛剛我們提到的AQS維護的一個volatile
修飾的State同步狀態位,主要用於展示當前資源的獲取鎖的情況,獨佔模式下和共享模式下,根據State同步狀態位判斷獲鎖情況的方式不一樣,獨佔模式下,會初始化狀態位State爲0,此時當線程嘗試去獲取鎖時,會判斷當前的State的值是否爲0,如果爲0說明鎖沒有被獲取,那麼該線程獲取鎖之後設置State爲1,此時當其他線程來獲取鎖時,發現狀態位爲1,就去排隊了。
而共享模式下,代表可以有多個線程一起共享鎖,但是很顯然這個可獲取鎖的線程數量是有限制的,那麼此時獲取鎖的規則是,先初始化State的值,每當一個線程獲取到了共享鎖之後,就會減少State的值,當State小於等於0之後,且其他擁有鎖的線程沒有釋放鎖的情況下,當前線程就會被阻塞。
在AQS中提供下面幾個方法用於獲取及操作狀態位State。
方法名稱 | 解釋 |
---|---|
protected final int getState() | 獲取狀態位的值 |
protected final void setState(int newState) | 設置狀態位的值 |
protected final boolean compareAndSetState(int expect, int update) | 通過CAS方式更新狀態位的值 |
深入ReentrantLock源碼分析AQS
說了這麼多關於AQS的結構,也是爲了下面深入AQS源碼講解做鋪墊。我們繼續回到ReentrantLock的acquire(1)
方法上來。剛纔我們講到當acquire(1)
方法中的tryAcquire()
方法返回了True,則說明當前線程獲取鎖成功,如果獲取失敗,就需要加入到等待隊列中。在非公平鎖中的tryAcquire()
本質上是調用nonfairTryAcquire(int acquires)
,acquires
的值爲1,關於該方法的分析如下。
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
// 獲取狀態位
int c = getState();
if (c == 0) {
// 當狀態位爲0,通過CAS方式判斷當前線程是否被篡改,若沒有則設置當前線程獨佔鎖
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 若當前線程已經持有鎖了
else if (current == getExclusiveOwnerThread()) {
// 則設置狀態位爲1
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
// 以上條件都不滿足則返回false,阻塞線程
return false;
}
tryAcquire(int arg)
方法在AQS源碼中只是簡單的拋了一個異常,因此上面的tryAcquire(int arg)
方法其實已經被ReentrantLock重寫了。
而在公平鎖中的tryAcquire(int arg)
方法就改動了下面這部分,添加了hasQueuedPredecessors()
判斷。用於加鎖時判斷等待隊列中是否存在有效節點的方法。如果返回false,說明當前線程有去爭取鎖的資格,如果返回true,說明隊列中存在有效節點,當前線程必須加入到等待隊列中。
final boolean nonfairTryAcquire(int acquires) {
...
if (c == 0) {
// 當狀態位爲0,通過CAS方式判斷當前線程是否被篡改,若沒有則設置當前線程獨佔鎖
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
...
}
AQS中線程入隊分析
通過上面的分析,我們已經知道了線程是如何通過修改狀態位的值判斷是否能獲取鎖的,以及獲取鎖失敗之後會進入等待隊列中,同時也大致清楚隊列的結構了,那麼將線程添加到隊列這個動作具體在源碼中怎麼體現的呢?
我們先來研究acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
中的addWaiter(Node mode)
方法。
// 將獲取鎖失敗的線程置入隊列中
private Node addWaiter(Node mode) {
// 將當前的線程封裝爲NODE節點,模式爲 EXCLUSIVE 獨佔模式
Node node = new Node(Thread.currentThread(), mode);
// 獲取尾節點
Node pred = tail;
if (pred != null) {
// 如果尾節點不爲空,則設置當前節點的前一個節點爲之前獲取的尾節點
node.prev = pred;
// 通過CAS的方式設置之前尾節點的下一個節點爲當前節點
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 如果尾節點爲null,執行該方法
enq(node);
return node;
}
// 通過CAS的方式設置尾節點,對tailOffset和Expect進行比較
// 如果tailOffset的Node和Expect的Node地址是相同的,那麼設置Tail的值爲Update的值
private final boolean compareAndSetTail(Node expect, Node update) {
return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}
// 當尾節點爲null時,通過循環+CAS在隊列中成功插入一個節點後返回
private Node enq(final Node node) {
for (;;) {
Node t = tail;
// 初始化head和tail,這個條件是必須的!
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
// 通過CAS的方式設置之前尾節點的下一個節點爲當前節點
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
通過上方的源碼很明顯可以看出,這個隊列是一個雙向隊列,並且新增節點都是往尾部進行添加的。可能大家會有疑惑,就是addWaiter
和enq
方法中新增一個節點時爲什麼要先將新節點的prev設爲tail再嘗試CAS,而不是CAS成功後再進行雙向鏈接操作?(如下所示) 這樣會不會更安全?
if (compareAndSetTail(pred, node)) {
node.prev = pred;
pred.next = node;
return node;
}
因爲雙向鏈表目前沒有基於CAS原子插入的手段,如果按照上面的代碼去執行,此時pred爲方法執行時讀到的tail,引用封閉在棧上,會導致這一瞬間的tail也就是pred的prev爲null,這就使得這一瞬間隊列處於一種不一致的中間狀態。因此,AQS通過將這兩個操作(node.prev = pred
和pred.next = node
)分離在CAS前後的好處是,保證每時每刻的tail.prev
都不會是一個null值(保證隊列的完整)。其實細品AQS的源碼會發現很多套路。
在enq(final Node node)
這個方法中還有一點需要注意的是,當獲取不到tail時,會初始化一個Node爲head頭節點,並且使tail指向head,但此時的頭結點並不是當前線程節點,而是調用了無參構造函數的節點,也就是該節點不持有線程,不存儲任何信息,只是佔位。因此,真正的第一個存儲數據的節點,是在第二個節點開始的
綜上我們可以完善一下之前畫的原理圖。
AQS中線程出隊分析
上文解釋了addWaiter方法,該方法就是將沒有獲取到鎖的線程以Node的數據結構加入一個雙向隊列中,並返回一個包含線程的Node節點,再回到acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
方法,該方法可以讓排隊中的線程不斷的進行"獲鎖"操作,直到獲取鎖(中斷)。
final boolean acquireQueued(final Node node, int arg) {
// 標記線程是否獲鎖成功
boolean failed = true;
try {
// 標記線程在等待過程中是否中斷過
boolean interrupted = false;
// 開始自旋,要麼獲取鎖,要麼中斷
for (;;) {
// 獲取當前節點的前驅節點
final Node p = node.predecessor();
// 如果p是頭結點(虛節點),說明當前節點在真實數據隊列的首部,就嘗試獲取鎖
if (p == head && tryAcquire(arg)) {
// 獲取鎖成功,頭指針移動到當前node
setHead(node);
// 通知GC回收
p.next = null;
failed = false;
return interrupted;
}
// 執行到此,說明p爲頭節點且當前沒有獲取到鎖(可能是非公平鎖被搶佔了)或者是p不爲頭結點,這個時候就要判斷當前node是否要被阻塞(被阻塞條件:前驅節點的waitStatus爲-1),防止無限循環浪費資源。
if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
以上源碼中的setHead(node)
操作是將當前已經獲取到鎖資源的節點設置爲虛節點(清空數據,並將head的指針指向該節點),但是並沒有對該節點的waitStatus進行修改,原因是這個節點還需要繼續被使用到(除非已經不使用了,比方說它的下一個節點變成了頭節點)。
當p爲頭節點且當前沒有獲取到鎖(可能是非公平鎖被搶佔了)或者是p不爲頭結點時會執行下面的方法,判斷當前線程是否應該被阻塞。
// 靠前驅節點判斷當前線程是否應該被阻塞
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 獲取頭結點的節點狀態
int ws = pred.waitStatus;
// 當頭結點處於喚醒狀態時,需要阻塞線程,防止無限循環浪費資源
if (ws == Node.SIGNAL)
return true;
// 通過枚舉值我們知道waitStatus>0即waitStatus=1是取消狀態
if (ws > 0) {
do {
// 循環向前查找取消節點,把取消節點從隊列中剔除
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
// 設置前任節點等待狀態爲SIGNAL
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
parkAndCheckInterrupt
方法主要用於掛起當前線程,阻塞調用棧,並返回當前線程的中斷狀態。
private final boolean parkAndCheckInterrupt() {
// 掛起當前線程
LockSupport.park(this);
return Thread.interrupted();
}
因此,我們可以得出結論,結束循環並出隊的條件是,前驅節點爲頭節點,並且當前的線程成功獲取到了鎖,並且我們可以通過判斷前驅節點的狀態(是否是被喚醒狀態)來決定是否掛起當前線程,而不用進行循環獲取前驅狀態的操作,適當的減少了資源的消耗。總結一下上面的操作如下圖所示。
AQS中的CANCELLED狀態節點生成
通過上面的分析,我們發現當獲取前驅節點的狀態時,會有一部分節點的狀態是1,表示該節點取消循環獲取操作,那麼CANCELLED
狀態節點是如何生成的呢?帶着這個疑問,我們可以觀察acquireQueued()
中的cancelAcquire(node)
方法,這個方法在finally代碼中。
// 取消繼續獲取前驅節點狀態操作
private void cancelAcquire(Node node) {
if (node == null)
return;
// 設置該節點爲虛節點
node.thread = null;
Node pred = node.prev;
// 通過判斷前驅節點的狀態,跳過取消狀態的node
while (pred.waitStatus > 0)
node.prev = pred = pred.prev;
// 獲取過濾後的前驅節點的後繼節點
Node predNext = pred.next;
// 把當前node的狀態設置爲CANCELLED
node.waitStatus = Node.CANCELLED;
// 如果當前節點是尾節點,將從後往前的第一個非取消狀態的節點設置爲尾節點
// 更新失敗的話,則進入else,如果更新成功,將tail的後繼節點設置爲null
if (node == tail && compareAndSetTail(node, pred)) {
compareAndSetNext(pred, predNext, null);
} else {
int ws;
// 如果當前節點不是head的後繼節點
// 則判斷當前節點前驅節點的是否爲SIGNAL,如果不是,則把前驅節點設置爲SINGAL看是否成功
// 如果上述操作中有一個爲true,再判斷當前節點的線程是否不爲空
// 如果上述條件都滿足,把當前節點的前驅節點的後繼指針指向當前節點的後繼節點
if (pred != head && ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null) {
Node next = node.next;
if (next != null && next.waitStatus <= 0)
compareAndSetNext(pred, predNext, next);
} else {
// 如果當前節點是head的後繼節點,或者上述條件不滿足,那就喚醒當前節點的後繼節點
// 主要是爲了保證隊列的活躍性,需要去喚醒一次後繼線程,比如說當pred == head時
// 可能此時並沒有線程持有鎖,更不會去喚醒後繼線程,那麼如果沒有這個操作,就會導致後面的線程
// 都不會被喚醒,整個隊列就會掛掉了
unparkSuccessor(node);
}
node.next = node; // help GC
}
}
上面的流程比較複雜,我們再來梳理一下,首先獲取當前節點的前驅節點,如果前驅節點的狀態是CANCELLED
,就一直往前遍歷,直至找到第一個狀態不爲CANCELLED
的節點,並將當前節點和找到的節點相關聯,並將當前的節點的狀態設置爲CANCELLED
。
以上操作完成之後,根據當前的節點的位置可以分爲三種情況
- 節點爲尾節點
- 節點是頭節點的後繼節點
- 節點既不是頭節點的後繼節點,也不是尾節點
在不同的情形下,都是對節點的next指針進行操作,但是並沒有對prev指針進行操作?
1)執行cancelAcquire的時候,當前節點的前置節點可能已經從隊列中出去了(已經執行過Try代碼塊中的shouldParkAfterFailedAcquire方法了),如果此時修改Prev指針,有可能會導致Prev指向另一個已經移除隊列的Node,因此這塊變化Prev指針不安全。
2)shouldParkAfterFailedAcquire方法中,會執行下面的代碼,其實就是在處理Prev指針。shouldParkAfterFailedAcquire是獲取鎖失敗的情況下才會執行,進入該方法後,說明共享資源已被獲取,當前節點之前的節點都不會出現變化,因此這個時候變更Prev指針比較安全。
do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0);
AQS中如何解鎖並釋放資源
問題來了,已經獲取到鎖的線程資源釋放之後,是怎麼通知到被掛起的線程呢?
我們直接觀察ReentrantLock的unlock()
方法,該方法本質上調用了release()
方法
public final boolean release(int arg) {
// 如果返回true,說明該鎖沒有被任何線程持有
if (tryRelease(arg)) {
Node h = head;
// head節點不爲空,且狀態不爲0(因爲head狀態不可能爲1,因此!=0也可以理解爲 <0)
// 只有這種情形下表明後繼節點可能被阻塞了,需要喚醒
if (h != null && h.waitStatus != 0)
// 喚醒後繼線程
unparkSuccessor(h);
return true;
}
return false;
}
通過上述代碼可以發現release做的事情就是調用tryRelease()
,如果tryRelease返回true也就是獨佔鎖被完全釋放之後,喚醒後繼線程,那麼被掛起的線程就可以去獲取鎖了。那麼tryRelease()
方法中究竟做了些什麼操作呢?一起來看看吧!
// 返回true則表示當前鎖沒有被線程持有,反之亦然
protected final boolean tryRelease(int releases) {
// 減少可重入次數,releases值爲1,則c=0
int c = getState() - releases;
// 如果當前線程不是持有鎖的線程,則拋出異常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
// 標誌當前鎖是不是沒有被線程持有(可釋放的)
boolean free = false;
// 如果持有線程全部釋放,將當前獨佔鎖所有線程設置爲null,並更新state
if (c == 0) {
free = true;
// 設置當前所有鎖的線程爲空
setExclusiveOwnerThread(null);
}
// 設置狀態位爲0,此時新來的線程就可以去爭取鎖了
setState(c);
return free;
}
那麼如何去判斷哪個節點需要被喚醒呢?我們繼續觀察unparkSuccessor(Node node)
方法。
// 喚醒下一個節點
private void unparkSuccessor(Node node) {
// 獲取頭結點狀態
int ws = node.waitStatus;
if (ws < 0)
// 如果頭節點狀態沒有被取消,則設置頭節點狀態爲0(初始化)
compareAndSetWaitStatus(node, ws, 0);
// 獲取頭節點節點的下一個節點
Node s = node.next;
// 如果下個節點是null或者下個節點被cancelled,就找到隊列最開始的非cancelled的節點
if (s == null || s.waitStatus > 0) {
s = null;
// 就從尾部節點開始找,到隊首,找到隊列第一個waitStatus<0的節點。
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
// 如果當前節點的下個節點不爲空,而且狀態<=0,就把當前節點喚醒
if (s != null)
LockSupport.unpark(s.thread);
}
爲什麼喚醒後繼節點時要從tail向前查找最接近node的非取消節點,而不是直接從node向後找到第一呢?
我們可以觀察之前的之前的addWaiter()
方法,因爲節點入隊並不是原子操作,如果在pred.next = node
還沒有執行前執行了unparkSuccessor()
方法,就沒辦法從前往後找了,因爲可能在某個瞬間找到的節點值爲null,所以需要從後往前找。而且在講解AQS中的CANCELLED狀態節點生成時我們也看到了,CANCELLED節點斷開的是next指針,如果從前往後找,很可能會碰到恰好next指針斷開的情況,而連接不上。
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
線程中斷恢復後的執行流程
當線程被喚醒之後,會返回線程的中斷狀態,在parkAndCheckInterrupt()
中有所體現,本質上就是返回了Thread.interrupted()
,這個方法在返回了線程的中斷狀態的同時,會重置線程的中斷狀態位爲未中斷。
private final boolean parkAndCheckInterrupt() {
// 喚醒當前的線程
LockSupport.park(this);
// 返回了線程的中斷狀態,重置線程的中斷狀態位爲未中斷
return Thread.interrupted();
}
因爲中斷的線程被喚醒之後並不知道被喚醒的原因。可能是在等待的過程中被中斷,也有可能是前一個獲取鎖的線程釋放鎖之後喚醒它的,因此通過Thread.interrupted()
可以檢查線程的中斷狀態,如果之前確實被中斷過,那麼在acquireQueued()
方法中就會返回中斷狀態爲true表明線程被中斷過。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
此時再觀察acquire(int arg)
方法,就可以解答我們文章開始提到的一個問題,就是爲什麼在線程獲取鎖失敗,並且加入隊列之後,還要執行selfInterrupt()
自我中斷的方法。因爲如果上一個獲取到鎖的線程還沒有釋放鎖的時候,因爲某種原因喚醒了下一個等待獲取鎖的線程(等待的過程中被中斷),那麼此時如果不調用selfInterrupt()
放任它的話,這個線程就會循環的去獲取上一個節點的狀態,失去了之前中斷它的意義,造成資源浪費,因此如果該線程之前被中斷過的話,證明它是下一個候選的線程,此時需要調用selfInterrupt()
再次中斷它,直至它的上一個線程釋放鎖之後喚醒它爲止。
結合以上代碼分析,以及下面的原理圖,相信下次被面試官問到AQS方面的問題,絕對不虛了!