當前位置:才華齋>計算機>java語言>

java多執行緒-執行緒通訊例項詳細解讀

java語言 閱讀(1.83W)

執行緒通訊的目標是使執行緒間能夠互相傳送訊號。另一方面,執行緒通訊使執行緒能夠等待其他執行緒的訊號。

java多執行緒-執行緒通訊例項詳細解讀

通過共享物件通訊

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

12345678910111213public class MySignal{protected boolean hasDataToProcess = false;public synchronized boolean hasDataToProcess(){return thisataToProcess;}public synchronized void setHasDataToProcess(boolean hasData){thisataToProcess = hasData;}}

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

忙等待(Busy Wait)

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

1234567protected MySignal sharedSignal = ......while(!ataToProcess()){//do nothing... busy waiting}

wait(),notify()和 notifyAll()

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

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

一個執行緒一旦呼叫了任意物件的 wait()方法,就會變為非執行狀態,直到另一個執行緒呼叫了同一個物件的 notify()方法。為了呼叫 wait()或者 notify(),執行緒必須先獲得那個物件的鎖。也就是說,執行緒必須在同步塊裡呼叫 wait()或者 notify()。以下是 MySingal 的修改版本——使用了 wait()和 notify()的 MyWaitNotify:

123456789101112131415161718192021public class MonitorObject{}public class MyWaitNotify{MonitorObject myMonitorObject = new MonitorObject();public void doWait(){synchronized(myMonitorObject){try{();} catch(InterruptedException e){...}}}public void doNotify(){synchronized(myMonitorObject){fy();}}}

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

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

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

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

一旦一個執行緒被喚醒,不能立刻就退出 wait()的方法呼叫,直到呼叫 notify()的

1234567891011121314151617181920212223242526public class MyWaitNotify2{MonitorObject myMonitorObject = new MonitorObject();boolean wasSignalled = false;public void doWait(){synchronized(myMonitorObject){if(!wasSignalled){try{();} catch(InterruptedException e){...}}//clear signal and continue running.wasSignalled = false;}}public void doNotify(){synchronized(myMonitorObject){wasSignalled = true;fy();}}}<br>

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

丟失的訊號(Missed Signals)

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

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

123456789101112131415161718192021222324public class MyWaitNotify2{MonitorObject myMonitorObject = new MonitorObject();boolean wasSignalled = false;public void doWait(){synchronized(myMonitorObject){if(!wasSignalled){try{();} catch(InterruptedException e){...}}//clear signal and continue running.wasSignalled = false;}}public void doNotify(){synchronized(myMonitorObject){wasSignalled = true;fy();}}}

留意 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 的修改版本展示了這點:

123456789101112131415161718192021222324public class MyWaitNotify3{MonitorObject myMonitorObject = new MonitorObject();boolean wasSignalled = false;public void doWait(){synchronized(myMonitorObject){while(!wasSignalled){try{();} catch(InterruptedException e){...}}//clear signal and continue running.wasSignalled = false;}}public void doNotify(){synchronized(myMonitorObject){wasSignalled = true;fy();}}}

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

多個執行緒等待相同訊號

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

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

(校注:本章說的字串常量指的是值為常量的變數)

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

123456789101112131415161718192021222324public class MyWaitNotify{String myMonitorObject = "";boolean wasSignalled = false;public void doWait(){synchronized(myMonitorObject){while(!wasSignalled){try{();} catch(InterruptedException e){...}}//clear signal and continue running.wasSignalled = false;}}public void doNotify(){synchronized(myMonitorObject){wasSignalled = true;fy();}}}

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

起初這可能不像個大問題。畢竟,如果 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()。