那些“簡單的”并發(fā)代碼背后,隱藏著大量信息。。。
獨占鎖雖說在j.u.c中有現(xiàn)成的實現(xiàn),但在JAVA的語言層面也同樣提供了支持(synchronized);但共享鎖卻是只存在于AQS中,而它在實際生產(chǎn)中的使用頻次絲毫不亞于獨占鎖,在整個AQS體系中占有舉重若輕的地位。而在某種意義上,因為可能同時存在多個線程的并發(fā),它的復雜度要高于獨占鎖。本章除了介紹共享鎖數(shù)據(jù)結(jié)構(gòu)等,還會重點對焦并發(fā)處理,看 doug lea 在并發(fā)部分是否有遺漏
j.u.c下支持的并發(fā)鎖有Semaphore、CountDownLatch等,本章我們采用經(jīng)典并發(fā)類Semaphore來闡述
二、簡介

共享鎖其實是相對獨占鎖而言的,涉及到共享鎖就要聊到并發(fā)度,即同一時刻最多允許同時執(zhí)行線程的數(shù)量。上圖所述的并發(fā)度為3,即在同一時刻,最多可有3個人在同時過河。
但共享鎖的并發(fā)度也可以設(shè)置為1,此時它可以看作是一個特殊的獨占鎖
2.1、waitStatus
在獨占鎖章節(jié)中,我們介紹到了關(guān)鍵的狀態(tài)標記字段waitStatus,它在獨占鎖的取值有
- 0
- SIGNAL (-1)
- CANCELLED (1)
而這些取值在共享鎖中也都存在,含義也保持一致,而除了上述這3個取值外,共享鎖還額外引入了新的取值:
- PROPAGATE (-3)
且-3這個取值在整個AQS體系中,只存在于共享鎖中,它的存在是為了更好地解決并發(fā)問題,我們將在后文中詳細介紹
2.2、使用場景
本人參加的某性能挑戰(zhàn)賽中,有這樣一個場景:數(shù)據(jù)產(chǎn)生于CPU,且有12個線程在不斷地制造數(shù)據(jù),而這些數(shù)據(jù)需要持久化到磁盤中,由于數(shù)據(jù)產(chǎn)生的非???,此時的瓶頸卡在IO上;磁盤的性能經(jīng)過基準測試,發(fā)現(xiàn)每次寫入8K數(shù)據(jù),且開4個線程寫入時,能將IO打滿;但如何控制在同一時刻,最多有4個線程進行IO寫入呢?

其實這是一個典型的使用共享鎖的場景,我們用三四行代碼即可解決
// 設(shè)置共享鎖的并發(fā)度為4
Semaphore semaphore = new Semaphore(4);
// 加鎖
semaphore.acquire();
// 執(zhí)行數(shù)據(jù)存儲
storeIO();
// 釋放鎖
semaphore.release();
三、并發(fā)
3.1、獨占鎖 vs 共享鎖
共享鎖的整體流程與獨占鎖相似,都是首先嘗試去獲取資源(子類邏輯,一般是CAS操作)
- 如果能拿到資源,那么進入同步塊執(zhí)行業(yè)務(wù)代碼;當同步塊執(zhí)行完畢后,喚醒阻塞隊列的頭結(jié)點
- 如果資源已空,那么進入阻塞隊列并掛起,等待被其他線程喚醒
兩者的不同點在什么地方呢?就在于“喚醒阻塞隊列的頭結(jié)點”的操作。在獨占鎖時,喚醒頭結(jié)點的操作,只會有一個線程(加鎖成功的線程調(diào)用release())去觸發(fā);而在共享鎖時,可能會有多個線程同時去調(diào)用釋放

直觀感覺這樣設(shè)計不太合理:如果多個線程同時去喚醒頭結(jié)點,而頭結(jié)點只能被喚醒一次,假定阻塞隊列中有20個節(jié)點,那這些節(jié)點只能等待上一個節(jié)點執(zhí)行完畢后才會被喚醒,無形中共享鎖的并發(fā)度變成了1。要解決這個疑問,我們先來看共享鎖的釋放邏輯
3.2、鎖釋放
先來思考一下鎖釋放需要做的事兒
- 阻塞隊列的第一個節(jié)點一定要被激活;這個問題看似不值一提,卻相當重要,區(qū)別于獨占鎖,共享鎖的鎖釋放是存在并發(fā)的,在高并發(fā)的流量下,一定要保證阻塞隊列的第一個有效節(jié)點被激活,否則會導致阻塞隊列永久性的掛死
- 保證激活阻塞隊列時的并發(fā)度;這個問題同樣也是獨占鎖不存在的,也就是我們在3.1提出的問題;假定這樣一種場景:“共享鎖的并發(fā)度為10,阻塞隊列中有100個待處理的節(jié)點,而此時又沒有新的加鎖請求,如何保證在激活阻塞隊列時,保持10的并發(fā)度?”
共享鎖如何解決這兩個問題呢?我們接下來逐一闡述
3.2.1、調(diào)用點
與獨占鎖不同,共享鎖調(diào)用“鎖釋放”有2個地方(注:AQS的一個阻塞隊列是可以同時添加獨占節(jié)點、共享節(jié)點的,為了簡化模型,我們這里暫不討論這種混合模型)
- a、某線程同步塊執(zhí)行完畢,正常調(diào)用解鎖邏輯;此點與獨占鎖一致
- b、在每次更換頭結(jié)點時,如果滿足以下任一條件,同樣會調(diào)用“鎖釋放”;更換頭結(jié)點的操作,其實此時已經(jīng)意味著當前線程已經(jīng)加鎖成功b.1、有額外的資源可用;拿信號量舉例,當發(fā)現(xiàn)信號量數(shù)量>0時,表示有額外資源可用b.2、舊的頭結(jié)點或當前頭結(jié)點的ws < 0
那這兩個點調(diào)用的時候,是否存在并發(fā)呢?有同學會說“a存在并發(fā),b是串行的”;其實此處b也是存在并發(fā)的,例如線程1更換了head節(jié)點后,準備執(zhí)行“鎖釋放”邏輯,正在此時,線程2正常鎖釋放后,喚醒了新的head節(jié)點(線程3),線程3又會執(zhí)行更換head節(jié)點,并準備執(zhí)行“鎖釋放”邏輯;此時線程1跟線程3都準備執(zhí)行“鎖釋放”邏輯

既然“鎖釋放”存在這么多并發(fā),那就一定要保證“鎖釋放”邏輯是冪等的,那它又是如何做到呢?
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;
}
}
對應(yīng)的流程圖如下:

我們簡單描述一下鎖釋放做的事兒
- 1、首選獲取頭結(jié)點的快照,并將其賦予變量h,同時獲取h.waitStatus,并標記位ws
- 2、判斷ws的狀態(tài)ws == -1 表示下一個節(jié)點已經(jīng)掛起,或即將掛起。如果只要發(fā)現(xiàn)是-1狀態(tài),就進行線程喚起的話,因為存在并發(fā),可能導致目標線程被喚起多次,故此處需要通過CAS進行搶鎖,保證只有一個線程去喚起ws == 0 如果發(fā)現(xiàn)節(jié)點ws為0,此處會存在兩種情況(情況1:節(jié)點剛新建完畢,還未進入阻塞隊列;情況2:節(jié)點由-1修改為了0),不管哪種情況,都強制將其由-1改為-3,標記位強制傳播,此處是否存在漏洞?ws == -3 表示當前節(jié)點已經(jīng)被標識為強制傳播了,直接結(jié)束
- 3、如果此時 h == head,說明在上述邏輯發(fā)生時,頭結(jié)點沒有發(fā)生變化,那么結(jié)束當前操作,否則重復上述步驟。注:AQS中所有節(jié)點只有一次當頭結(jié)點的機會,也就是某個節(jié)點當過一次頭結(jié)點后,便會被拋棄,再無可能第二次成為頭結(jié)點,這點至關(guān)重要
根據(jù)以上分析,我們發(fā)現(xiàn),節(jié)點的狀態(tài)流轉(zhuǎn)是通過ws來控制的,即0、-1、-3,乍看上去,貌似不太嚴謹,那我們來做具體分析
3.2.2、ws狀態(tài)流轉(zhuǎn)
僅有2個功能點會對ws進行修改,一是將節(jié)點加入阻塞隊列時,二就是3.2.1中描述的調(diào)用鎖釋放邏輯時;
我們將加入阻塞隊列時ws的狀態(tài)流轉(zhuǎn)再回憶下:
- 狀態(tài)為0(初始狀態(tài)),加入阻塞隊列前,需要將前節(jié)點修改為-1,然后進入線程掛起
- 狀態(tài)為-3(強制傳播狀態(tài),被解鎖線程標記),加入阻塞隊列前,同樣需要將前節(jié)點修改為-1,然后進入線程掛起
綜述,我們出一張ws的整體狀態(tài)流轉(zhuǎn)圖

由上圖可得知,只要解鎖邏輯成功通過CAS將head節(jié)點由-1修改為0的話,那么就要負責喚醒阻塞隊列中的第一個節(jié)點了
整個流轉(zhuǎn)過程有bug嗎?我們設(shè)想如下場景:共享鎖的并發(fā)度設(shè)置為1,A、B兩個線程同時進入加鎖邏輯,B線程成功搶到鎖,并開始進入同步塊,A線程搶鎖失敗,準備掛到阻塞隊列,正常流程是A線程將ws由0修改為-1后,進入掛起狀態(tài),但B線程執(zhí)行較快,已經(jīng)優(yōu)先A線程并開始執(zhí)行解鎖邏輯,將ws由0修改為了-3,然后B線程正常結(jié)束;A線程發(fā)現(xiàn)ws為-3后,將其修改為-1,然后進入掛起。 如果這個場景真實發(fā)生的話,A線程將永久處于掛起狀態(tài),那豈不是存在漏洞?
然而事實并非如此,因為只要A線程將ws修改為-1后,都要再嘗試進行一次獲取鎖的操作,正是這個操作避免了上述情況的發(fā)生,可見aqs是很嚴謹?shù)?/p>

3.3、保證并發(fā)度
阻塞隊列中節(jié)點的激活順序是什么樣呢?其實激活順序3.2章節(jié)已經(jīng)描述的較為清楚,解鎖的邏輯只負責激活頭節(jié)點,那如何保證共享鎖的并發(fā)度?
我們還是假定這樣一個場景:共享鎖的并發(fā)度為5,阻塞隊列中有20個節(jié)點,只有head節(jié)點已被喚醒,且沒有新的請求進入,我們希望在同一時刻,同時有5個節(jié)點處于激活狀態(tài)。針對上述場景,aqs如何做到呢?

其實head節(jié)點被激活時,在第一時間會通知后續(xù)節(jié)點,并將其喚醒,然后才會執(zhí)行同步塊邏輯,保證了等待中的節(jié)點快速激活