1. 程式人生 > >【Java併發基礎】使用“等待—通知”機制優化死鎖中佔用且等待解決方案

【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.h