面試突擊24:為什麼wait和notify必須放在synchronized中?

在多執行緒編程中,wait 方法是讓當前執行緒進入休眠狀態,直到另一個執行緒調用了 notify 或 notifyAll 方法之後,才能繼續恢復執行。而在 Java 中,wait 和 notify/notifyAll 有著一套自己的使用格式要求,也就是在使用 wait 和 notify(notifyAll 的使用和 notify 類似,所以下文就只用 notify 用來指代二者)必須配合 synchronized 一起使用才行。

wait/notify基礎使用

wait 和 notify 的基礎方法如下:

Object lock = new Object();
new Thread(() -> {
    synchronized (lock) {
        try {
            System.out.println("wait 之前");
            // 調用 wait 方法
            lock.wait();
            System.out.println("wait 之後");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}).start();

Thread.sleep(100);
synchronized (lock) {
    System.out.println("執行 notify");
    // 調用 notify 方法
    lock.notify();
}

以上程式碼的執行結果如下圖所示:
image.png

wait/notify和synchronized一起用?

那問題來了,是不是 wait 和 notify 一定要配合 synchronized 一起使用呢?wait 和 notify 單獨使用行不行呢?
我們嘗試將以上程式碼中的 synchronized 程式碼行刪除,實現程式碼如下:
image.png
初看程式碼好像沒啥問題,編譯器也沒報錯,好像能「正常使用」,然而當我們運行以上程式時就會發生如下錯誤:
image.png
從上述結果可以看出:無論是 wait 還是 notify,如果不配合 synchronized 一起使用,在程式運行時就會報 IllegalMonitorStateException 非法的監視器狀態異常,而且 notify 也不能實現程式的喚醒功能了。

原因分析

從上述的報錯資訊我們可以看出,JVM 在運行時會強制檢查 wait 和 notify 有沒有在 synchronized 程式碼中,如果沒有的話就會報非法監視器狀態異常(IllegalMonitorStateException),但這也僅僅是運行時的程式表象,那為什麼 Java 要這樣設計呢?
其實這樣設計的原因就是為了防止多執行緒並發運行時,程式的執行混亂問題。初看這句話,好像是用來描述「鎖」的。然而實際情況也是如此,wait 和 notify 引入鎖就是來規避並發執行時程式的執行混亂問題的。那這個「執行混亂問題」到底是啥呢?接下來我們繼續往下看。

wait和notify問題復現

我們假設 wait 和 notify 可以不加鎖,我們用它們來實現一個自定義阻塞隊列。
這裡的阻塞隊列是指讀操作阻塞,也就是當讀取數據時,如果有數據就返回數據,如果沒有數據則阻塞等待數據,實現程式碼如下:

class MyBlockingQueue {
    // 用來保存數據的集合
    Queue<String> queue = new LinkedList<>();

    /**
     * 添加方法
     */
    public void put(String data) {
        // 隊列加入數據
        queue.add(data); 
        // 喚醒執行緒繼續執行(這裡的執行緒指的是執行 take 方法的執行緒)
        notify(); // ③
    }

    /**
     * 獲取方法(阻塞式執行)
     * 如果隊列裡面有數據則返回數據,如果沒有數據就阻塞等待數據
     * @return
     */
    public String take() throws InterruptedException {
        // 使用 while 判斷是否有數據(這裡使用 while 而非 if 是為了防止虛假喚醒)
        while (queue.isEmpty()) { // ①  
            // 沒有任務,先阻塞等待
            wait(); // ②
        }
        return queue.remove(); // 返回數據
    }
}

注意上述程式碼,我們在程式碼中標識了三個關鍵執行步驟:①:判斷隊列中是否有數據;②:執行 wait 休眠操作;③:給隊列中添加數據並喚醒阻塞執行緒。
如果不強制要求添加 synchronized,那麼就會出現如下問題:

步驟 執行緒1 執行緒2
1 執行步驟 ① 判斷當前隊列中沒有數據
2 執行步驟 ③ 將數據添加到隊列,並喚醒執行緒1繼續執行
3 執行步驟 ② 執行緒 1 進入休眠狀態

從上述執行流程看出問題了嗎?
如果 wait 和 notify 不強制要求加鎖,那麼在執行緒 1 執行完判斷之後,尚未執行休眠之前,此時另一個執行緒添加數據到隊列中。然而這時執行緒 1 已經執行過判斷了,所以就會直接進入休眠狀態,從而導致隊列中的那條數據永久性不能被讀取,這就是程式並發運行時「執行結果混亂」的問題。
然而如果配合 synchronized 一起使用的話,程式碼就會變成以下這樣:

class MyBlockingQueue {
    // 用來保存任務的集合
    Queue<String> queue = new LinkedList<>();

    /**
     * 添加方法
     */
    public void put(String data) {
        synchronized (MyBlockingQueue.class) {
            // 隊列加入數據
            queue.add(data);
            // 為了防止 take 方法阻塞休眠,這裡需要調用喚醒方法 notify
            notify(); // ③
        }
    }

    /**
     * 獲取方法(阻塞式執行)
     * 如果隊列裡面有數據則返回數據,如果沒有數據就阻塞等待數據
     * @return
     */
    public String take() throws InterruptedException {
        synchronized (MyBlockingQueue.class) {
            // 使用 while 判斷是否有數據(這裡使用 while 而非 if 是為了防止虛假喚醒)
            while (queue.isEmpty()) {  // ①
                // 沒有任務,先阻塞等待
                wait(); // ②
            }
        }
        return queue.remove(); // 返回數據
    }
}

這樣改造之後,關鍵步驟 ① 和關鍵步驟 ② 就可以一起執行了,從而當執行緒執行了步驟 ③ 之後,執行緒 1 就可以讀取到隊列中的那條數據了,它們的執行流程如下:

步驟 執行緒1 執行緒2
1 執行步驟 ① 判斷當前隊列沒有數據
2 執行步驟 ② 執行緒進入休眠狀態
3 執行步驟 ③ 將數據添加到隊列,並執行喚醒操作
4 執行緒被喚醒,繼續執行
5 判斷隊列中有數據,返回數據

這樣咱們的程式就可以正常執行了,這就是為什麼 Java 設計一定要讓 wait 和 notify 配合上 synchronized 一起使用的原因了。

總結

本文介紹了 wait 和 notify 的基礎使用,以及為什麼 wait 和 notify/notifyAll 一定要配合 synchronized 使用的原因。如果 wait 和 notify/notifyAll 不強制和 synchronized 一起使用,那麼在多執行緒執行時,就會出現 wait 執行了一半,然後又執行了添加數據和 notify 的操作,從而導致執行緒一直休眠的缺陷。

是非審之於己,毀譽聽之於人,得失安之於數。

公眾號:Java面試真題解析

面試合集://gitee.com/mydb/interview