1. 程式人生 > >求求你,別再用wait和notify了!

求求你,別再用wait和notify了!

`Condition` 是 JDK 1.5 中提供的用來替代 `wait` 和 `notify` 的執行緒通訊方法,那麼一定會有人問:**為什麼不能用 `wait` 和 `notify` 了?** 哥們我用的好好的。老弟彆著急,聽我給你細說... 之所以推薦使用 `Condition` 而非 `Object` 中的 `wait` 和 `notify` 的原因有兩個: 1. **使用 `notify` 在極端環境下會造成執行緒“假死”;** 1. **`Condition` 效能更高。** 接下來怎們就用程式碼和流程圖的方式來演示上述的兩種情況。 # 1.notify 執行緒“假死” **所謂的執行緒“假死”是指,在使用 `notify` 喚醒多個等待的執行緒時,卻意外的喚醒了一個沒有“準備好”的執行緒,從而導致整個程式進入了阻塞的狀態不能繼續執行。** 以多執行緒程式設計中的經典案例生產者和消費者模型為例,我們先來演示一下執行緒“假死”的問題。 ## 1.1 正常版本 在演示執行緒“假死”的問題之前,我們先使用 `wait` 和 `notify` 來實現一個簡單的生產者和消費者模型,為了讓程式碼更直觀,我這裡寫一個超級簡單的實現版本。我們先來建立一個工廠類,工廠類裡面包含兩個方法,一個是迴圈生產資料的(存入)方法,另一個是迴圈消費資料的(取出)方法,實現程式碼如下。 ```java /** * 工廠類,消費者和生產者通過呼叫工廠類實現生產/消費 */ class Factory { private int[] items = new int[1]; // 資料儲存容器(為了演示方便,設定容量最多儲存 1 個元素) private int size = 0; // 實際儲存大小 /** * 生產方法 */ public synchronized void put() throws InterruptedException { // 迴圈生產資料 do { while (size == items.length) { // 注意不能是 if 判斷 // 儲存的容量已經滿了,阻塞等待消費者消費之後喚醒 System.out.println(Thread.currentThread().getName() + " 進入阻塞"); this.wait(); System.out.println(Thread.currentThread().getName() + " 被喚醒"); } System.out.println(Thread.currentThread().getName() + " 開始工作"); items[0] = 1; // 為了方便演示,設定固定值 size++; System.out.println(Thread.currentThread().getName() + " 完成工作"); // 當生產佇列有資料之後通知喚醒消費者 this.notify(); } while (true); } /** * 消費方法 */ public synchronized void take() throws InterruptedException { // 迴圈消費資料 do { while (size == 0) { // 生產者沒有資料,阻塞等待 System.out.println(Thread.currentThread().getName() + " 進入阻塞(消費者)"); this.wait(); System.out.println(Thread.currentThread().getName() + " 被喚醒(消費者)"); } System.out.println("消費者工作~"); size--; // 喚醒生產者可以新增生產了 this.notify(); } while (true); } } ``` 接下來我們來建立兩個執行緒,一個是生產者呼叫 `put` 方法,另一個是消費者呼叫 `take` 方法,實現程式碼如下: ```java public class NotifyDemo { public static void main(String[] args) { // 建立工廠類 Factory factory = new Factory(); // 生產者 Thread producer = new Thread(() -> { try { factory.put(); } catch (InterruptedException e) { e.printStackTrace(); } }, "生產者"); producer.start(); // 消費者 Thread consumer = new Thread(() -> { try { factory.take(); } catch (InterruptedException e) { e.printStackTrace(); } }, "消費者"); consumer.start(); } } ``` 執行結果如下: ![image.png](https://cdn.nlark.com/yuque/0/2020/png/92791/1607948732003-64251ff8-eca9-4804-bfc3-2a49fb57e667.png#align=left&display=inline&height=423&margin=%5Bobject%20Object%5D&name=image.png&originHeight=846&originWidth=516&size=84483&status=done&style=none&width=258) 從上述結果可以看出,生產者和消費者在迴圈交替的執行任務,場面非常和諧,是我們想要的正確結果。 ## 1.2 執行緒“假死”版本 當只有一個生產者和一個消費者時,`wait` 和 `notify` 方法不會有任何問題,然而**將生產者增加到兩個時就會出現執行緒“假死”的問題了,**程式的實現程式碼如下: ```java public class NotifyDemo { public static void main(String[] args) { // 建立工廠方法(工廠類的程式碼不變,這裡不再複述) Factory factory = new Factory(); // 生產者 Thread producer = new Thread(() -> { try { factory.put(); } catch (InterruptedException e) { e.printStackTrace(); } }, "生產者"); producer.start(); // 生產者 2 Thread producer2 = new Thread(() -> { try { factory.put(); } catch (InterruptedException e) { e.printStackTrace(); } }, "生產者2"); producer2.start(); // 消費者 Thread consumer = new Thread(() -> { try { factory.take(); } catch (InterruptedException e) { e.printStackTrace(); } }, "消費者"); consumer.start(); } } ``` 程式執行結果如下: ![image.png](https://cdn.nlark.com/yuque/0/2020/png/92791/1607949589545-3f5333fc-23c1-49d1-b627-e83688b6e696.png#align=left&display=inline&height=394&margin=%5Bobject%20Object%5D&name=image.png&originHeight=788&originWidth=630&size=77841&status=done&style=none&width=315) 從以上結果可以看出,當我們將生產者的數量增加到 2 個時,就會造成執行緒“假死”阻塞執行的問題,當生產者 2 被喚醒又被阻塞之後,整個程式就不能繼續執行了。 #### 執行緒“假死”問題分析 我們先把以上程式的執行步驟標註一下,得到如下結果: ![image.png](https://cdn.nlark.com/yuque/0/2020/png/92791/1607950112183-d566d1e2-00fe-4c39-a5f7-9a8fbe69ded2.png#align=left&display=inline&height=391&margin=%5Bobject%20Object%5D&name=image.png&originHeight=782&originWidth=628&size=84673&status=done&style=none&width=314) 從上圖可以看出:**當執行到第 ④ 步時,此時生產者為工作狀態,而生產者 2 和消費者為等待狀態,此時正確的做法應該是喚醒消費著進行消費,然後消費者消費完之後再喚醒生產者繼續工作;但此時生產者卻錯誤的喚醒了生產者 2,而生產者 2 因為佇列已經滿了,所以自身並不具備繼續執行的能力,因此就導致了整個程式的阻塞**,流程圖如下所示: ![image.png](https://cdn.nlark.com/yuque/0/2020/png/92791/1607951802243-8b2a0bd4-9cb3-479b-b704-f751c9d19f13.png#align=left&display=inline&height=604&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1208&originWidth=960&size=82224&status=done&style=none&width=480) 正確執行流程應該是這樣的: ![image.png](https://cdn.nlark.com/yuque/0/2020/png/92791/1607952158414-5d358375-9136-4d83-9af4-1ced43d76583.png#align=left&display=inline&height=589&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1178&originWidth=962&size=82372&status=done&style=none&width=481) ## 1.3 使用 Condition 為了解決執行緒的“假死”問題,我們可以使用 `Condition` 來嘗試實現一下,`Condition` 是 JUC(java.util.concurrent)包下的類,需要使用 `Lock` 鎖來建立,`Condition` 提供了 3 個重要的方法: - `await`:對應 `wait` 方法; - `signal`:對應 `notify` 方法; - `signalAll`: `notifyAll` 方法。 `Condition` 的使用和 `wait/notify` 類似,也是先獲得鎖然後在鎖中進行等待和喚醒操作,`Condition` 的基礎用法如下: ```java // 建立 Condition 物件 Lock lock = new ReentrantLock(); Condition condition = lock.newCondition(); // 加鎖 lock.lock(); try { // 業務方法.... // 1.進入等待狀態 condition.await(); // 2.喚醒操作 condition.signal(); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } ``` ### 小知識:Lock的正確使用姿勢 切記 `Lock` 的 `lock.lock()` 方法不能放入 `try` 程式碼中,如果 `lock` 方法在 `try` 程式碼塊之內,可能由於其它方法丟擲異常,導致在 `finally` 程式碼塊中, `unlock` 對未加鎖的物件解鎖,它會呼叫 `AQS` 的 `tryRelease` 方法(取決於具體實現類),丟擲 `IllegalMonitorStateException` 異常。 ### 迴歸主題 回到本文的主題,我們如果使用 `Condition` 來實現執行緒的通訊就可以避免程式的“假死”情況,因為 `Condition` 可以建立多個等待集,以本文的生產者和消費者模型為例,我們可以使用兩個等待集,一個用做消費者的等待和喚醒,另一個用來喚醒生產者,這樣就不會出現生產者喚醒生產者的情況了(生產者只能喚醒消費者,消費者只能喚醒生產者)這樣整個流程就不會“假死”了,它的執行流程如下圖所示: ![image.png](https://cdn.nlark.com/yuque/0/2020/png/92791/1607954450625-0656d241-c3e2-429c-93ef-30b21a4dec96.png#align=left&display=inline&height=661&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1322&originWidth=1776&size=140414&status=done&style=none&width=888) 瞭解了它的基本流程之後,咱們來看具體的實現程式碼。 基於 `Condition` 的工廠實現程式碼如下: ```java class FactoryByCondition { private int[] items = new int[1]; // 資料儲存容器(為了演示方便,設定容量最多儲存 1 個元素) private int size = 0; // 實際儲存大小 // 建立 Condition 物件 private Lock lock = new ReentrantLock(); // 生產者的 Condition 物件 private Condition producerCondition = lock.newCondition(); // 消費者的 Condition 物件 private Condition consumerCondition = lock.newCondition(); /** * 生產方法 */ public void put() throws InterruptedException { // 迴圈生產資料 do { lock.lock(); while (size == items.length) { // 注意不能是 if 判斷 // 生產者進入等待 System.out.println(Thread.currentThread().getName() + " 進入阻塞"); producerCondition.await(); System.out.println(Thread.currentThread().getName() + " 被喚醒"); } System.out.println(Thread.currentThread().getName() + " 開始工作"); items[0] = 1; // 為了方便演示,設定固定值 size++; System.out.println(Thread.currentThread().getName() + " 完成工作"); // 喚醒消費者 consumerCondition.signal(); try { } finally { lock.unlock(); } } while (true); } /** * 消費方法 */ public void take() throws InterruptedException { // 迴圈消費資料 do { lock.lock(); while (size == 0) { // 消費者阻塞等待 consumerCondition.await(); } System.out.println("消費者工作~"); size--; // 喚醒生產者 producerCondition.signal(); try { } finally { lock.unlock(); } } while (true); } } ``` 兩個生產者和一個消費者的實現程式碼如下: ```java public class NotifyDemo { public static void main(String[] args) { FactoryByCondition factory = new FactoryByCondition(); // 生產者 Thread producer = new Thread(() -> { try { factory.put(); } catch (InterruptedException e) { e.printStackTrace(); } }, "生產者"); producer.start(); // 生產者 2 Thread producer2 = new Thread(() -> { try { factory.put(); } catch (InterruptedException e) { e.printStackTrace(); } }, "生產者2"); producer2.start(); // 消費者 Thread consumer = new Thread(() -> { try { factory.take(); } catch (InterruptedException e) { e.printStackTrace(); } }, "消費者"); consumer.start(); } } ``` 程式的執行結果如下圖所示: ![image.png](https://cdn.nlark.com/yuque/0/2020/png/92791/1607955214921-acf26366-d4a0-4415-852c-cba6fa1ad04f.png#align=left&display=inline&height=588&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1176&originWidth=394&size=94307&status=done&style=none&width=197) 從上述結果可以看出,當使用 `Condition` 時,生產者、消費者、生產者 2 會一直交替迴圈執行,執行結果符合我們的預期。 # 2.效能問題 在上面我們演示 `notify` 會造成執行緒的“假死”問題的時候,一定有朋友會想到,如果把 `notify` 換成 `notifyAll` 執行緒就不會“假死”了。 這樣做法確實可以解決執行緒“假死”的問題,但同時會到來新的效能問題,空說無憑,直接上程式碼展示。 以下是使用 `wait` 和 `notifyAll` 改進後的程式碼: ```java /** * 工廠類,消費者和生產者通過呼叫工廠類實現生產/消費功能. */ class Factory { private int[] items = new int[1]; // 資料儲存容器(為了演示方便,設定容量最多儲存 1 個元素) private int size = 0; // 實際儲存大小 /** * 生產方法 * @throws InterruptedException */ public synchronized void put() throws InterruptedException { // 迴圈生產資料 do { while (size == items.length) { // 注意不能是 if 判斷 // 儲存的容量已經滿了,阻塞等待消費者消費之後喚醒 System.out.println(Thread.currentThread().getName() + " 進入阻塞"); this.wait(); System.out.println(Thread.currentThread().getName() + " 被喚醒"); } System.out.println(Thread.currentThread().getName() + " 開始工作"); items[0] = 1; // 為了方便演示,設定固定值 size++; System.out.println(Thread.currentThread().getName() + " 完成工作"); // 喚醒所有執行緒 this.notifyAll(); } while (true); } /** * 消費方法 * @throws InterruptedException */ public synchronized void take() throws InterruptedException { // 迴圈消費資料 do { while (size == 0) { // 生產者沒有資料,阻塞等待 System.out.println(Thread.currentThread().getName() + " 進入阻塞(消費者)"); this.wait(); System.out.println(Thread.currentThread().getName() + " 被喚醒(消費者)"); } System.out.println("消費者工作~"); size--; // 喚醒所有執行緒 this.notifyAll(); } while (true); } } ``` 依舊是兩個生產者加一個消費者,實現程式碼如下: ```java public static void main(String[] args) { Factory factory = new Factory(); // 生產者 Thread producer = new Thread(() -> { try { factory.put(); } catch (InterruptedException e) { e.printStackTrace(); } }, "生產者"); producer.start(); // 生產者 2 Thread producer2 = new Thread(() -> { try { factory.put(); } catch (InterruptedException e) { e.printStackTrace(); } }, "生產者2"); producer2.start(); // 消費者 Thread consumer = new Thread(() -> { try { factory.take(); } catch (InterruptedException e) { e.printStackTrace(); } }, "消費者"); consumer.start(); } ``` 執行的結果如下圖所示: ![image.png](https://cdn.nlark.com/yuque/0/2020/png/92791/1607956087770-1b7ae4f7-1f4a-4228-a905-2a395c40b813.png#align=left&display=inline&height=755&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1510&originWidth=536&size=144091&status=done&style=none&width=268) 通過以上結果可以看出:**當我們呼叫 `notifyAll` 時確實不會造成執行緒“假死”了,但會造成所有的生產者都被喚醒了,但因為待執行的任務只有一個,因此被喚醒的所有生產者中,只有一個會執行正確的工作,而另一個則是啥也不幹,然後又進入等待狀態,這就行為對於整個程式來說,無疑是多此一舉,只會增加執行緒排程的開銷,從而導致整個程式的效能下降**。 反觀 `Condition` 的 `await` 和 `signal` 方法,即使有多個生產者,程式也只會喚醒一個有效的生產者進行工作,如下圖所示: ![image.png](https://cdn.nlark.com/yuque/0/2020/png/92791/1607955214921-acf26366-d4a0-4415-852c-cba6fa1ad04f.png#align=left&display=inline&height=588&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1176&originWidth=394&size=94307&status=done&style=none&width=197) 生產者和生產者 2 依次會被交替的喚醒進行工作,所以這樣執行時並沒有任何多餘的開銷,從而相比於 `notifyAll` 而言整個程式的效能會提升不少。 # 總結 本文我們通過程式碼和流程圖的方式演示了 `wait` 方法和 `notify/notifyAll` 方法的使用缺陷,它的缺陷主要有兩個,一個是在極端環境下使用 `notify` 會造成程式“假死”的情況,另一個就是使用 `notifyAll` 會造成效能下降的問題,因此在進行執行緒通訊時,強烈建議使用 `Condition` 類來實現。 > PS:有人可能會問為什麼不用 Condition 的 signalAll 和 notifyAll 進行效能對比?而使用 signal 和 notifyAll 進行對比?我只想說,既然使用 signal 可以實現此功能,為什麼還要使用 signalAll 呢?這就好比在有暖氣的 25 度的房間裡,穿一件短袖就可以了,為什麼還要穿一件棉襖呢? > 關注公眾號「Java中文社群」檢視更多幹貨,檢視 Github 發現更多精彩:https://github.com/vipstone/al