Java併發:深入淺出AQS之獨佔鎖模式原始碼分析
作者:凌風郎少
原文連結:https://mp.weixin.qq.com/s/0WxKOqfvq1kVJDgk6NwWlg
AbstractQueuedSynchronizer(以下簡稱AQS)作為java.util.concurrent包的基礎,它提供了一套完整的同步程式設計框架,開發人員只需要實現其中幾個簡單的方法就能自由的使用諸如獨佔,共享,條件佇列等多種同步模式。我們常用的比如ReentrantLock,CountDownLatch等等基礎類庫都是基於AQS實現的,足以說明這套框架的強大之處。鑑於此,我們開發人員更應該瞭解它的實現原理,這樣才能在使用過程中得心應手。
總體來說個人感覺AQS的程式碼非常難懂,本文就其中的獨佔鎖實現原理進行分析。
一、執行過程概述
首先先從整體流程入手,瞭解下AQS獨佔鎖的執行邏輯,然後再一步一步深入分析原始碼。
獲取鎖的過程:
1、當執行緒呼叫acquire()申請獲取鎖資源,如果成功,則進入臨界區。
2、當獲取鎖失敗時,則進入一個FIFO等待佇列,然後被掛起等待喚醒。
3、當佇列中的等待執行緒被喚醒以後就重新嘗試獲取鎖資源,如果成功則進入臨界區,否則繼續掛起等待。
釋放鎖的過程:
1、當執行緒呼叫release()進行鎖資源釋放時,如果沒有其他執行緒在等待鎖資源,則釋放完成。
2、如果佇列中有其他等待鎖資源的執行緒需要喚醒,則喚醒佇列中的第一個等待節點(先進先出)。
二、原始碼深入分析
AQS核心實現
用一個雙向連結串列來儲存所有等待鎖的Thread佇列
連結串列中的每一個Node記錄了一個執行緒以及其對應的等待鎖的狀態.
值得注意的是, 在AQS和Node的屬性中各有一個state
AQS中的state
// 代表了當前鎖的狀態, 該鎖即為佇列中的所有Thread所搶佔的鎖,
// 注意, 這個state的取值是不受限制的, 不同於Node中的waitStatus, 這個state只有兩種狀態:
//0代表沒有被佔用,大於0代表有執行緒持有當前鎖
private
volatile
int
state;
Node中的waitStatus
// 代表了當前Node所代表的執行緒的鎖的等待狀態
// 取值範圍有限, 詳見下文Node Field部分
volatile
int
waitStatus;
AQS Field
以下只列出幾個重要的屬性
// 頭結點,大多數情況下就是當前持有鎖的節點
private
transient
volatile
Node
head;
// 尾節點,每一個請求鎖的執行緒會加到隊尾
private
transient
volatile
Node
tail;
// 當前鎖的狀態,0代表沒有被佔用,大於0代表有執行緒持有當前鎖
// 因為存在可重入鎖的情況, 所以該值可能大於1
private
volatile
int
state;
// 代表當前持有獨佔鎖的執行緒,在可重入鎖中可以用這個來判斷當前執行緒是否已經擁有了鎖
// if (currentThread == getExclusiveOwnerThread()) {state++}
private
transient
Thread
exclusiveOwnerThread;
//繼承自AbstractOwnableSynchronizer
Node Field
以下只列出幾個重要的屬性
static
final
class
Node
{
/* Marker to indicate a node is waiting in shared mode /
static
final
Node
SHARED =
new
Node
();
/* Marker to indicate a node is waiting in exclusive mode /
static
final
Node
EXCLUSIVE =
null
;
/* waitStatus value to indicate thread has cancelled /
static
final
int
CANCELLED =
1
;
/* waitStatus value to indicate successor`s thread needs unparking /
static
final
int
SIGNAL = –
1
;
/* waitStatus value to indicate thread is waiting on condition /
static
final
int
CONDITION = –
2
;
/**
* waitStatus value to indicate the next acquireShared should
* unconditionally propagate
*/
static
final
int
PROPAGATE = –
3
;
volatile
int
waitStatus;
// 前置節點
volatile
Node
prev;
// 後置節點
volatile
Node
next;
// 節點所代表的執行緒
volatile
Thread
thread;
Node
nextWaiter;
}
acquire(int arg)
基於上面所講的獨佔鎖獲取釋放的大致過程,我們再來看下原始碼實現邏輯:
首先來看下獲取鎖的方法acquire()
public
final
void
acquire(
int
arg) {
if
(!tryAcquire(arg) &&
acquireQueued(addWaiter(
Node
.EXCLUSIVE), arg))
selfInterrupt();
}
程式碼雖然短,但包含的邏輯卻很多,一步一步看下:
1、首先是呼叫開發人員自己實現的 tryAcquire() 方法嘗試獲取鎖資源,如果成功則整個 acquire()方法執行完畢,即當前執行緒獲得鎖資源,可以進入臨界區。
2、如果獲取鎖失敗,則開始進入後面的邏輯,首先是 addWaiter(Node.EXCLUSIVE)方法。來看下這個方法的原始碼實現
addWaiter(Node)
此方法用於將當前執行緒加入到等待佇列的隊尾,並返回當前執行緒所在的節點。
//注意:該入隊方法的返回值就是新建立的節點
private
Node
addWaiter(
Node
mode) {
//基於當前執行緒,節點型別(Node.EXCLUSIVE)建立新的節點
//由於這裡是獨佔模式,因此節點型別就是Node.EXCLUSIVE
Node
node =
new
Node
(
Thread
.currentThread(), mode);
Node
pred = tail;
//這裡為了提搞效能,首先執行一次快速入隊操作,即直接嘗試將新節點加入隊尾
if
(pred !=
null
) {
node.prev = pred;
//這裡根據CAS的邏輯,即使併發操作也只能有一個執行緒成功並返回,其餘的都要執行後面的入隊操作。即enq()方法
if
(compareAndSetTail(pred, node)) {
pred.next = node;
return
node;
}
}
//上一步失敗則通過enq入隊。
enq(node);
return
node;
}
enq(final Node node)
此方法用於將node加入隊尾
//完整的入隊操作
private
Node
enq(
final
Node
node) {
// CAS 自旋 ,直到成功加入隊尾
for
(;;) {
Node
t = tail;
//如果佇列還沒有初始化,則進行初始化,即建立一個空的頭節點
if
(t ==
null
) {
//同樣是CAS,只有一個執行緒可以初始化頭結點成功,其餘的都要重複執行迴圈體
if
(compareAndSetHead(
new
Node
()))
tail = head;
}
else
{
//新建立的節點指向佇列尾節點,毫無疑問併發情況下這裡會有多個新建立的節點指向佇列尾節點
node.prev = t;
//基於這一步的CAS,不管前一步有多少新節點都指向了尾節點,這一步只有一個能真正入隊成功,其他的都必須重新執行迴圈體
if
(compareAndSetTail(t, node)) {
t.next = node;
//該迴圈體唯一退出的操作,就是入隊成功(否則就要無限重試)
return
t;
}
}
}
}
上面的入隊操作有兩點需要說明:
1、初始化佇列的觸發條件就是當前已經有執行緒佔有了鎖資源,因此上面建立的空的頭節點可以認為就是當前佔有鎖資源的節點(雖然它並沒有設定任何屬性)。
2、注意 enq(finalNodenode)程式碼是,是一個經典的CAS自旋操作,直到成功加入隊尾,否則一直重試。
經過上面的操作,我們申請獲取鎖的執行緒已經成功加入了等待佇列,通過文章最一開始說的獨佔鎖獲取流程,那麼節點現在要做的就是掛起當前執行緒,等待被喚醒,這個邏輯是怎麼實現的呢?來看下原始碼:
acquireQueued(final Node node, int arg)
通過 tryAcquire()和 addWaiter(),如果執行緒獲取資源失敗,已經被放入等待佇列尾部了。
如果執行緒獲取資源失敗,下一步進入等待狀態休息,直到其他執行緒徹底釋放資源後,喚醒自己再拿到資源,在等待佇列中排隊拿號,直到拿到號後再返回。(佇列先進先出)
通過上面的分析,該方法入參node就是剛入隊的包含當前執行緒資訊的節點
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
;
//setHead中node.prev已置為null,此處再將head.next置為null,就是為了方便GC回收以前的head結點。也就意味著之前拿完資源的結點出隊了!
//表示鎖資源成功獲取,因此把failed置為false
failed =
false
;
//返回中斷標記,表示當前節點是被正常喚醒還是被中斷喚醒
return
interrupted;
}
//如果沒有獲取鎖成功,則進入掛起邏輯
if
(shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
//如果等待過程中被中斷過,哪怕只有那麼一次,就將interrupted標記為true
interrupted =
true
;
}
}
finally
{
//最後會分析獲取鎖失敗處理邏輯
if
(failed)
cancelAcquire(node);
}
}
掛起邏輯是很重要的邏輯,這裡拿出來單獨分析一下,首先要注意目前為止,我們只是根據當前執行緒,節點型別建立了一個節點並加入佇列中,其他屬性都是預設值。
shouldParkAfterFailedAcquire(Node pred, Node node)
此方法主要用於檢查狀態,看看自己是否真的可以去休息了,進入 waiting狀態
//首先說明一下引數,node是當前執行緒的節點,pred是它的前置節點
private
static
boolean
shouldParkAfterFailedAcquire(
Node
pred,
Node
node) {
//獲取前置節點的waitStatus
int
ws = pred.waitStatus;
if
(ws ==
Node
.SIGNAL)
//如果前置節點的waitStatus是Node.SIGNAL則返回true,然後會執行parkAndCheckInterrupt()方法進行掛起
return
true
;
if
(ws >
0
) {
//由waitStatus的幾個取值可以判斷這裡表示前置節點被取消
do
{
node.prev = pred = pred.prev;
}
while
(pred.waitStatus >
0
);
//這裡我們由當前節點的前置節點開始,一直向前找最近的一個沒有被取消的節點
//注,由於頭結點head是通過new Node()建立,它的waitStatus為0,因此這裡不會出現空指標問題,也就是說最多就是找到頭節點上面的迴圈就退出了
pred.next = node;
}
else
{
//根據waitStatus的取值限定,這裡waitStatus的值只能是0或者PROPAGATE,那麼我們把前置節點的waitStatus設為Node.SIGNAL然後重新進入該方法進行判斷
compareAndSetWaitStatus(pred, ws,
Node
.SIGNAL);
}
return
false
;
}
上面這個方法邏輯比較複雜,它是用來判斷當前節點是否可以被掛起,也就是喚醒條件是否已經具備,即如果掛起了,那一定是可以由其他執行緒來喚醒的。該方法如果返回false,即掛起條件沒有完備,那就會重新執行 acquireQueued()方法的迴圈體,進行重新判斷,如果返回 true,那就表示萬事俱備,可以掛起了,就會進入 parkAndCheckInterrupt()方法看下原始碼:
parkAndCheckInterrupt()
private
final
boolean
parkAndCheckInterrupt() {
LockSupport
.park(
this
);
//呼叫park()使執行緒進入waiting狀態
//被喚醒之後,返回中斷標記,即如果是正常喚醒則返回false,如果是由於中斷醒來,就返回true
return
Thread
.interrupted();
}
注意: Thread.interrupted()會清除當前執行緒的中斷標記位。
park()會讓當前執行緒進入 waiting狀態。在此狀態下,有兩種途徑可以喚醒該執行緒:1,被 unpark();2,被 interrupt()
看 acquireQueued方法中的原始碼,如果是因為中斷醒來,那麼就把中斷標記置為 true。
不管是正常被喚醒還是由與中斷醒來,都會去嘗試獲取鎖資源。如果成功則返回中斷標記,否則繼續掛起等待。
Thread.interrupted()方法在返回中斷標記的同時會清除中斷標記,也就是說當由於中斷醒來然後獲取鎖成功,那麼整個 acquireQueued方法就會返回 true
表示是因為中斷醒來,但如果中斷醒來以後沒有獲取到鎖,繼續掛起,由於這次的中斷已經被清除了,下次如果是被正常喚醒,那麼 acquireQueued方法就會返回 false,表示沒有中斷。
看了 shouldParkAfterFailedAcquire(Nodepred,Nodenode)和 parkAndCheckInterrupt(),現在讓我們再回到 acquireQueued(finalNodenode,intarg),總結下該函式的具體流程:
節點進入隊尾後,檢查狀態,是否可以被掛起去休息;
呼叫 park進入 waiting狀態,等待 unpark()或 interrupt()喚醒自己;
被喚醒後,看自己是不是有資格能拿到號。如果拿到,head指向當前結點,並返回從入隊到拿到號的整個過程中是否被中斷過;如果沒拿到,繼續流程1。
cancelAcquire(Node node)
最後我們回到 acquireQueued方法的最後一步, finally模組。這裡是針對鎖資源獲取失敗以後做的一些善後工作,翻看上面的程式碼,其實能進入這裡的就是 tryAcquire()方法丟擲異常,也就是說AQS框架針對開發人員自己實現的獲取鎖操作如果丟擲異常,也做了妥善的處理,一起來看下原始碼:
//傳入的方法引數是當前獲取鎖資源失敗的節點
private
void
cancelAcquire(
Node
node) {
// 如果節點不存在則直接忽略
if
(node ==
null
)
return
;
node.thread =
null
;
// 跳過所有已經取消的前置節點,跟上面的那段跳轉邏輯類似
Node
pred = node.prev;
while
(pred.waitStatus >
0
)
node.prev = pred = pred.prev;
//這個是前置節點的後繼節點,由於上面可能的跳節點的操作,所以這裡可不一定就是當前節點,仔細想一下。^_^
Node
predNext = pred.next;
//把當前節點waitStatus置為取消,這樣別的節點在處理時就會跳過該節點
node.waitStatus =
Node
.CANCELLED;
//如果當前是尾節點,則直接刪除,即出隊
//注:這裡不用關心CAS失敗,因為即使併發導致失敗,該節點也已經被成功刪除
if
(node == tail && compareAndSetTail(node, pred)) {
compareAndSetNext(pred, predNext,
null
);
}
else
{
int
ws;
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
)
//這裡的判斷邏輯很繞,具體就是如果當前節點的前置節點不是頭節點且它後面的節點等待它喚醒(waitStatus小於0),
//再加上如果當前節點的後繼節點沒有被取消就把前置節點跟後置節點進行連線,相當於刪除了當前節點
compareAndSetNext(pred, predNext, next);
}
else
{
//進入這裡,要麼當前節點的前置節點是頭結點,要麼前置節點的waitStatus是PROPAGATE,直接喚醒當前節點的後繼節點
unparkSuccessor(node);
}
node.next = node;
// help GC
}
}
上面就是獨佔模式獲取鎖的核心原始碼,確實非常難懂,很繞,就這幾個方法需要反反覆覆看很多遍,才能慢慢理解。
release(int arg)
接下來看下釋放鎖的過程:
此方法是獨佔模式下執行緒釋放共享資源的頂層入口。它會釋放指定量的資源,如果徹底釋放了(即 state=0),它會喚醒等待佇列裡的其他執行緒來獲取資源。這也正是 unlock()的語義,當然不僅僅只限於 unlock()。下面是 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()方法是使用者自定義的釋放鎖邏輯,如果成功,就判斷等待佇列中有沒有需要被喚醒的節點(waitStatus為0表示沒有需要被喚醒的節點),一起看下喚醒操作:
private
void
unparkSuccessor(
Node
node) {
//這裡,node一般為當前執行緒所在的節點。
int
ws = node.waitStatus;
if
(ws <
0
)
//把標記為設定為0,表示喚醒操作已經開始進行,提高併發環境下效能
compareAndSetWaitStatus(node, ws,
0);
Node
s = node.next;
//如果當前節點的後繼節點為null,或者已經被取消
if
(s ==
null
|| s.waitStatus >
0
) {
s =
null
;
//注意這個迴圈沒有break,也就是說它是從後往前找,一直找到離當前節點最近的一個等待喚醒的節點
for
(
Node
t = tail; t !=
null
&& t != node; t = t.prev)
if
(t.waitStatus <=
0
)
s = t;
}
//執行喚醒操作
if
(s !=
null
)
LockSupport
.unpark(s.thread);
//喚醒
}
這個函式並不複雜。一句話概括:用 unpark()喚醒等待佇列中最前邊的那個未放棄執行緒,這裡我們也用 s來表示吧。此時,再和 acquireQueued()聯絡起來,s被喚醒後,進入 if(p==head&&tryAcquire(arg))的判斷(即使 p!=head也沒關係,它會再進入 shouldParkAfterFailedAcquire()尋找一個安全點。這裡既然 s已經是等待佇列中最前邊的那個未放棄執行緒了,那麼通過 shouldParkAfterFailedAcquire()的調整, s也必然會跑到 head的 next結點,下一次自旋 p==head就成立啦),然後 s把自己設定成 head標杆結點,表示自己已經獲取到資源了, acquire()也返回了!!
三、總結
以上就是AQS獨佔鎖的獲取與釋放過程,大致思想很簡單,就是嘗試去獲取鎖,如果失敗就加入一個佇列中掛起。釋放鎖時,如果佇列中有等待的執行緒就進行喚醒。但如果一步一步看原始碼,會發現細節非常多,很多地方很難搞明白,我自己也是反反覆覆學習很久才有點心得,但也不敢說已經研究通了AQS,甚至不敢說我上面的研究成果就是對的,只是寫篇文章總結一下,跟同行交流交流心得。
除了獨佔鎖,後面還會產出AQS一系列的文章,包括共享鎖,條件佇列的實現原理等。
相關文章
- AQS原始碼深入分析之獨佔模式-ReentrantLock鎖特性詳解AQS原始碼模式ReentrantLock
- 深入理解Java併發框架AQS系列(三):獨佔鎖(Exclusive Lock)Java框架AQS
- Java併發之AQS原始碼分析(二)JavaAQS原始碼
- 逐行分析AQS原始碼(2)——獨佔鎖的釋放AQS原始碼
- 深入淺出AQS原始碼解析AQS原始碼
- 【Java併發】【AQS鎖】鎖在原始碼中的應用JavaAQS原始碼
- java 併發程式設計-AQS原始碼分析Java程式設計AQS原始碼
- Java併發包原始碼學習系列:ReentrantLock可重入獨佔鎖詳解Java原始碼ReentrantLock
- Java併發程式設計之鎖機制之AQSJava程式設計AQS
- 併發程式設計之——寫鎖原始碼分析程式設計原始碼
- 深入理解Java併發框架AQS系列(四):共享鎖(Shared Lock)Java框架AQS
- 圖解AQS系列(上)--獨佔鎖圖解AQS
- 解讀 JUC —— AQS 獨佔模式AQS模式
- 併發程式設計之:AQS原始碼解析程式設計AQS原始碼
- Java併發指南10:Java 讀寫鎖 ReentrantReadWriteLock 原始碼分析Java原始碼
- 深入淺出Java多執行緒(十一):AQSJava執行緒AQS
- Java併發之AQS詳解JavaAQS
- Java併發之AQS原理剖析JavaAQS
- 深入淺出 Java 併發程式設計 (1)Java程式設計
- 深入淺出 Java 併發程式設計 (2)Java程式設計
- 【JavaSE】Lock鎖,獨佔鎖ReentrantLock的AQS原始碼,如何管理同步佇列。acquire方法和release方法JavaReentrantLockAQS原始碼佇列UI
- 死磕 java併發包之AtomicInteger原始碼分析Java原始碼
- 死磕 java併發包之LongAdder原始碼分析Java原始碼
- java併發之hashmap原始碼JavaHashMap原始碼
- 《淺入淺出MySQL》表鎖 行鎖 併發插入MySql
- AQS原始碼探究之競爭鎖資源AQS原始碼
- AQS原始碼分析AQS原始碼
- 深入淺出Java執行緒池:原始碼篇Java執行緒原始碼
- 深入淺出ReentrantReadWriteLock原始碼解析原始碼
- 深入淺出Semaphore原始碼解析原始碼
- 深入淺出ReentrantLock原始碼解析ReentrantLock原始碼
- JUC併發程式設計基石AQS原始碼之結構篇程式設計AQS原始碼
- 故障分析 | 從 Insert 併發死鎖分析 Insert 加鎖原始碼邏輯原始碼
- Java併發指南8:AQS中的公平鎖與非公平鎖,CondtionJavaAQS
- 併發程式設計 —— 原始碼分析公平鎖和非公平鎖程式設計原始碼
- Java併發之AQS同步器學習JavaAQS
- Java併發之執行緒池ThreadPoolExecutor原始碼分析學習Java執行緒thread原始碼
- java併發程式設計 | 鎖詳解:AQS,Lock,ReentrantLock,ReentrantReadWriteLockJava程式設計AQSReentrantLock