[Java併發-5]用“等待-通知”機制優化循環等待

由上一篇文章你應該已經知道,在 破壞佔用且等待條件 的時候,如果轉出賬本和轉入賬本不滿足同時在文件架上這個條件,就用死循環的方式來循環等待,核心代碼如下:

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

如果 apply() 操作耗時非常短,而且併發衝突量也不大時,這個方案還挺不錯的,因爲這種場景下,循環上幾次或者幾十次就能一次性獲取轉出賬戶和轉入賬戶了。但是如果 apply() 操作耗時長,或者併發衝突量大的時候,可能要循環上萬次才能獲取到鎖,太消耗 CPU 了。

其實在這種場景下,最好的方案應該是:如果線程要求的條件(轉出賬本和轉入賬本同在文件架上)不滿足,則線程阻塞自己,進入等待狀態;當線程要求的條件(轉出賬本和轉入賬本同在文件架上)滿足後, 通知等待的線程重新執行。其中,使用線程阻塞的方式就能避免循環等待消耗 CPU 的問題。

下面我們就來看看 Java 語言是如何支持 等待 - 通知機制

這裏直接給出 等待 - 通知機制 的相關步驟:

線程首先獲取互斥鎖,當線程要求的條件不滿足時,釋放互斥鎖,進入等待狀態;當要求的條件滿足時,通知其他等待的線程,重新獲取互斥鎖.

用 synchronized 實現等待 - 通知機制

在 Java 語言裏,等待 - 通知機制可以有多種實現方式,比如 Java 語言內置的 synchronized 配合 wait()、notify()、notifyAll() 這三個方法就能輕鬆實現。

先用 synchronized 實現互斥鎖。在下面這個圖裏,左邊有一個等待隊列,同一時刻,只允許一個線程進入 synchronized 保護的臨界區,當有一個線程進入臨界區後,其他線程就只能進入圖中左邊的等待隊列裏等待。 這個等待隊列和互斥鎖是一對一的關係,每個互斥鎖都有自己獨立的等待隊列。

wait() 操作工作原理圖

在併發程序中,當一個線程進入臨界區後,由於某些條件不滿足,需要進入等待狀態,Java 對象的 wait() 方法就能夠滿足這種需求。如上圖所示,當調用 wait() 方法後,當前線程就會被阻塞,並且進入到右邊的等待隊列中,這個等待隊列也是互斥鎖的等待隊列。 線程在進入等待隊列的同時,會釋放持有的互斥鎖,線程釋放鎖後,其他線程就有機會獲得鎖,並進入臨界區了。

那線程要求的條件滿足時,該怎麼通知這個等待的線程呢?很簡單,就是 Java 對象的 notify() 和 notifyAll() 方法。我在下面這個圖裏爲你大致描述了這個過程,當條件滿足時調用 notify(),會通知等待隊列(互斥鎖的等待隊列)中的線程,告訴它條件曾經滿足過

notify() 操作工作原理圖

爲什麼說是曾經滿足過呢?因爲 notify() 只能保證在通知時間點,條件是滿足的。而被通知線程的執行時間點和通知的時間點基本上不會重合,所以當線程執行的時候,很可能條件已經不滿足了(可能會有其他線程插隊)。這一點你需要格外注意。除此之外,還有一個需要注意的點,被通知的線程要想重新執行,仍然需要獲取到互斥鎖(因爲曾經獲取的鎖在調用 wait() 時已經釋放了)。

注意 wait()、notify()、notifyAll() 方法操作的等待隊列是互斥鎖的等待隊列,所以方法要使用在
上,synchronized 鎖定的是 this,那麼對應的一定是 this.wait()、this.notify()、this.notifyAll();。而且 wait()、notify()、notifyAll() 這三個方法能夠被調用的前提是已經獲取了相應的互斥鎖,所以我們會發現 wait()、notify()、notifyAll() 都是在 synchronized{}內部被調用的。如果在 synchronized{}外部調用,或者鎖定的 this,而用 target.wait() 調用的話,JVM 會拋出一個運行時異常:java.lang.IllegalMonitorStateException

一個更好地資源分配器

等待 - 通知機制的基本原理搞清楚後,我們來看看它如何解決一次性申請轉出賬戶和轉入賬戶的問題。在這個等待 - 通知機制中,我們需要考慮以下四個要素。

  • 互斥鎖:上一篇文章我們提到 Allocator 需要是單例的,所以我們可以用 this 作爲互斥鎖。
  • 線程要求的條件:轉出賬戶和轉入賬戶都沒有被分配過。
  • 何時等待:線程要求的條件不滿足就等待。
  • 何時通知:當有線程釋放賬戶時就通知。

注意下面的判斷方式

  while(條件不滿足) {
    wait();
  }

利用這種範式可以解決上面提到的條件曾經滿足過的情況。至於爲什麼這麼寫,後面講解 管程的時候會在詳細解釋。

來看完成後的代碼

class Allocator {
  private List<Object> als;
  // 一次性申請所有資源
  synchronized void apply(
    Object from, Object to){
    // 經典寫法
    while(als.contains(from) ||
         als.contains(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();
  }
}

儘量使用 notifyAll()

在上面的代碼中,我用的是 notifyAll() 來實現通知機制,爲什麼不使用 notify() 呢?這二者是有區別的。

notify() 是會隨機地通知等待隊列中的一個線程,而 notifyAll() 會通知等待隊列中的所有線程。

從感覺上來講,應該是 notify() 更好一些,因爲即便通知所有線程,也只有一個線程能夠進入臨界區。但實際上使用 notify() 也很有風險,它的風險在於可能導致某些線程永遠不會被通知到。

假設我們有資源 A、B、C、D,線程 1 申請到了 AB,線程 2 申請到了 CD,此時線程 3 申請 AB,會進入等待隊列(AB 分配給線程 1,線程 3 要求的條件不滿足),線程 4 申請 CD 也會進入等待隊列。我們再假設之後線程 1 歸還了資源 AB,如果使用 notify() 來通知等待隊列中的線程,有可能被通知的是線程 4,但線程 4 申請的是 CD,所以此時線程 4 還是會繼續等待,而真正該喚醒的線程 3 就再也沒有機會被喚醒了。

所以除非經過深思熟慮,否則儘量使用 notifyAll()。

總結

Java 語言的這種實現,背後的理論模型其實是管程,後面會專門介紹管程。現在你只需要能夠熟練使用就可以了。

思考:wait() 方法和 sleep() 方法都能讓當前線程掛起一段時間,那它們的區別是什麼?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章