深入理解Java並發框架AQS系列(一):線程
深入理解Java並發框架AQS系列(二):AQS框架簡介及鎖概念
深入理解Java並發框架AQS系列(三):獨占鎖(Exclusive Lock)
深入理解Java並發框架AQS系列(四):共享鎖(Shared Lock)
深入理解Java並發框架AQS系列(五):條件隊列(Condition)
一、前言
那些“簡單的”並發代碼背后,隱藏着大量信息。。。
獨占鎖雖說在j.u.c中有現成的實現,但在JAVA的語言層面也同樣提供了支持(synchronized);但共享鎖卻是只存在於AQS中,而它在實際生產中的使用頻次絲毫不亞於獨占鎖,在整個AQS體系中占有舉重若輕的地位。而在某種意義上,因為可能同時存在多個線程的並發,它的復雜度要高於獨占鎖。本章除了介紹共享鎖數據結構等,還會重點對焦並發處理,看 doug lea 在並發部分是否有遺漏
j.u.c下支持的並發鎖有Semaphore、CountDownLatch等,本章我們采用經典並發類Semaphore來闡述
二、簡介

共享鎖其實是相對獨占鎖而言的,涉及到共享鎖就要聊到並發度,即同一時刻最多允許同時執行線程的數量。上圖所述的並發度為3,即在同一時刻,最多可有3個人在同時過河。
但共享鎖的並發度也可以設置為1,此時它可以看作是一個特殊的獨占鎖
2.1、waitStatus
在獨占鎖章節中,我們介紹到了關鍵的狀態標記字段waitStatus,它在獨占鎖的取值有
0SIGNAL (-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節點被激活時,在第一時間會通知后續節點,並將其喚醒,然后才會執行同步塊邏輯,保證了等待中的節點快速激活
