【Java并發基礎】使用“等待—通知”機制優化死鎖中占用且等待解決方案
閱讀目錄
前言
就醫流程—完整的“等待—通知”機制
Java中“等待—通知”機制的實作
如何使線程等待,wait()
如何喚醒線程,notify()/notifyAll()
使用“等待-通知”機制重寫轉賬
一些需要注意的問題
sleep()和wait()的差別
為什麼wait()、notify()、notifyAll()是定義在Object中,而不是Thread中?
小結
回到目錄
在前篇介紹死鎖的文章中,我們破壞等待占用且等待條件時,用了一個死循環來擷取兩個賬本對象。
java
// 一次性申請轉出賬戶和轉入賬戶,直到成功
while(!actr.apply(this, target))
;
我們提到過,如果apply()操作耗時非常短,且并發沖突量也不大,這種方案還是可以。否則的話,就可能要循環上萬次才可以擷取鎖,這樣的話就太消耗CPU了!
于是我們給出另一個更好的解決方案,等待-通知機制:
若是線程要求的條件不滿足,則線程阻塞自己,進入等待狀态;當線程要求的條件滿足時,通知等待的線程重新執行。
Java是支援這種等待-通知機制的,下面我們就來詳細介紹這個機制,并用這個機制來優化我們的轉賬流程。
我們先通過一個就醫流程來了解一個完善的“等待-通知”機制。
在醫院就醫的流程基本是如下這樣:
患者先去挂号,然後到就診門口分診,等待叫号;
當叫到自己的号時,患者就可以找醫生就診;
就診過程中,醫生可能會讓患者去做檢查,同時叫一位患者;
當患者做完檢查後,拿着檢查單重新分診,等待叫号;
當醫生再次叫到自己時,患者就再去找醫生就診。
我們将上述過程對應到線程的運作情況:
患者到就診門口分診,類似于線程要去擷取互斥鎖;
當患者被叫到号時,類似于線程擷取到了鎖;
醫生讓患者去做檢查(缺乏檢查報告不能診斷病因),類似于線程要求的條件沒有滿足;
患者去做檢查,類似于線程進入了等待狀态;然後醫生叫下一個患者,意味着線程釋放了持有的互斥鎖;
患者做完檢查,類似于線程要求的條件已經滿足;患者拿着檢查報告重新分診,類似于線程需要重新擷取互斥鎖。
一個完整的“等待—通知”機制如下:
線程首先擷取互斥鎖,當線程要求條件不滿足時,釋放互斥鎖,進入等待狀态;當條件滿足時,通知等待的線程,重新擷取鎖。
一定要了解每一個關鍵點,還需要注意,通知的時候雖然條件滿足了,但是不代表該線程再次擷取到鎖時,條件還是滿足的。
在Java中,等待—通知機制可以有多種實作,這裡我們講解由synchronized配合wait()、notify()或者notifyAll()的實作。
當線程進入擷取鎖進入同步代碼塊後,若是條件不滿足,我們便調用wait()方法使得目前線程被阻塞且釋放鎖。
上圖中的等待隊列和互斥鎖是一一對應的,每個互斥鎖都有自己的獨立的等待隊列(等待隊列是同一個)。(這句話還在暗示我們後面喚醒線程時,是喚醒對應鎖上的線程。)
當條件滿足時,我們調用notify()或者notifyAll(),通知等待隊列(互斥鎖的等待隊列)中的線程,告訴它條件曾經滿足過。
我們要在相應的鎖上使用wait() 、notify()和notifyAll()。
需要注意,這三個方法可以被調用的前提是我們已經擷取到了相應的互斥鎖。是以,我們會發現wait() 、notify() notifyAll()都是在synchronized{...}内部中被調用的。如果在synchronized外部調用,JVM會抛出異常:java.lang.IllegalMonitorStateException。
我們現在使用“等待—通知”機制來優化上篇的一直循環擷取鎖的方案。首先我們要清楚如下如下四點:
互斥鎖:賬本管理者Allocator是單例,是以我們可以使用this作為互斥鎖;
線程要求的條件:轉出賬戶和轉入賬戶都存在,沒有被配置設定出去;
何時等待:線程要求的條件不滿足則等待;
何時通知:當有線程歸還賬戶時就通知;
使用“等待—通知”機制時,我們一般會套用一個“範式”,可以看作是前人的經驗總結用法。
while(條件不滿足) {
wait();
}
這個範式可以解決“條件曾将滿足過”這個問題。因為當wait()傳回時,條件已經發生變化,使用這種結構就可以檢驗條件是否還滿足。
解決我們的轉賬問題:
class Allocator {
private List<Object> als;
// 一次性申請所有資源
synchronized void apply(Object from, Object to){
// 經典寫法
while(als.contains(from) || als.contains(to)){
// from 或者 to賬戶被其他線程擁有
try{
wait(); // 條件不滿足時阻塞目前線程
}catch(Exception e){
}
}
als.add(from);
als.add(to);
}
// 歸還資源
synchronized void free(
Object from, Object to){
als.remove(from);
als.remove(to);
notifyAll(); // 歸還資源,喚醒其他所有線程
}
sleep()和wait()都可以使線程阻塞,但是它們還是有很大的差別:
wait()方法會使目前線程釋放鎖,而sleep()方法則不會。
當調用wait()方法後,目前線程會暫停執行,并進入互斥鎖的等待隊列中,直到有線程調用了notify()或者notifyAll(),等待隊列中的線程才會被喚醒,重新競争鎖。
sleep()方法的調用需要指定等待的時間,它讓目前正在執行的線程在指定的時間内暫停執行,進入阻塞狀态,但是它不會使線程釋放鎖,這意味其他線程在目前線程阻塞的時候,是不能進入擷取鎖,執行同步代碼的。
wait()隻能在同步方法或者同步代碼塊中執行,而sleep()可以在任何地方執行。
使用wait()無需捕獲異常,而使用sleep()則必須捕獲。
wait()是Object類的方法,而sleep是Thread的方法。
wait()、notify()以及notifyAll()它們之間的聯系是依靠互斥鎖,也就同步鎖(内置鎖),我們前面介紹過,每個Java對象都可以用作一個實作同步的鎖,是以這些方法是定義在Object中,而不是Thread中。
“等待—通知”機制是一種非常普遍的線程間協作的方式,我們在了解時可以利用生活中的例子去類似,就如上面的就醫流程。上文中沒有明顯說明notify()和notifyAll()的差別,隻是在圖中标注了一下。我們建議盡量使用notifyAll(),notify() 是會随機地通知等待隊列中的一個線程,在極端情況下可能會使某個線程一直處于阻塞狀态不能去競争擷取鎖導緻線程“饑餓”;而 notifyAll() 會通知等待隊列中的所有線程,即所有等待的線程都有機會去擷取鎖的使用權。
參考:
[1]極客時間專欄王寶令《Java并發程式設計實戰》
[2]Brian Goetz.Tim Peierls. et al.Java并發程式設計實戰[M].北京:機械工業出版社,2016
[3]skywang12345.Java多線程系列--“基礎篇”05之 線程等待與喚醒.
https://www.cnblogs.com/skywang12345/p/3479224.html原文位址
https://www.cnblogs.com/myworld7/p/12231936.html