深刻理解Java併發框架AQS系列(一):線程
深刻理解Java併發框架AQS系列(二):AQS框架簡介及鎖概念
深刻理解Java併發框架AQS系列(三):獨佔鎖(Exclusive Lock)
深刻理解Java併發框架AQS系列(四):共享鎖(Shared Lock)html
那些「簡單的」併發代碼背後,隱藏着大量信息。。。數據結構
獨佔鎖雖然說在j.u.c
中有現成的實現,但在JAVA的語言層面也一樣提供了支持(synchronized
);但共享鎖倒是隻存在於AQS中,而它在實際生產中的使用頻次絲絕不亞於獨佔鎖,在整個AQS體系中佔有舉重若輕的地位。而在某種意義上,由於可能同時存在多個線程的併發,它的複雜度要高於獨佔鎖。本章除了介紹共享鎖數據結構等,還會重點對焦併發處理,看 doug lea 在併發部分是否有遺漏併發
j.u.c
下支持的併發鎖有Semaphore
、CountDownLatch
等,本章咱們採用經典併發類Semaphore
來闡述框架
共享鎖實際上是相對獨佔鎖而言的,涉及到共享鎖就要聊到併發度,即同一時刻最多容許同時執行線程的數量。上圖所述的併發度爲3,即在同一時刻,最多可有3我的在同時過河。高併發
但共享鎖的併發度也能夠設置爲1,此時它能夠看做是一個特殊的獨佔鎖oop
waitStatus
在獨佔鎖章節中,咱們介紹到了關鍵的狀態標記字段waitStatus
,它在獨佔鎖的取值有性能
0
SIGNAL (-1)
CANCELLED (1)
而這些取值在共享鎖中也都存在,含義也保持一致,而除了上述這3個取值外,共享鎖還額外引入了新的取值:測試
PROPAGATE (-3)
且-3
這個取值在整個AQS體系中,只存在於共享鎖中,它的存在是爲了更好的解決併發問題,咱們將在後文中詳細介紹ui
本人蔘加的某性能挑戰賽中,有這樣一個場景:數據產生於CPU,且有12個線程在不斷的製造數據,而這些數據須要持久化到磁盤中,因爲數據產生的很是快,此時的瓶頸卡在IO上;磁盤的性能通過基準測試,發現每次寫入8K數據,且開4個線程寫入時,能將IO打滿;但如何控制在同一時刻,最多有4個線程進行IO寫入呢?線程
其實這是一個典型的使用共享鎖的場景,咱們用三四行代碼便可解決
// 設置共享鎖的併發度爲4 Semaphore semaphore = new Semaphore(4); // 加鎖 semaphore.acquire(); // 執行數據存儲 storeIO(); // 釋放鎖 semaphore.release();
共享鎖的總體流程與獨佔鎖類似,都是首先嚐試去獲取資源(子類邏輯,通常是CAS操做)
二者的不一樣點在什麼地方呢?就在於「喚醒阻塞隊列的頭結點」的操做。在獨佔鎖時,喚醒頭結點的操做,只會有一個線程(加鎖成功的線程調用release()
)去觸發;而在共享鎖時,可能會有多個線程同時去調用釋放
直觀感受這樣設計不太合理:若是多個線程同時去喚醒頭結點,而頭結點只能被喚醒一次,假定阻塞隊列中有20個節點,那這些節點只能等待上一個節點執行完畢後纔會被喚醒,無形中共享鎖的併發度變成了1。要解決這個疑問,咱們先來看共享鎖的釋放邏輯
先來思考一下鎖釋放須要作的事兒
共享鎖如何解決這兩個問題呢?咱們接下來逐一闡述
與獨佔鎖不一樣,共享鎖調用「鎖釋放」有2個地方(注:AQS的一個阻塞隊列是能夠同時添加獨佔節點、共享節點的,爲了簡化模型,咱們這裏暫不討論這種混合模型)
ws < 0
那這兩個點調用的時候,是否存在併發呢?有同窗會說「a存在併發,b是串行的」;其實此處b也是存在併發的,例如線程1更換了head節點後,準備執行「鎖釋放」邏輯,正在此時,線程2正常鎖釋放後,喚醒了新的head節點(線程3),線程3又會執行更換head節點,並準備執行「鎖釋放」邏輯;此時線程1跟線程3都準備執行「鎖釋放」邏輯
既然「鎖釋放」存在這麼多併發,那就必定要保證「鎖釋放」邏輯是冪等的,那它又是如何作到呢?
直接貼一下它的源碼吧,釋放鎖的代碼寥寥幾筆,卻很難說它簡單
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; } }
對應的流程圖以下:
咱們簡單描述一下鎖釋放作的事兒
h
,同時獲取h.waitStatus
,並標記位ws
ws
的狀態
ws == -1
表示下一個節點已經掛起,或即將掛起。若是隻要發現是-1狀態,就進行線程喚起的話,由於存在併發,可能致使目標線程被喚起屢次,故此處須要經過CAS進行搶鎖,保證只有一個線程去喚起ws == 0
若是發現節點ws
爲0,此處會存在兩種狀況(狀況1:節點剛新建完畢,還未進入阻塞隊列;狀況2:節點由-1修改成了0),無論哪一種狀況,都強制將其由-1改成-3,標記位強制傳播,此處是否存在漏洞?ws == -3
表示當前節點已經被標識爲強制傳播了,直接結束h == head
,說明在上述邏輯發生時,頭結點沒有發生變化,那麼結束當前操做,不然重複上述步驟。注:AQS中全部節點只有一次當頭結點的機會,也就是某個節點當過一次頭結點後,便會被拋棄,再無可能第二次成爲頭結點,這點相當重要根據以上分析,咱們發現,節點的狀態流轉是經過ws
來控制的,即0、-一、-3,乍看上去,貌似不太嚴謹,那咱們來作具體分析
ws
狀態流轉僅有2個功能點會對ws
進行修改,一是將節點加入阻塞隊列時,二就是3.2.1中描述的調用鎖釋放邏輯時;
咱們將加入阻塞隊列時ws
的狀態流轉再回憶下:
綜述,咱們出一張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.2章節已經描述的較爲清楚,解鎖的邏輯只負責激活頭節點,那如何保證共享鎖的併發度?
咱們仍是假定這樣一個場景:共享鎖的併發度爲5,阻塞隊列中有20個節點,只有head節點已被喚醒,且沒有新的請求進入,咱們但願在同一時刻,同時有5個節點處於激活狀態。針對上述場景,aqs如何作到呢?
其實head節點被激活時,在第一時間會通知後續節點,並將其喚醒,而後纔會執行同步塊邏輯,保證了等待中的節點快速激活