Java多執行緒中的wait/notify通訊模式

JJian發表於2020-11-30

前言

  最近在看一些JUC下的原始碼,更加意識到想要學好Java多執行緒,基礎是關鍵,比如想要學好ReentranLock原始碼,就得掌握好AQS原始碼,而AQS原始碼中又有很多Java多執行緒經典的一些應用;再比如看了執行緒池的核心原始碼實現,又學到了很多核心實現,其實這些都可以提出來慢慢消化並變成自己的知識點,今天這個Java等待/通知模式其實是Thread.join()實現的關鍵,還有執行緒池工作執行緒中執行緒跟執行緒之間的通訊的核心所在,故在此為了加深理解,做此記錄!

  參考資料《Java併發程式設計藝術》(電子PDF版),有需要的朋友的可以私信或者評論

 


 

一、什麼是Java執行緒的等待/通知模式

1、等待/通知模式概述

  首先先介紹下官方的一個正式的介紹:

  等待/通知機制,是指一個執行緒A呼叫了物件object的wait()方法進入等待狀態,而另一個執行緒B呼叫了物件object的notify或者notifyAll()方法,執行緒A收到通知後從物件O的wait()方法返回,進而還行後續操作。

  而我的理解是(舉例說明):

  假設工廠裡有兩條流水線,某個工作流程需要這兩個流水線配合完成,這兩個流水線分別是A和B,其中A負責準備各種配件,B負責租裝配件之後產出輸出到工作臺。B的工作需要A的配件準備充分,否則就會一直等待A準備好配件,並且A準備好配件後會通過一個開頭通知告訴B我已經準備好了,你那邊不用一直等待了,可以繼續執行任務了。流程A與流程B就是對應的執行緒A與執行緒B之間的通訊,即可以理解為相互配合,具體也就是“”通知/等待“”機制!

2、需要注意的細節  

  那麼,我們都知道超類Object有wait()方法與notify()/notifyAll()方法,在進行正式程式碼舉例之前,應該先加深下對這三個方法的理解與一些細節(有一些細節確實容易被忽略)

  • 呼叫wait()方法,會釋放鎖(這一點我想大部分人都知道),執行緒狀態由RUNNING->WAITNG,當前執行緒進入物件等待佇列中;
  • 呼叫notify()/notifyAll()方法不會立馬釋放鎖(這一點我大家人也應該知道,但是什麼時候釋放鎖呢?--------請看下一條),notify()方法是將等待佇列中的執行緒移到同步佇列中,而notifyAll()則是全部移到同步佇列中,被移出的執行緒狀態WAITING-->BLOCKED;
  • 當前呼叫notify()/notifyAll()的執行緒釋放鎖了才算釋放鎖,才有機會喚醒wait執行緒返回(為什麼有才有機會返回呢?------繼續看下一條)
  • 從wait()返回的前提是必須獲得呼叫物件鎖,也就是說notify()與notifyAll()釋放鎖之後,wait()進入BLOCKED狀態,如果其他執行緒有競爭當前鎖的話,wait執行緒繼續爭取鎖資格(不好理解的話,請看下面的程式碼舉例)
  • 使用wait()、notify()、notifyAll()方法時需要先調物件加鎖(這可能是最容易忽視的點了,至於為什麼,請先看了程式碼之後,看本篇博文最後補充:wait()、notify()、notifyAll()加鎖的原因----防止執行緒即飢餓

二、程式碼舉例

1、結合程式碼理解

 結合上述的“工廠流程裝配配件併產出的例子”,我們有兩個執行緒(流水線)WaitThread與NotifyThread、其中WaitThread是被通知的任務,完成主要的工作(組裝配件完成產品),需要時刻判斷標誌位(開關);NotifyThread是需要通知的任務,需要對WaitThread進行“監督通知”,兩個配合才能更好完成產品的組裝並輸出。

public class WaitNotify {

    static Object lock = new Object();
    static boolean flag = false;
    public static void main(String[] args) {
        new Thread(new WaitThread(), "WaitThread").start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        new Thread(new NotifyThread(), "NotifyThread").start();

    }

    /**
     * 流水線A,完成主要任務
     */
    static class WaitThread implements Runnable{
        @Override
        public void run() {
            // 獲取object物件鎖
            synchronized (lock){
                // 條件不滿足時一直在等,等另外的執行緒改變該條件,並通知該wait執行緒
                while (!flag){
                    try {
                        System.out.println(Thread.currentThread() + " is waiting, flag is "+flag);
                        // wait()方法呼叫就會釋放鎖,當前執行緒進入等待佇列。
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                // TODO 條件已經滿足,不繼續while,完成任務
                System.out.println(Thread.currentThread() + " is running, flag is "+flag);
            }
        }
    }
    /**
     * 流水線B,對開關進行控制,並通知流水線A
     */
    static class NotifyThread implements Runnable{
        @Override
        public void run() {
            // 獲取等wait執行緒同一個object物件鎖
            synchronized (lock){
                flag = true;
                // 通知wait執行緒,我已經改變了條件,你可以繼續返回執行了(返回之後繼續判斷while)
                // 但是此時通知notify()操作並立即不會釋放鎖,而是要等當前執行緒釋放鎖
                // TODO 我準備好配件了,我需要通知全部的組裝流水線A.....
                lock.notifyAll();
                System.out.println(Thread.currentThread() + " hold lock, notify waitThread and flag is "+flag);
            }
        }
    }
}

執行main函式,輸出:

Thread[WaitThread,5,main] is waiting, flag is false
Thread[NotifyThread,5,main] hold lock, notify waitThread and flag is true
Thread[WaitThread,5,main] is running, flag is true

車床流水工作開啟,流水線的開關一開始是關閉的(flag=false),流水線B(NotifyThread)去開啟後,開始自動喚醒流水線A(WaitThread),整個流水線開始工作了......

  • Thread[WaitThread,5,main] is waiting, flag is false: 一開始流水線A發現自己沒有配件可租裝,所以等流水線A準備好配件(這樣是不是覺得特別傻,哈哈哈,真正的流水線不會浪費時間等的,而且會有很多條流水線B準備配件的,這裡只是舉例說明,望理解!);
  • Thread[NotifyThread,5,main] hold lock, notify waitThread and flag is true:流水線B準備好了配件,開啟開關(flag=ture),並通知流水線A,讓流水線A開始工作;
  • Thread[WaitThread,5,main] is running, flag is true,流水線B收到了通知,再次檢查開關是否開啟了,開啟的話就開始返回繼續完成工作了

其實結合上述我舉的例子還是很好理解的,下面是大概的一個粗略時序圖:

            

2、擴充套件理解----wait()返回的前提是獲得了鎖

上述已經表達了這個注意的細節:從wait()返回的前提是必須獲得呼叫物件鎖我們再增加能競爭lock的同步程式碼塊(紅字部分)。

public class WaitNotify {

    static Object lock = new Object();
    static boolean flag = false;
    public static void main(String[] args) {
        new Thread(new WaitThread(), "WaitThread").start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        new Thread(new NotifyThread(), "NotifyThread").start();
    }

    /**
     * 流水線A,完成主要任務
     */
    static class WaitThread implements Runnable{
        @Override
        public void run() {
            // 獲取object物件鎖
            synchronized (lock){
                // 條件不滿足時一直在等,等另外的執行緒改變該條件,並通知該wait執行緒
                while (!flag){
                    try {
                        System.out.println(Thread.currentThread() + " is waiting, flag is "+flag);
                        // wait()方法呼叫就會釋放鎖,當前執行緒進入等待佇列。
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                // TODO 條件已經滿足,不繼續while,完成任務
                System.out.println(Thread.currentThread() + " is running, flag is "+flag);
            }
        }
    }
    /**
     * 流水線B,對開關進行控制,並通知流水線A
     */
    static class NotifyThread implements Runnable{
        @Override
        public void run() {
            // 獲取等wait執行緒同一個object物件鎖
            synchronized (lock){
                flag = true;
                // 通知wait執行緒,我已經改變了條件,你可以繼續返回執行了(返回之後繼續判斷while)
                // 但是此時通知notify()操作並立即不會釋放鎖,而是要等當前執行緒釋放鎖
                // TODO 我準備好配件了,我需要通知全部的組裝流水線A.....
                lock.notifyAll();
                System.out.println(Thread.currentThread() + " hold lock, notify waitThread and flag is "+flag);
            }
            // 模擬跟流水線B競爭
            synchronized (lock){
                System.out.println(Thread.currentThread() + " hold lock again");
            }
        }
    }
}

輸出結果:

Thread[WaitThread,5,main] is waiting, flag is false
Thread[NotifyThread,5,main] hold lock, notify waitThread and flag is true
Thread[NotifyThread,5,main] hold lock again
Thread[WaitThread,5,main] is running, flag is true

 

其中第三條跟第四條順序可能會反著來的,這就是因為lock鎖可能被紅字部分的synchronized程式碼塊競爭獲取(這樣wait()方法可能獲取不到lock鎖,不會返回),也可能被waitThread獲取從wait()方法返回

Thread[WaitThread,5,main] is waiting, flag is false
Thread[NotifyThread,5,main] hold lock, notify waitThread and flag is true
Thread[WaitThread,5,main] is running, flag is true
Thread[NotifyThread,5,main] hold lock again

三、等待/通知模式的應用

1、Thread.join()中原始碼應用

Thread.join()作用:當執行緒A等待thread執行緒終止之後才從thread.join()返回, 每個執行緒終止的前提是前驅執行緒終止,每個執行緒等待前驅執行緒終止後,才從join方法返回,這裡涉及了等待/通知機制(等待前驅執行緒結束,接收前驅執行緒結束通知)

Thread.join()原始碼中,使用while選好判斷前驅執行緒是否活著,如果前驅執行緒還活著就一直wait等待,當然如果超時的話就直接返回。

public final synchronized void join(long millis)
    throws InterruptedException {
        long base = System.currentTimeMillis();
        long now = 0;

        if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }
        // 這裡的while(){wait(millis)} 就是利用等待/通知中的等待模式,只不過加上了超時設定
        if (millis == 0) {
            // while迴圈,當執行緒還活著的時候就一直迴圈等待,直到執行緒終止
            while (isAlive()) {
                // wait等待
                wait(0);
            }
            // 條件滿足時返回
        } else {
            while (isAlive()) {
                long delay = millis - now;
                if (delay <= 0) {
                    break;
                }
                wait(delay);
                now = System.currentTimeMillis() - base;
            }
        }
    }

2、其它的應用

  執行緒池的本質是使用一個執行緒安全的工作佇列連線工作者執行緒和客戶端執行緒,客戶端執行緒將任務放入工作佇列後便返回,而工作者執行緒則不斷地從工作佇列中取出工作並執行。那麼,在這裡的等待/通知模式的應用就是:

  工作佇列中執行緒job沒有的話也就是工作佇列為空的情況下,等待客戶端放入工作佇列執行緒任務,並通知工作執行緒繼續從工作佇列中獲取執行緒執行。

  注:關於執行緒池的應用原始碼這裡不做介紹,因為一時也講不完(自己也還沒有完全消化),先簡單介紹下應用到的地方還有概念。

  補充:其實資料庫的連線池也類似執行緒池這種工作流程,也會涉及等待/通知模式。

3、等待/通知正規化

  介紹了那麼多應用,這種模式應該有個統一的正規化來套用。對的,必然是有的:

  對於等待者(也可以稱之為消費者):

synchronized (物件lock) {
        while (條件不滿足) {
            物件.wait();
        }
        // TODO 處理邏輯
    }

  對於通知者(也可以稱之為生產者):

 synchronized (物件lock) {
        while (條件滿足) {
            改變條件
            物件.notify();
        }
    }

  注意實際開發中最好採用的是超時等待/通知模式,在thread.join()原始碼方法中完美體現

四、wait()、notify()、notifyAll()使用前需要加鎖的原因----防止執行緒即飢餓

(1)其實根據wait()注意事項也能明白,wait()是釋放鎖的,那麼不加鎖哪來釋放鎖

(2)wait()與notify()或者notifyAll()必須是搭配一起使用的,否則執行緒呼叫object.wait()之後,沒有超時機制,也沒有呼叫notify()或者notifyAll()喚醒的話,就一直處於WAITING狀態,造成呼叫wait()的執行緒一直都是飢餓狀態。

(3)由於第2條的,我們已知:即便我們使用了notify()或者notifyAll()去喚醒執行緒,但是沒有在適當的時機喚醒(比如呼叫wait()之前就喚醒了那麼仍然呼叫wait()執行緒處於WAITING狀態,所以我們必須保證wait()方法要麼不執行,要麼就執行完在被喚醒。也就是下列程式碼中1那裡不能允許插入呼叫notify/notifyAll,自然而然就增加synchronized關鍵字,保證wait()操作整體執行不被破壞

 synchronized (物件lock) {
        while (條件不滿足) {
            // 1 這裡如果先執行了notify/notifyAll方法,那麼2執行之後,該執行緒就一直WAITING
            物件.wait(); // 2
        }
        // TODO 處理邏輯
    }

用圖片展示執行順序就是:

(4)注意synchronized程式碼塊中,程式碼錯誤或者其它原因執行緒終止的話,沒有執行到wait()方法的話,是會自動釋放鎖的,不必擔心會死鎖

 

 

  

  

 

相關文章