Java-併發-wait()、notify()和notifyAll()

羊37發表於2024-05-16

0.是什麼(What)

wait(), notify(), 和 notifyAll()方法都是Object類的一部分,用於實現執行緒間的協作。

1.為什麼(Why)

執行緒的執行順序是隨機的(作業系統隨機排程的,搶佔式執行),但是有時候,我們希望的是它們能夠順序的執行。

所以引入了這幾個方法,使得我們能保證一定的順序。

1.1 Objec類

在 Java 中,所有物件都可以作為同步的監視器鎖,即:何物件都可以在 synchronized 塊或方法中被用作鎖。

透過繼承的特性,放在Object類中,無論哪個類的例項,都可以使用這些方法進行執行緒間通訊。

1.2 歷史原因

在 Java 1.0 版本釋出時,Java 的併發機制主要依賴於 synchronized 關鍵字和 Object 類中的 wait(), notify(), 和 notifyAll() 方法。

這種設計雖然在高階併發控制工具引入後顯得基礎,但依然是 Java 語言設計的一部分,保持了向後相容性和麵向物件設計的一致性。

2.怎麼用(How)

檢視Object類,可以看到這幾個方法。

  • notify()、notifyAll()、wait(long timeout)都是final + native

  • wait()和wait(long timeout, int nanos)則是基於wait(long timeout)的過載

    public final native void notify();
	
	public final native void notifyAll();

	public final native void wait(long timeout) throws InterruptedException;

	public final void wait() throws InterruptedException {
        wait(0);
    }

    public final void wait(long timeout, int nanos) throws InterruptedException {
        if (timeout < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (nanos < 0 || nanos > 999999) {
            throw new IllegalArgumentException(
                                "nanosecond timeout value out of range");
        }

        if (nanos > 0) {
            timeout++;
        }

        wait(timeout);
    }

由於Object是頂層父類,所以任意變數例項化後,都會自動繼承其中的方法,wait()、notify()、notifyAll()亦是如此,任何例項物件都會自動具備。

image-20240511220815239

2.1 執行緒的狀態

在Thread中,定義了一個列舉State,描述了執行緒的幾種狀態。

image-20240515233813626

    public enum State {
        NEW,
        
        RUNNABLE,
        
        BLOCKED,

        WAITING,

        TIMED_WAITING,

        TERMINATED;
    }

6態關係,如下圖所示。

怎麼記憶:

  • 正常情況下:新建 -> 執行 -> 終止
  • 涉及到2種等待狀態:等待 or 超時等待
  • 1種阻塞狀態
序號 執行緒狀態 描述
1 新建(New) 執行緒物件被建立後,但尚未呼叫 start() 方法。
2 可執行(Runnable) 呼叫了 start() 方法後,執行緒進入就緒狀態,等待CPU排程執行。
3 阻塞(Blocked) 執行緒等待獲取監視器鎖,試圖進入同步方法或同步塊時。
4 等待(Waiting) 執行緒等待另一個執行緒顯式地喚醒它,例如呼叫 Object.wait()Thread.join() 方法。
5 計時等待(Timed Waiting) 執行緒等待指定時間或被喚醒,例如呼叫 Thread.sleep(long millis)Object.wait(long timeout) 方法。
6 終止(Terminated) 執行緒執行完畢或因異常退出,執行緒生命週期結束。

image-20240515233529943

看上面的圖,我們很好理解到,核心點在於Runnable向其他狀態的轉義。

開始、執行和終止很好理解,關鍵在於理解等待、超時等待和阻塞。

2.1.1 New

  • 描述:執行緒物件已經建立,但尚未啟動。

  • 特點:執行緒處於新建狀態,此時還未呼叫 start() 方法。

    Thread thread = new Thread(() -> {
        // 執行緒任務
    });
    

2.1.2 Runnable

  • 描述:執行緒已經啟動,正在執行或準備執行。

  • 特點:呼叫 start() 方法後,執行緒進入 Runnable 狀態。此狀態下的執行緒可能正在執行,也可能在等待作業系統為其分配CPU時間片。

    thread.start(); // 啟動執行緒
    

2.1.3 Terminated

  • 描述:執行緒已經完成執行。

  • 特點:執行緒的 run() 方法執行完畢或執行緒因為異常而終止。

    // 執行緒任務執行完畢
    

2.1.4 Waiting

  • 描述:執行緒正在等待其他執行緒顯式地喚醒。

  • 特點:執行緒進入等待狀態需要呼叫以下方法之一:Object.wait()Thread.join()LockSupport.park()

    synchronized (obj) {
        obj.wait(); // 進入等待狀態
    }
    

2.1.5 Timed_Waiting

  • 描述:執行緒正在等待一段指定的時間。

  • 特點:執行緒進入超時等待狀態需要呼叫帶有超時引數的方法,如 Thread.sleep(long millis)Object.wait(long timeout)Thread.join(long millis)LockSupport.parkNanos(long nanos)LockSupport.parkUntil(long deadline)

    Thread.sleep(2000); // 休眠2秒
    

2.1.6 Blocked

  • 描述:執行緒試圖獲取一個被其他執行緒持有的鎖而被阻塞。

  • 特點:執行緒在進入同步塊或同步方法時,如果鎖被其他執行緒持有,會進入 Blocked 狀態,直到獲取到鎖。

    synchronized (obj) {
        // 嘗試獲取鎖
    }
    

2.1.7 擴充套件

2.1.7.1 等待和超時等待

在Java中,等待狀態(Waiting)和超時等待狀態(Timed Waiting)都是執行緒的非執行狀態,意味著執行緒不會佔用CPU時間。

1.等待Waiting

  • 觸發條件

    執行緒進入等待狀態是因為呼叫了以下幾種方法,而這些方法要求另一個執行緒顯式地喚醒它:

    • Object.wait():執行緒在呼叫 wait() 方法後進入等待狀態,直到其他執行緒呼叫 notify()notifyAll() 方法來喚醒它。
    • Thread.join():呼叫 join() 方法的執行緒將等待目標執行緒完成。
    • LockSupport.park():執行緒呼叫 park() 方法後進入等待狀態,直到被其他執行緒呼叫 unpark() 方法喚醒。
  • 特點

    • 無法自動喚醒:執行緒進入等待狀態後,必須依賴其他執行緒顯式地喚醒它。
    • 無超時:執行緒將一直處於等待狀態,直到被喚醒。

2.超時等待Timed Waiting

  • 觸發條件

    執行緒進入超時等待狀態是因為呼叫了帶有超時引數的方法,這些方法會使執行緒等待一段指定的時間或者被喚醒。常見的方法包括:

    • Thread.sleep(long millis):執行緒將休眠指定的毫秒數。
    • Object.wait(long timeout):執行緒在呼叫 wait() 方法時等待指定的時間,如果超時未被喚醒則自動喚醒。
    • Thread.join(long millis):呼叫 join(long millis) 的執行緒將等待目標執行緒完成或者超時。
    • LockSupport.parkNanos(long nanos)LockSupport.parkUntil(long deadline):執行緒等待指定的納秒時間或直到特定時間。
  • 特點

    • 自動喚醒:執行緒進入超時等待狀態後,如果沒有被顯式喚醒,會在指定時間到後自動喚醒。
    • 有限等待時間:執行緒將在設定的時間期限到達後自動從超時等待狀態中退出。

3.對比理解

  • 依賴其他執行緒喚醒
    • 等待狀態(Waiting)依賴於其他執行緒的顯式喚醒操作
    • 超時等待狀態(Timed Waiting)在設定時間到達後自動喚醒。
  • 持續時間
    • 等待狀態可以無限期地等待,直到被顯式喚醒。
    • 超時等待狀態有一個明確的時間期限,執行緒在期限到達後會自動喚醒。

4.適用場景

序號 狀態 特點 場景
1 等待狀態(Waiting) 需要其他執行緒顯式喚醒,執行緒將無限期等待,直到被喚醒 - 生產者-消費者模型中消費者等待生產者通知
- 多執行緒協作,如等待其他執行緒完成任務
2 超時等待狀態(Timed Waiting) 指定等待時間,時間到達或被喚醒後自動退出等待狀態 - 網路程式設計中的超時操作
- 定時任務
- 獲取鎖時的超時重試

下面是一個示例:

@Slf4j
public class WaitNotifyExample {

    private static final Object lock = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (lock) {
                try {
                    log.info("執行緒1:等待通知");
                    lock.wait(); // 進入等待狀態,並釋放鎖
                    log.info("執行緒1:收到通知");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (lock) {
                log.info("執行緒2:持有鎖並休眠");
                try {
                    Thread.sleep(2000); // 持有鎖2秒鐘
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                lock.notify(); // 通知等待的執行緒
                log.info("執行緒2:通知等待的執行緒");
            }
        });

        t1.start();
        t2.start();
    }

}

這段程式碼中:

  • 執行緒1和執行緒2持有同一個物件鎖lock,執行緒1進入wait()後,釋放當前鎖。
  • 執行緒2拿到鎖後,休眠2s後主動醒過來,然後喚醒執行緒1,最後執行緒1輸出收到了通知。

image-20240516210128514

2.1.7.2 等待和阻塞

2.1.1.1 等待(Waiting)

  • 觸發條件

    執行緒進入等待狀態是因為呼叫了以下幾種方法,而這些方法要求另一個執行緒顯式地喚醒它:

    • Object.wait():執行緒在呼叫 wait() 方法後進入等待狀態,直到其他執行緒呼叫 notify()notifyAll() 方法來喚醒它。
    • Thread.join():呼叫 join() 方法的執行緒將等待目標執行緒完成。
    • LockSupport.park():執行緒呼叫 park() 方法後進入等待狀態,直到被其他執行緒呼叫 unpark() 方法喚醒。
  • 特點

    • 無法自動喚醒:執行緒進入等待狀態後,必須依賴其他執行緒顯式地喚醒它。
    • 無超時:執行緒將一直處於等待狀態,直到被喚醒。

2.1.1.2 阻塞(Block)

  • 觸發條件

    執行緒進入阻塞狀態是因為它試圖獲取一個被其他執行緒持有的鎖。

    • 例如,當一個執行緒試圖進入一個同步塊或同步方法,但鎖被其他執行緒持有時,執行緒進入阻塞狀態。
  • 特點

    • 不釋放鎖:執行緒被阻塞時,不會釋放它已經持有的鎖。
    • 自動喚醒:當持有鎖的執行緒釋放鎖時,阻塞的執行緒會自動被喚醒。

2.1.1.3 對比理解

  • 觸發條件

    • 等待(Waiting):呼叫 wait()join()park() 方法。
    • 阻塞(Blocked):試圖獲取一個被其他執行緒持有的鎖。
  • 釋放鎖

    • 等待(Waiting):執行緒呼叫 wait() 方法時,會釋放當前持有的鎖。
    • 阻塞(Blocked):執行緒被阻塞時,不會釋放它已經持有的鎖。
  • 喚醒方式

    • 等待(Waiting):需要其他執行緒顯式呼叫 notify()notifyAll(),或者 unpark()
    • 阻塞(Blocked):當持有鎖的執行緒釋放鎖時,阻塞的執行緒會自動被喚醒。
  • 執行緒狀態轉換

    • 等待(Waiting):執行緒從執行狀態(RUNNABLE)轉換到等待狀態(WAITING),然後回到執行狀態(RUNNABLE)。
    • 阻塞(Blocked):執行緒從執行狀態(RUNNABLE)轉換到阻塞狀態(BLOCKED),然後回到執行狀態(RUNNABLE)。

2.1.1.4 適用場景

序號 狀態 特點 場景
1 等待狀態(Waiting) 需要其他執行緒顯式喚醒,執行緒將無限期等待,直到被喚醒 - 生產者-消費者模型中消費者等待生產者通知
- 多執行緒協作,如等待其他執行緒完成任務
2 阻塞狀態(Blocked) 執行緒被阻塞時,不會釋放已持有的鎖,當持有鎖的執行緒釋放鎖時,阻塞的執行緒會自動被喚醒。 - 執行緒試圖進入同步塊或同步方法,但鎖被其他執行緒持有。

下面是一個例子:

@Slf4j
public class BlockedExample {
    private static final Object lock = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (lock) {
                log.info("執行緒1:持有鎖並休眠");
                try {
                    Thread.sleep(3000); // 持有鎖3秒鐘
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                log.info("執行緒1:釋放鎖");
            }
        });

        Thread t2 = new Thread(() -> {
            log.info("執行緒2:等待獲取鎖");
            synchronized (lock) {
                log.info("執行緒2:獲取到鎖");
            }
        });

        t1.start();
        t2.start();
    }
}

這段程式碼中:

  • 執行緒1持有鎖後,帶著鎖睡眠3s,之後釋放鎖。
  • 執行緒2在進入同步程式碼塊前,需要等待執行緒1釋放鎖,之後才能輸出。

image-20240516210828539

2.1 狀態切換

由上文可知,等待狀態(Waiting)如果不主動喚醒,的執行緒可能會一直處於等待狀態,永遠不會被喚醒。

這意味著該執行緒將一直保持阻塞狀態,不會繼續執行其後續程式碼,且不會釋放它所持有的任何鎖。

這種情況可能會導致以下問題:

  • 執行緒洩漏:等待狀態的執行緒如果沒有被喚醒,可能會佔用系統資源而不做任何有用的工作,導致執行緒洩漏。

  • 死鎖:如果一個執行緒在等待狀態中持有某些關鍵資源(例如鎖),其他執行緒可能無法獲取這些資源,導致死鎖。

  • 程式掛起:如果程式中有重要任務依賴於等待狀態的執行緒被喚醒,那麼程式的某些部分可能會永遠無法執行,導致程式掛起或不響應。

2.1.1 wait()、notify()、notifyAll()

序號 方法 定義 使用場景
1 wait() 使當前執行緒等待,直到其他執行緒呼叫notify()notifyAll()方法,或執行緒被中斷 執行緒等待某個條件滿足
2 notify() 喚醒在此物件監視器上等待的單個執行緒。
如果有多個執行緒在等待,則隨機選擇其中一個被喚醒。
通知一個等待執行緒的條件已經滿足
3 notifyAll() 喚醒在此物件監視器上等待的所有執行緒。
被喚醒的執行緒會去競爭物件的鎖,只有獲得鎖的執行緒才能繼續執行
通知所有等待執行緒的條件已經滿足

2.1.2 物件的鎖狀態

wait(), notify(), 和 notifyAll() 的使用主要是為了實現執行緒間的協作和同步,具體是否需要使用這些方法取決於是否需要當前執行緒進行等待和喚醒,與鎖的具體型別沒有直接關係。

附:在Java-執行緒-synchronized這篇文章中,我們描述到Java物件的鎖狀態有以下幾種。

img

序號 鎖型別 場景 實現 位置 優點 缺點
0 無鎖 - 物件沒有被任何執行緒持有鎖,所有執行緒都能自由訪問物件,無需任何同步機制。 - - -
1 偏向鎖 同一執行緒多次進入同步塊時(沒有競爭) 在物件頭中記錄偏向執行緒的ID,如果同一執行緒再次進入同步塊,直接進入,無需CAS操作。 使用者態 鎖開銷極低,適用於無競爭的情況 一旦有其他執行緒競爭,需等待偏向撤銷,適用於執行緒間少量競爭的場景。
2 輕量級鎖 鎖定時間短,發生競爭時使用 執行緒嘗試透過CAS操作獲取鎖,如果失敗則自旋等待一段時間。 使用者態 避免了執行緒切換的開銷,自旋等待有機會快速獲得鎖。 自旋等待會消耗CPU時間,不適用於長時間持鎖的情況。
3 重量級鎖 執行緒競爭嚴重或持鎖時間較長 透過作業系統的互斥量(Mutex)實現,執行緒競爭鎖失敗時會被掛起。 核心態 執行緒掛起等待鎖釋放,不消耗CPU時間 執行緒掛起和恢復的開銷較大,適用於長時間持鎖的情況。

3.常見問題

3.1 sleep方法跟那個wait方法有什麼區別啊?

序號 特性 sleep() wait()
1 所屬類 Thread Object
2 是否釋放鎖
3 是否需要在同步塊中使用
4 用途 暫停當前執行緒指定時間 等待其他執行緒的通知或超時喚醒
5 是否用於執行緒間通訊
6 呼叫方式 靜態方法 例項方法
  • 關於是否釋放鎖

    • sleep() 方法
      • 不釋放鎖:當一個執行緒呼叫 sleep() 方法時,執行緒會進入休眠狀態,但它依然持有任何已經持有的鎖。這意味著其他執行緒無法獲得這些鎖,直到休眠執行緒醒來並釋放鎖。
      • 影響:由於 sleep() 方法不釋放鎖,使用它進行執行緒間的協調時需要小心。如果一個執行緒在持有鎖的情況下呼叫 sleep() 方法,可能會導致其他需要相同鎖的執行緒被阻塞,進而導致效能問題或死鎖。
    • wait() 方法
      • 釋放鎖:當一個執行緒呼叫 wait() 方法時,它會釋放當前持有的鎖,並進入等待狀態。這樣其他執行緒可以獲取到這個鎖並執行相應的操作。
      • 影響wait() 方法的設計初衷是用於執行緒間的通訊和協調。透過釋放鎖,其他執行緒可以繼續執行,從而實現資源共享和執行緒間協作。例如,在生產者-消費者模型中,消費者在等待新的資料時呼叫 wait() 方法釋放鎖,讓生產者可以繼續生產資料。
  • 關於是否需要在同步塊中使用

    • sleep() 方法
      • 不需要在同步塊中使用sleep() 方法可以在任何地方呼叫,不需要在同步塊或同步方法中。它只是讓當前執行緒暫停執行指定時間,與執行緒同步機制無關。
      • 影響:由於 sleep() 方法不涉及鎖的釋放與獲取,它在設計上與同步機制無關。你可以在同步塊內外呼叫 sleep() 方法,但需要注意的是,如果在同步塊內呼叫 sleep() 方法,執行緒在休眠期間依然持有鎖。
    • wait() 方法
      • 必須在同步塊中使用wait() 方法必須在同步塊或同步方法中呼叫。呼叫 wait() 方法的執行緒必須持有呼叫物件的監視器鎖(即物件鎖),否則會丟擲 IllegalMonitorStateException 異常。
      • 影響wait() 方法的設計初衷是用於執行緒間的通訊和協調。只有在同步塊中呼叫 wait() 方法,才能確保執行緒在進入等待狀態之前持有物件的監視器鎖,並在呼叫 wait() 時釋放該鎖,使其他執行緒能夠獲得該鎖進行相應操作。

好,這裡你就可以簡單的理解為,因為sleep不釋放鎖,而wait釋放鎖,所以呢,為了保證wait能有鎖來釋放,所以說wait必須在同步程式碼塊中。

參考下我這篇文章,瞭解下鎖機制:Java-執行緒-synchronized

3.2 多執行緒的wait、notify、notifyAll方法為何放在Object上而不是Thread上

嗯,這個經過前面的分析,我們已經很好理解了。

關鍵點在於,鎖是與物件關聯的。

  • Java中的每一個物件都可以作為一個鎖,稱為監視器鎖。synchronized 關鍵字就是基於物件鎖來實現同步的。
  • 執行緒在進入同步塊時會獲取物件的鎖,並在退出同步塊時釋放鎖。
  • wait(), notify(), 和 notifyAll() 方法是與物件鎖關聯的,而不是與執行緒本身關聯的。呼叫這些方法必須持有相應物件的鎖,這也就是為什麼這些方法在 Object 類中定義的原因。

相關文章