【Java並發基礎】使用“等待—通知”機制優化死鎖中占用且等待解決方案


前言

在前篇介紹死鎖的文章中,我們破壞等待占用且等待條件時,用了一個死循環來獲取兩個賬本對象。

// 一次性申請轉出賬戶和轉入賬戶,直到成功
while(!actr.apply(this, target))
  ;

我們提到過,如果apply()操作耗時非常短,且並發沖突量也不大,這種方案還是可以。否則的話,就可能要循環上萬次才可以獲取鎖,這樣的話就太消耗CPU了!

於是我們給出另一個更好的解決方案,等待-通知機制
若是線程要求的條件不滿足,則線程阻塞自己,進入等待狀態;當線程要求的條件滿足時,通知等待的線程重新執行。

Java是支持這種等待-通知機制的,下面我們就來詳細介紹這個機制,並用這個機制來優化我們的轉賬流程。
我們先通過一個就醫流程來了解一個完善的“等待-通知”機制。

就醫流程—完整的“等待—通知”機制

在醫院就醫的流程基本是如下這樣:

  1. 患者先去掛號,然后到就診門口分診,等待叫號;
  2. 當叫到自己的號時,患者就可以找醫生就診;
  3. 就診過程中,醫生可能會讓患者去做檢查,同時叫一位患者;
  4. 當患者做完檢查后,拿着檢查單重新分診,等待叫號;
  5. 當醫生再次叫到自己時,患者就再去找醫生就診。

我們將上述過程對應到線程的運行情況:

  1. 患者到就診門口分診,類似於線程要去獲取互斥鎖;
  2. 當患者被叫到號時,類似於線程獲取到了鎖;
  3. 醫生讓患者去做檢查(缺乏檢查報告不能診斷病因),類似於線程要求的條件沒有滿足;
    患者去做檢查,類似於線程進入了等待狀態;然后醫生叫下一個患者,意味着線程釋放了持有的互斥鎖;
  4. 患者做完檢查,類似於線程要求的條件已經滿足;患者拿着檢查報告重新分診,類似於線程需要重新獲取互斥鎖。

一個完整的“等待—通知”機制如下:
線程首先獲取互斥鎖,當線程要求條件不滿足時,釋放互斥鎖,進入等待狀態;當條件滿足時,通知等待的線程,重新獲取鎖

一定要理解每一個關鍵點,還需要注意,通知的時候雖然條件滿足了,但是不代表該線程再次獲取到鎖時,條件還是滿足的。

Java中“等待—通知”機制的實現

在Java中,等待—通知機制可以有多種實現,這里我們講解由synchronized配合wait()notify()或者notifyAll()的實現。

如何使線程等待,wait()

當線程進入獲取鎖進入同步代碼塊后,若是條件不滿足,我們便調用wait()方法使得當前線程被阻塞且釋放鎖

上圖中的等待隊列和互斥鎖是一一對應的,每個互斥鎖都有自己的獨立的等待隊列(等待隊列是同一個)。(這句話還在暗示我們后面喚醒線程時,是喚醒對應鎖上的線程。)

如何喚醒線程,notify()/notifyAll()

當條件滿足時,我們調用notify()或者notifyAll(),通知等待隊列(互斥鎖的等待隊列)中的線程,告訴它條件曾經滿足過

我們要在相應的鎖上使用wait() 、notify()和notifyAll()。
需要注意,這三個方法可以被調用的前提是我們已經獲取到了相應的互斥鎖。所以,我們會發現wait() 、notify() notifyAll()都是在synchronized{...}內部中被調用的。如果在synchronized外部調用,JVM會拋出異常:java.lang.IllegalMonitorStateException。

使用“等待-通知”機制重寫轉賬

我們現在使用“等待—通知”機制來優化上篇的一直循環獲取鎖的方案。首先我們要清楚如下如下四點:

  1. 互斥鎖:賬本管理員Allocator是單例,所以我們可以使用this作為互斥鎖;
  2. 線程要求的條件:轉出賬戶和轉入賬戶都存在,沒有被分配出去;
  3. 何時等待:線程要求的條件不滿足則等待;
  4. 何時通知:當有線程歸還賬戶時就通知;

使用“等待—通知”機制時,我們一般會套用一個“范式”,可以看作是前人的經驗總結用法。

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()的區別

sleep()wait()都可以使線程阻塞,但是它們還是有很大的區別:

  1. wait()方法會使當前線程釋放鎖,而sleep()方法則不會。
    當調用wait()方法后,當前線程會暫停執行,並進入互斥鎖的等待隊列中,直到有線程調用了notify()或者notifyAll(),等待隊列中的線程才會被喚醒,重新競爭鎖。
    sleep()方法的調用需要指定等待的時間,它讓當前正在執行的線程在指定的時間內暫停執行,進入阻塞狀態,但是它不會使線程釋放鎖,這意味其他線程在當前線程阻塞的時候,是不能進入獲取鎖,執行同步代碼的。
  2. wait()只能在同步方法或者同步代碼塊中執行,而sleep()可以在任何地方執行。
  3. 使用wait()無需捕獲異常,而使用sleep()則必須捕獲。
  4. wait()是Object類的方法,而sleep是Thread的方法。

為什么wait()、notify()、notifyAll()是定義在Object中,而不是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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM