深入理解Java併發框架AQS系列(一):執行緒
深入理解Java併發框架AQS系列(二):AQS框架簡介及鎖概念
深入理解Java併發框架AQS系列(三):獨佔鎖(Exclusive Lock)
深入理解Java併發框架AQS系列(四):共享鎖(Shared Lock)
一、前言
那些“簡單的”併發程式碼背後,隱藏著大量資訊。。。
獨佔鎖雖說在j.u.c
中有現成的實現,但在JAVA的語言層面也同樣提供了支援(synchronized
);但共享鎖卻是隻存在於AQS中,而它在實際生產中的使用頻次絲毫不亞於獨佔鎖,在整個AQS體系中佔有舉重若輕的地位。而在某種意義上,因為可能同時存在多個執行緒的併發,它的複雜度要高於獨佔鎖。本章除了介紹共享鎖資料結構等,還會重點對焦併發處理,看 doug lea 在併發部分是否有遺漏
j.u.c
下支援的併發鎖有Semaphore
、CountDownLatch
等,本章我們採用經典併發類Semaphore
來闡述
二、簡介
共享鎖其實是相對獨佔鎖而言的,涉及到共享鎖就要聊到併發度,即同一時刻最多允許同時執行執行緒的數量。上圖所述的併發度為3,即在同一時刻,最多可有3個人在同時過河。
但共享鎖的併發度也可以設定為1,此時它可以看作是一個特殊的獨佔鎖
2.1、waitStatus
在獨佔鎖章節中,我們介紹到了關鍵的狀態標記欄位waitStatus
,它在獨佔鎖的取值有
0
SIGNAL (-1)
CANCELLED (1)
而這些取值在共享鎖中也都存在,含義也保持一致,而除了上述這3個取值外,共享鎖還額外引入了新的取值:
PROPAGATE (-3)
且-3
這個取值在整個AQS體系中,只存在於共享鎖中,它的存在是為了更好的解決併發問題,我們將在後文中詳細介紹
2.2、使用場景
本人參加的某效能挑戰賽中,有這樣一個場景:資料產生於CPU,且有12個執行緒在不斷的製造資料,而這些資料需要持久化到磁碟中,由於資料產生的非常快,此時的瓶頸卡在IO上;磁碟的效能經過基準測試,發現每次寫入8K資料,且開4個執行緒寫入時,能將IO打滿;但如何控制在同一時刻,最多有4個執行緒進行IO寫入呢?
其實這是一個典型的使用共享鎖的場景,我們用三四行程式碼即可解決
// 設定共享鎖的併發度為4
Semaphore semaphore = new Semaphore(4);
// 加鎖
semaphore.acquire();
// 執行資料儲存
storeIO();
// 釋放鎖
semaphore.release();
三、併發
3.1、獨佔鎖 vs 共享鎖
共享鎖的整體流程與獨佔鎖相似,都是首先嚐試去獲取資源(子類邏輯,一般是CAS操作)
- 如果能拿到資源,那麼進入同步塊執行業務程式碼;當同步塊執行完畢後,喚醒阻塞佇列的頭結點
- 如果資源已空,那麼進入阻塞佇列並掛起,等待被其他執行緒喚醒
兩者的不同點在什麼地方呢?就在於“喚醒阻塞佇列的頭結點”的操作。在獨佔鎖時,喚醒頭結點的操作,只會有一個執行緒(加鎖成功的執行緒呼叫release()
)去觸發;而在共享鎖時,可能會有多個執行緒同時去呼叫釋放
直觀感覺這樣設計不太合理:如果多個執行緒同時去喚醒頭結點,而頭結點只能被喚醒一次,假定阻塞佇列中有20個節點,那這些節點只能等待上一個節點執行完畢後才會被喚醒,無形中共享鎖的併發度變成了1。要解決這個疑問,我們先來看共享鎖的釋放邏輯
3.2、鎖釋放
先來思考一下鎖釋放需要做的事兒
- 1、阻塞佇列的第一個節點一定要被啟用;這個問題看似不值一提,卻相當重要,區別於獨佔鎖,共享鎖的鎖釋放是存在併發的,在高併發的流量下,一定要保證阻塞佇列的第一個有效節點被啟用,否則會導致阻塞佇列永久性的掛死
- 2、保證啟用阻塞佇列時的併發度;這個問題同樣也是獨佔鎖不存在的,也就是我們在3.1提出的問題;假定這樣一種場景:“共享鎖的併發度為10,阻塞佇列中有100個待處理的節點,而此時又沒有新的加鎖請求,如何保證在啟用阻塞佇列時,保持10的併發度?”
共享鎖如何解決這兩個問題呢?我們接下來逐一闡述
3.2.1、呼叫點
與獨佔鎖不同,共享鎖呼叫“鎖釋放”有2個地方(注:AQS的一個阻塞佇列是可以同時新增獨佔節點、共享節點的,為了簡化模型,我們這裡暫不討論這種混合模型)
- a、某執行緒同步塊執行完畢,正常呼叫解鎖邏輯;此點與獨佔鎖一致
- b、在每次更換頭結點時,如果滿足以下任一條件,同樣會呼叫“鎖釋放”;更換頭結點的操作,其實此時已經意味著當前執行緒已經加鎖成功
- b.1、有額外的資源可用;拿訊號量舉例,當發現訊號量數量>0時,表示有額外資源可用
- b.2、舊的頭結點或當前頭結點的
ws < 0
那這兩個點呼叫的時候,是否存在併發呢?有同學會說“a存在併發,b是序列的”;其實此處b也是存在併發的,例如執行緒1更換了head節點後,準備執行“鎖釋放”邏輯,正在此時,執行緒2正常鎖釋放後,喚醒了新的head節點(執行緒3),執行緒3又會執行更換head節點,並準備執行“鎖釋放”邏輯;此時執行緒1跟執行緒3都準備執行“鎖釋放”邏輯
既然“鎖釋放”存在這麼多併發,那就一定要保證“鎖釋放”邏輯是冪等的,那它又是如何做到呢?
3.2.1、鎖釋放
直接貼一下它的原始碼吧,釋放鎖的程式碼寥寥幾筆,卻很難說它簡單
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
對應的流程圖如下:
我們簡單描述一下鎖釋放做的事兒
- 1、首選獲取頭結點的快照,並將其賦予變數
h
,同時獲取h.waitStatus
,並標記位ws
- 2、判斷
ws
的狀態ws == -1
表示下一個節點已經掛起,或即將掛起。如果只要發現是-1狀態,就進行執行緒喚起的話,因為存在併發,可能導致目標執行緒被喚起多次,故此處需要通過CAS進行搶鎖,保證只有一個執行緒去喚起ws == 0
如果發現節點ws
為0,此處會存在兩種情況(情況1:節點剛新建完畢,還未進入阻塞佇列;情況2:節點由-1修改為了0),不管哪種情況,都強制將其由-1改為-3,標記位強制傳播,此處是否存在漏洞?ws == -3
表示當前節點已經被標識為強制傳播了,直接結束
- 3、如果此時
h == head
,說明在上述邏輯發生時,頭結點沒有發生變化,那麼結束當前操作,否則重複上述步驟。注:AQS中所有節點只有一次當頭結點的機會,也就是某個節點當過一次頭結點後,便會被拋棄,再無可能第二次成為頭結點,這點至關重要
根據以上分析,我們發現,節點的狀態流轉是通過ws
來控制的,即0、-1、-3,乍看上去,貌似不太嚴謹,那我們來做具體分析
3.2.2、ws
狀態流轉
僅有2個功能點會對ws
進行修改,一是將節點加入阻塞佇列時,二就是3.2.1中描述的呼叫鎖釋放邏輯時;
我們將加入阻塞佇列時ws
的狀態流轉再回憶下:
- 狀態為0(初始狀態),加入阻塞佇列前,需要將前節點修改為-1,然後進入執行緒掛起
- 狀態為-3(強制傳播狀態,被解鎖執行緒標記),加入阻塞佇列前,同樣需要將前節點修改為-1,然後進入執行緒掛起
綜述,我們出一張ws
的整體狀態流轉圖
由上圖可得知,只要解鎖邏輯成功通過CAS將head節點由-1
修改為0
的話,那麼就要負責喚醒阻塞佇列中的第一個節點了
整個流轉過程有bug嗎?我們設想如下場景:共享鎖的併發度設定為1,A、B兩個執行緒同時進入加鎖邏輯,B執行緒成功搶到鎖,並開始進入同步塊,A執行緒搶鎖失敗,準備掛到阻塞佇列,正常流程是A執行緒將ws
由0修改為-1後,進入掛起狀態,但B執行緒執行較快,已經優先A執行緒並開始執行解鎖邏輯,將ws
由0修改為了-3,然後B執行緒正常結束;A執行緒發現ws
為-3後,將其修改為-1,然後進入掛起。 如果這個場景真實發生的話,A執行緒將永久處於掛起狀態,那豈不是存在漏洞?
然而事實並非如此,因為只要A執行緒將ws
修改為-1後,都要再嘗試進行一次獲取鎖的操作,正是這個操作避免了上述情況的發生,可見aqs是很嚴謹的
3.3、保證併發度
阻塞佇列中節點的啟用順序是什麼樣呢?其實啟用順序3.2章節已經描述的較為清楚,解鎖的邏輯只負責啟用頭節點,那如何保證共享鎖的併發度?
我們還是假定這樣一個場景:共享鎖的併發度為5,阻塞佇列中有20個節點,只有head節點已被喚醒,且沒有新的請求進入,我們希望在同一時刻,同時有5個節點處於啟用狀態。針對上述場景,aqs如何做到呢?
其實head節點被啟用時,在第一時間會通知後續節點,並將其喚醒,然後才會執行同步塊邏輯,保證了等待中的節點快速啟用