在開始解讀aqs的共享功能前,我們再重溫一下countdownlatch,countdownlatch為java.util.concurrent包下的計數器工具類,常被用在多線程環境下,它在初始時需要指定一個計數器的大小,然後可被多個線程并發的實作減1操作,并在計數器為0後調用await方法的線程被喚醒,進而實作多線程間的協作。它在多線程環境下的基本使用方式為:
注意,線程thread 1,2,3各自調用 countdown後,countdownlatch 的計數為0,await方法傳回,控制台輸入“over”,在此之前main thread 會一直沉睡。
可以看到countdownlatch的作用類似于一個“欄栅”,在countdownlatch的計數為0前,調用await方法的線程将一直阻塞,直到countdownlatch計數為0,await方法才會傳回,
而countdownlatch的countdown()方法則一般由各個線程調用,實作countdownlatch計數的減1。
知道了countdownlatch的基本使用方式,我們就從上述demo的第一行new countdownlatch(3)開始,看看countdownlatch是怎麼實作的。
首先,看下countdownlatch的構造方法:
和reentrantlock類似,countdownlatch内部也有一個叫做sync的内部類,同樣也是用它繼承了aqs。
再看下sync:
如果你看過本系列的上半部分,你對setstate方法一定不會陌生,它是aqs的一個“狀态位”,在不同的場景下,代表不同的含義,比如在reentrantlock中,表示加鎖的次數,在countdownlatch中,
則表示countdownlatch的計數器的初始大小。
設定完計數器大小後countdownlatch的構造方法傳回,下面我們再看下countdownlatch的await()方法:
調用了sync的acquiresharedinterruptibly方法,因為sync是aqs子類的原因,這裡其實是直接調用了aqs的acquiresharedinterruptibly方法:
從方法名上看,這個方法的調用是響應線程的打斷的,是以在前兩行會檢查下線程是否被打斷。接着,嘗試着擷取共享鎖,小于0,表示擷取失敗,通過本系列的上半部分的解讀,
我們知道aqs在擷取鎖的思路是,先嘗試直接擷取鎖,如果失敗會将目前線程放在隊列中,按照fifo的原則等待鎖。
而對于共享鎖也是這個思路,如果和獨占鎖一緻,這裡的tryacquireshared應該是個空方法,留給子類去判斷:
再看看countdownlatch:
如果state變成0了,則傳回1,表示擷取成功,否則傳回-1則表示擷取失敗。
看到這裡,讀者可能會發現, await方法的擷取方式更像是在擷取一個獨占鎖,那為什麼這裡還會用tryacquireshared呢?
回想下countdownlatch的await方法是不是隻能在主線程中調用?答案是否定的,countdownlatch的await方法可以在多個線程中調用,當countdownlatch的計數器為0後,調用await的方法都會依次傳回。
也就是說可以多個線程同時在等待await方法傳回,是以它被設計成了實作tryacquireshared方法,擷取的是一個共享鎖,鎖在所有調用await方法的線程間共享,是以叫共享鎖。
回到acquiresharedinterruptibly方法:
如果擷取共享鎖失敗(傳回了-1,說明state不為0,也就是countdownlatch的計數器還不為0),進入調用doacquiresharedinterruptibly方法中,按照我們上述的猜想,應該是要将目前線程放入到隊列中去。
在這之前,我們再回顧一下aqs隊列的資料結構:aqs是一個雙向連結清單,通過節點中的next,pre變量分别指向目前節點後一個節點和前一個節點。其中,每個節點中都包含了一個線程和一個類型變量:表示目前節點是獨占節點還是共享節點,頭節點中的線程為正在占有鎖的線程,而後的所有節點的線程表示為正在等待擷取鎖的線程。如下圖所示:
黃色節點,表示正在擷取鎖的節點,剩下的藍色節點(node1、node2、node3)為正在等待鎖的節點,他們通過各自的next,pre變量分别指向前後節點,形成了aqs中的雙向連結清單。
再看看doacquiresharedinterruptibly方法:
這裡有幾點需要說明的:
1. setheadandpropagate方法:
首先,使用了cas更換了頭節點,然後,将目前節點的下一個節點取出來,如果同樣是“shared”類型的,再做一個”releaseshared”操作。看下doreleaseshared方法:
為什麼要這麼做呢?這就是共享功能和獨占功能最不一樣的地方,對于獨占功能來說,有且隻有一個線程(通常隻對應一個節點,拿reentantlock舉例,如果目前持有鎖的線程重複調用lock()方法,
那根據本系列上半部分我們的介紹,我們知道,會被包裝成多個節點在aqs的隊列中,是以用一個線程來描述更準确),能夠擷取鎖,但是對于共享功能來說。
共享的狀态是可以被共享的,也就是意味着其他aqs隊列中的其他節點也應能第一時間知道狀态的變化。是以,一個節點擷取到共享狀态流程圖是這樣的:
比如現在有如下隊列:
當node1調用tryacquireshared成功後,更換了頭節點:
node1變成了頭節點然後調用unparksuccessor()方法喚醒了node2,node2中持有的線程a出于上面流程圖的park node的位置,
線程a被喚醒後,重複黃色線條的流程,重新檢查調用tryacquireshared方法,看能否成功,如果成功,則又更改頭結點,重複以上步驟,以實作節點自身擷取共享鎖成功後,喚醒下一個共享類型結點的操作,實作共享狀态的向後傳遞。
2.其實對于doacquireshared方法,aqs還提供了集中類似的實作:
分别對應了:
1. 帶參數請求共享鎖。 (忽略中斷)
2. 帶參數請求共享鎖,且響應中斷。(每次循環時,會檢查目前線程的中斷狀态,以實作對線程中斷的響應)
3. 帶參數請求共享鎖但是限制等待時間。(第二個參數設定逾時時間,超出時間後,方法傳回。)
比較特别的為最後一個doacquiresharednanos方法,我們一起看下它怎麼實作逾時時間的控制的。
因為該方法和其餘擷取共享鎖的方法邏輯是類似的,我用紅色框圈出了它所不一樣的地方,也就是實作逾時時間控制的地方。
可以看到,其實就是在進入方法時,計算出了一個“deadline”,每次循環的時候用目前時間和“deadline”比較,大于“dealine”說明逾時時間已到,直接傳回方法。
注意,最後一個紅框中的這行代碼:
nanostimeout > spinfortimeoutthreshold
從變量的字面意思可知,這是拿逾時時間和逾時自旋的最小閥值作比較,在這裡doug lea把逾時自旋的閥值設定成了1000ns,即隻有逾時時間大于1000ns才會去挂起線程,否則,再次循環,以實作“自旋”操作。這是“自旋”在aqs中的應用之處。
看完await方法,我們再來看下countdown()方法:
調用了aqs的releaseshared方法,并傳入了參數1:
同樣先嘗試去釋放鎖,tryreleaseshared同樣為空方法,留給子類自己去實作,以下是countdownlatch的内部類sync的實作:
死循環更新state的值,實作state的減1操作,之是以用死循環是為了確定state值的更新成功。
從上文的分析中可知,如果state的值為0,在countdownlatch中意味:所有的子線程已經執行完畢,這個時候可以喚醒調用await()方法的線程了,而這些線程正在aqs的隊列中,并被挂起的,
是以下一步應該去喚醒aqs隊列中的頭結點了(aqs的隊列為fifo隊列),然後由頭節點去依次喚醒aqs隊列中的其他共享節點。如果tryreleaseshared傳回true,進入doreleaseshared()方法:
當線程被喚醒後,會重新嘗試擷取共享鎖,而對于countdownlatch線程擷取共享鎖判斷依據是state是否為0,而這個時候顯然state已經變成了0,是以可以順利擷取共享鎖并且依次喚醒aqs隊裡中後面的節點及對應的線程。
本文從countdownlatch入手,深入分析了aqs關于共享鎖方面的實作方式:
如果擷取共享鎖失敗後,将請求共享鎖的線程封裝成node對象放入aqs的隊列中,并挂起node對象對應的線程,實作請求鎖線程的等待操作。待共享鎖可以被擷取後,從頭節點開始,依次喚醒頭節點及其以後的所有共享類型的節點。實作共享狀态的傳播。這裡有幾點值得注意:
1. 與aqs的獨占功能一樣,共享鎖是否可以被擷取的判斷為空方法,交由子類去實作。
2. 與aqs的獨占功能不同,當鎖被頭節點擷取後,獨占功能是隻有頭節點擷取鎖,其餘節點的線程繼續沉睡,等待鎖被釋放後,才會喚醒下一個節點的線程,而共享功能是隻要頭節點擷取鎖成功,就在喚醒自身節點對應的線程的同時,繼續喚醒aqs隊列中的下一個節點的線程,每個節點在喚醒自身的同時還會喚醒下一個節點對應的線程,以實作共享狀态的“向後傳播”,進而實作共享功能。
以上的分析都是從aqs子類的角度去看待aqs的部分功能的,而如果直接看待aqs,或許可以這麼去解讀:
首先,aqs并不關心“是什麼鎖”,對于aqs來說它隻是實作了一系列的用于判斷“資源”是否可以通路的api,并且封裝了在“通路資源”受限時将請求通路的線程的加入隊列、挂起、喚醒等操作, aqs隻關心“資源不可以通路時,怎麼處理?”、“資源是可以被同時通路,還是在同一時間隻能被一個線程通路?”、“如果有線程等不及資源了,怎麼從aqs的隊列中退出?”等一系列圍繞資源通路的問題,而至于“資源是否可以被通路?”這個問題則交給aqs的子類去實作。
當aqs的子類是實作獨占功能時,例如reentrantlock,“資源是否可以被通路”被定義為隻要aqs的state變量不為0,并且持有鎖的線程不是目前線程,則代表資源不能通路。
當aqs的子類是實作共享功能時,例如:countdownlatch,“資源是否可以被通路”被定義為隻要aqs的state變量不為0,說明資源不能通路。這是典型的将規則和操作分開的設計思路:規則子類定義,操作邏輯因為具有公用性,放在父類中去封裝。當然,正式因為aqs隻是關心“資源在什麼條件下可被通路”,是以子類還可以同時使用aqs的共享功能和獨占功能的api以實作更為複雜的功能。
比如:reentrantreadwritelock,我們知道reentrantreadwritelock的中也有一個叫sync的内部類繼承了aqs,而aqs的隊列可以同時存放共享鎖和獨占鎖,對于reentrantreadwritelock來說分别代表讀鎖和寫鎖,當隊列中的頭節點為讀鎖時,代表讀操作可以執行,而寫操作不能執行,是以請求寫操作的線程會被挂起,當讀操作依次推出後,寫鎖成為頭節點,請求寫操作的線程被喚醒,可以執行寫操作,而此時的讀請求将被封裝成node放入aqs的隊列中。如此往複,實作讀寫鎖的讀寫交替進行。
而本系列文章上半部分提到的futuretask,其實思路也是:封裝一個存放線程執行結果的變量a,使用aqs的獨占api實作線程對變量a的獨占通路,判斷規則是,線程沒有執行完畢:call()方法沒有傳回前,不能通路變量a,或者是逾時時間沒到前不能通路變量a(這就是futuretask的get方法可以實作擷取線程執行結果時,設定逾時時間的原因)。
綜上所述,本系列文章從aqs獨占鎖和共享鎖兩個方面深入分析了aqs的實作方式和獨特的設計思路,希望對讀者有啟發,下一篇文章,我們将繼續jdk 1.8下 j.u.c (java.util.concurrent)包中的其他工具類,敬請期待。