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

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

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

若是 apply() 操做耗時很是短,並且併發衝突量也不大時,這個方案還挺不錯的,由於這種場景下,循環上幾回或者幾十次就能一次性獲取轉出帳戶和轉入帳戶了。可是若是 apply() 操做耗時長,或者併發衝突量大的時候,可能要循環上萬次才能獲取到鎖,太消耗 CPU 了。併發

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

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

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

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

用 synchronized 實現等待 - 通知機制

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

先用 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() 方法都能讓當前線程掛起一段時間,那它們的區別是什麼?

相關文章
相關標籤/搜索