1. 程式人生 > >wait()、notify()、notifyAll()與執行緒通訊方式總結

wait()、notify()、notifyAll()與執行緒通訊方式總結

1、通過wait()、notify()、notifyAll()進行執行緒通訊

執行緒通訊的目標是使執行緒間能夠互相傳送訊號。另一方面,執行緒通訊使執行緒能夠等待其他執行緒的訊號。例如,執行緒B可以等待執行緒A的一個訊號,這個訊號會通知執行緒B資料已經準備好了。

Java有一個內建的等待機制來允許執行緒在等待訊號的時候變為非執行狀態。java.lang.Object 類定義了三個方法,wait()notify()notifyAll()來實現這個等待機制。

一個執行緒一旦呼叫了任意物件的wait()方法,就會變為非執行狀態,直到另一個執行緒呼叫了同一個物件的notify()方法。為了呼叫 wait()或者notify(),執行緒必須先獲得那個物件的鎖。也就是說,執行緒必須在同步塊裡呼叫wait()或者notify()

以下是一個執行緒通訊的案例——使用了wait()和notify()的MyWaitNotify:

  
  1. public class MonitorObject{
  2. }
  3.  
  4. public class MyWaitNotify{
  5.  
  6.   MonitorObject myMonitorObject = new MonitorObject();
  7.  
  8.   public void doWait
    (){
  9.     synchronized(myMonitorObject){
  10.       try{
  11.         myMonitorObject.wait();
  12.       } catch(InterruptedException e){...}
  13.     }
  14.   
    }
  15.  
  16.   public void doNotify(){
  17.     synchronized(myMonitorObject){
  18.       myMonitorObject.notify();
  19.     }
  20.   }
  21. }

等待執行緒將呼叫doWait(),而喚醒執行緒將呼叫doNotify()。當一個執行緒呼叫一個物件的notify()方法,正在等待該物件的所有線 程中將有一個執行緒被喚醒並允許執行(校注:這個將被喚醒的執行緒是隨機的,不可以指定喚醒哪個執行緒)。同時也提供了一個notifyAll()方法來喚醒正 在等待一個給定物件的所有執行緒。

如你所見,不管是等待執行緒還是喚醒執行緒都在同步塊裡呼叫wait()和notify()。這是強制性的!一個執行緒如果沒有持有物件鎖,將不能呼叫 wait(),notify()或者notifyAll()。否則,會丟擲IllegalMonitorStateException異常。

(校注:JVM是這麼實現的,當你呼叫wait時候它首先要檢查下當前執行緒是否是鎖的擁有者,不是則丟擲IllegalMonitorStateExcept,參考JVM原始碼的 1422行。)

但是,這怎麼可能?等待執行緒在同步塊裡面執行的時候,不是一直持有監視器物件(myMonitor物件)的鎖嗎?等待執行緒不能阻塞喚醒執行緒進入 doNotify()的同步塊嗎?答案是:的確不能。一旦執行緒呼叫了wait()方法,它就釋放了所持有的監視器物件上的鎖。這將允許其他執行緒也可以呼叫 wait()或者notify()。

一旦一個執行緒被喚醒,不能立刻就退出wait()的方法呼叫,直到呼叫notify()的執行緒退出了它自己的同步塊。換句話說:被喚醒的執行緒必須重新獲得 監視器物件的鎖,才可以退出wait()的方法呼叫,因為wait方法呼叫執行在同步塊裡面。如果多個執行緒被notifyAll()喚醒,那麼在同一時刻 將只有一個執行緒可以退出wait()方法,因為每個執行緒在退出wait()前必須獲得監視器物件的鎖。


2、其他的執行緒通訊方式

1、通過共享物件通訊

2、忙等待

通過共享物件通訊

執行緒間傳送訊號的一個簡單方式是在共享物件的變數裡設定訊號值。執行緒A在一個同步塊裡設定boolean型成員變數 hasDataToProcess為true,執行緒B也在同步塊裡讀取hasDataToProcess這個成員變數。這個簡單的例子使用了一個持有訊號 的物件,並提供了setcheck方法:

  
  1. public class MySignal{
  2.  
  3.   protected boolean hasDataToProcess = false;
  4.  
  5.   public synchronized boolean hasDataToProcess(){
  6.     return this.hasDataToProcess;
  7.   }
  8.  
  9.   public synchronized void setHasDataToProcess(boolean hasData){
  10.     this.hasDataToProcess = hasData;
  11.   }
  12.  
  13. }

執行緒A和B必須獲得指向一個MySignal共享例項的引用,以便進行通訊。如果它們持有的引用指向不同的MySingal例項,那麼彼此將不能檢測到對方的訊號。需要處理的資料可以存放在一個共享快取區裡,它和MySignal例項是分開存放的。

忙等待(Busy Wait)

準備處理資料的執行緒B正在等待資料變為可用。換句話說,它在等待執行緒A的一個訊號,這個訊號使hasDataToProcess()返回true。執行緒B執行在一個迴圈裡,以等待這個訊號:

  
  1. protected MySignal sharedSignal = ...
  2.  
  3. ...
  4.  
  5. while(!sharedSignal.hasDataToProcess()){
  6.   //do nothing... busy waiting
  7. }

忙等待沒有對執行等待執行緒的CPU進行有效的利用,除非平均等待時間非常短。否則,讓等待執行緒進入睡眠或者非執行狀態更為明智,直到它接收到它等待的訊號。

3 執行緒通訊時要注意的問題

丟失的訊號(Missed Signals)

notify()和notifyAll()方法不會儲存呼叫它們的方法,因為當這兩個方法被呼叫時,有可能沒有執行緒處於等待狀態。通知訊號過後便丟 棄了。因此,如果一個執行緒先於被通知執行緒呼叫wait()前呼叫了notify(),等待的執行緒將錯過這個訊號。這可能是也可能不是個問題。不過,在某些 情況下,這可能使等待執行緒永遠在等待,不再醒來,因為執行緒錯過了喚醒訊號。

為了避免丟失訊號,必須把它們儲存在訊號類裡。在MyWaitNotify的例子中,通知訊號應被儲存在MyWaitNotify例項的一個成員變數裡。以下是MyWaitNotify的修改版本:

  
  1. public class MyWaitNotify2{
  2.  
  3.   MonitorObject myMonitorObject = new MonitorObject();
  4.   boolean wasSignalled = false;
  5.  
  6.   public void doWait(){
  7.     synchronized(myMonitorObject){
  8.       if(!wasSignalled){
  9.         try{
  10.           myMonitorObject.wait();
  11.          } catch(InterruptedException e){...}
  12.       }
  13.       //clear signal and continue running.
  14.       wasSignalled = false;
  15.     }
  16.   }
  17.  
  18.   public void doNotify(){
  19.     synchronized(myMonitorObject){
  20.       wasSignalled = true;
  21.       myMonitorObject.notify();
  22.     }
  23.   }
  24. }

留意doNotify()方法在呼叫notify()前把wasSignalled變數設為true。同時,留意doWait()方法在呼叫 wait()前會檢查wasSignalled變數。事實上,如果沒有訊號在前一次doWait()呼叫和這次doWait()呼叫之間的時間段裡被接收 到,它將只調用wait()。

(校注:為了避免訊號丟失, 用一個變數來儲存是否被通知過。在notify前,設定自己已經被通知過。在wait後,設定自己沒有被通知過,需要等待通知。)

假喚醒

由於莫名其妙的原因,執行緒有可能在沒有呼叫過notify()和notifyAll()的情況下醒來。這就是所謂的假喚醒(spurious wakeups)。無端端地醒過來了。

如果在MyWaitNotify2的doWait()方法裡發生了假喚醒,等待執行緒即使沒有收到正確的訊號,也能夠執行後續的操作。這可能導致你的應用程式出現嚴重問題。

為了防止假喚醒,儲存訊號的成員變數將在一個while迴圈裡接受檢查,而不是在if表示式裡。這樣的一個while迴圈叫做自旋鎖(校注:這種做法要慎重,目前的JVM實現自旋會消耗CPU,如果長時間不呼叫doNotify方法,doWait方法會一直自旋,CPU會消耗太大)。被喚醒的執行緒會自旋直到自旋鎖(while迴圈)裡的條件變為false。以下MyWaitNotify2的修改版本展示了這點:

  
  1. public class MyWaitNotify3{
  2.  
  3.   MonitorObject myMonitorObject = new MonitorObject();
  4.   boolean wasSignalled = false;
  5.  
  6.   public void doWait(){
  7.     synchronized(myMonitorObject){
  8.       while(!wasSignalled){
  9.         try{
  10.           myMonitorObject.wait();
  11.          } catch(InterruptedException e){...}
  12.       }
  13.       //clear signal and continue running.
  14.       wasSignalled = false;
  15.     }
  16.   }
  17.  
  18.   public void doNotify(){
  19.     synchronized(myMonitorObject){
  20.       wasSignalled = true;
  21.       myMonitorObject.notify();
  22.     }
  23.   }
  24. }

留意wait()方法是在while迴圈裡,而不在if表示式裡。如果等待執行緒沒有收到訊號就喚醒,wasSignalled變數將變為false,while迴圈會再執行一次,促使醒來的執行緒回到等待狀態。

多個執行緒等待相同訊號

如果你有多個執行緒在等待,被notifyAll()喚醒,但只有一個被允許繼續執行,使用while迴圈也是個好方法。每次只有一個執行緒可以獲得監 視器物件鎖,意味著只有一個執行緒可以退出wait()呼叫並清除wasSignalled標誌(設為false)。一旦這個執行緒退出doWait()的同 步塊,其他執行緒退出wait()呼叫,並在while迴圈裡檢查wasSignalled變數值。但是,這個標誌已經被第一個喚醒的執行緒清除了,所以其餘 醒來的執行緒將回到等待狀態,直到下次訊號到來。

不要在字串常量或全域性物件中呼叫wait()

本文早期的一個版本在MyWaitNotify例子裡使用字串常量(””)作為管程物件。以下是那個例子:

  
  1. public class MyWaitNotify{
  2.  
  3.   String myMonitorObject = "";
  4.   boolean wasSignalled = false;
  5.  
  6.   public void doWait(){
  7.     synchronized(myMonitorObject){
  8.       while(!wasSignalled){
  9.         try{
  10.           myMonitorObject.wait();
  11.          } catch(InterruptedException e){...}
  12.       }
  13.       //clear signal and continue running.
  14.       wasSignalled = false;
  15.     }
  16.   }
  17.  
  18.   public void doNotify(){
  19.     synchronized(myMonitorObject){
  20.       wasSignalled = true;
  21.       myMonitorObject.notify();
  22.     }
  23.   }
  24. }

在空字串作為鎖的同步塊(或者其他常量字串)裡呼叫wait()和notify()產生的問題是,JVM/編譯器內部會把常量字串轉換成同一個物件。這意味著,即使你有2個不同的MyWaitNotify例項,它們都引用了相同的空字串例項。同時也意味著存在這樣的風險:在第一個 MyWaitNotify例項上呼叫doWait()的執行緒會被在第二個MyWaitNotify例項上呼叫doNotify()的執行緒喚醒。這種情況可 以畫成以下這張圖:

Image.png

起初這可能不像個大問題。畢竟,如果doNotify()在第二個MyWaitNotify例項上被呼叫,真正發生的事不外乎執行緒A和B被錯誤的喚 醒了 。這個被喚醒的執行緒(A或者B)將在while迴圈裡檢查訊號值,然後回到等待狀態,因為doNotify()並沒有在第一個MyWaitNotify實 例上呼叫,而這個正是它要等待的例項。這種情況相當於引發了一次假喚醒。執行緒A或者B在訊號值沒有更新的情況下喚醒。但是程式碼處理了這種情況,所以執行緒回 到了等待狀態。記住,即使4個執行緒在相同的共享字串例項上呼叫wait()和notify(),doWait()和doNotify()裡的訊號還會被 2個MyWaitNotify例項分別儲存。在MyWaitNotify1上的一次doNotify()呼叫可能喚醒MyWaitNotify2的執行緒, 但是訊號值只會儲存在MyWaitNotify1裡。

問題在於,由於doNotify()僅呼叫了notify()而不是notifyAll(),即使有4個執行緒在相同的字串(空字串)例項上等 待,只能有一個執行緒被喚醒。所以,如果執行緒A或B被髮給C或D的訊號喚醒,它會檢查自己的訊號值,看看有沒有訊號被接收到,然後回到等待狀態。而C和D都 沒被喚醒來檢查它們實際上接收到的訊號值,這樣訊號便丟失了。這種情況相當於前面所說的丟失訊號的問題。C和D被髮送過訊號,只是都不能對訊號作出迴應。

如果doNotify()方法呼叫notifyAll(),而非notify(),所有等待執行緒都會被喚醒並依次檢查訊號值。執行緒A和B將回到等待 狀態,但是C或D只有一個執行緒注意到訊號,並退出doWait()方法呼叫。C或D中的另一個將回到等待狀態,因為獲得訊號的執行緒在退出doWait() 的過程中清除了訊號值(置為false)。

看過上面這段後,你可能會設法使用notifyAll()來代替notify(),但是這在效能上是個壞主意。在只有一個執行緒能對訊號進行響應的情況下,沒有理由每次都去喚醒所有執行緒。

所以:在wait()/notify()機制中,不要使用全域性物件,字串常量等。應該使用對應唯一的物件。例如,每一個MyWaitNotify3的例項(前一節的例子)擁有一個屬於自己的監視器物件,而不是在空字串上呼叫wait()/notify()。

校注:管程 (英語:Monitors,也稱為監視器) 是對多個工作執行緒實現互斥訪問共享資源的物件或模組。這些共享資源一般是硬體裝置或一群變數。管程實現了在一個時間點,最多隻有一個執行緒在執行它的某個子 程式。與那些通過修改資料結構實現互斥訪問的併發程式設計相比,管程很大程度上簡化了程式設計。   


原文地址:http://www.tianshouzhi.com/api/tutorials/mutithread/69