java併發程式設計系列:wait/notify機制

小孩子4919發表於2019-04-18

標籤: 「我們都是小青蛙」公眾號文章

如果一個執行緒從頭到尾執行完也不和別的執行緒打交道的話,那就不會有各種安全性問題了。但是協作越來越成為社會發展的大勢,一個大任務拆成若干個小任務之後,各個小任務之間可能也需要相互協作最終才能執行完整個大任務。所以各個執行緒在執行過程中可以相互通訊,所謂通訊就是指相互交換一些資料或者傳送一些控制指令,比如一個執行緒給另一個暫停執行的執行緒傳送一個恢復執行的指令,下邊詳細看都有哪些通訊方式。

volatile和synchronized

可變共享變數是天然的通訊媒介,也就是說一個執行緒如果想和另一個執行緒通訊的話,可以修改某個在多執行緒間共享的變數,另一個執行緒通過讀取這個共享變數來獲取通訊的內容。

由於原子性操作、記憶體可見性和指令重排序的存在,java提供了volatilesynchronized的同步手段來保證通訊內容的正確性,假如沒有這些同步手段,一個執行緒的寫入不能被另一個執行緒立即觀測到,那這種通訊就是不靠譜的~

wait/notify機制

故事背景

也不知道是那個遭天殺的給我們學校廁所的坑裡塞了個塑料瓶,導致樓道里如黃河氾濫一般,臭味熏天。更加悲催的是整個樓只有這麼一個廁所,比這個更悲催的是這個廁所裡只有一個坑!!!!!好吧,讓我們用java來描述一下這個廁所:

public class Washroom {

    private volatile boolean isAvailable = false;    //表示廁所是否是可用的狀態

    private Object lock = new Object(); //廁所門的鎖

    public boolean isAvailable() {
        return isAvailable;
    }

    public void setAvailable(boolean available) {
        this.isAvailable = available;
    }

    public Object getLock() {
        return lock;
    }
}
複製程式碼

isAvailable欄位代表廁所是否可用,由於廁所損壞,預設是false的,lock欄位代表這個廁所門的鎖。需要注意的是isAvailable欄位被volatile修飾,也就是說有一個執行緒修改了它的值,它可以立即對別的執行緒可見~

由於廁所資源寶貴,英明的學校領導立即擬定了一個修復任務:

public class RepairTask implements Runnable {

    private Washroom washroom;

    public RepairTask(Washroom washroom) {
        this.washroom = washroom;
    }

    @Override
    public void run() {

        synchronized (washroom.getLock()) {
            System.out.println("維修工 獲取了廁所的鎖");
            System.out.println("廁所維修中,維修廁所是一件辛苦活,需要很長時間。。。");

            try {
                Thread.sleep(5000L);    //用執行緒sleep表示維修的過程
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            washroom.setAvailable(true);        //維修結束把廁所置為可用狀態
            System.out.println("維修工把廁所修好了,準備釋放鎖了");
        }
    }
}
複製程式碼

這個維修計劃的內容就是當維修工進入廁所之後,先把門鎖上,然後開始維修,維修結束之後把WashroomisAvailable欄位設定為true,以表示廁所可用。

與此同時,一群急得像熱鍋上的螞蟻的傢伙在廁所門前打轉轉,他們想做神馬不用我明說了吧??:

public class ShitTask implements Runnable {

    private Washroom washroom;

    private String name;

    public ShitTask(Washroom washroom, String name) {
        this.washroom = washroom;
        this.name = name;
    }

    @Override
    public void run() {
        synchronized (washroom.getLock()) {
            System.out.println(name + " 獲取了廁所的鎖");
            while (!washroom.isAvailable()) {
                // 一直等
            }
            System.out.println(name + " 上完了廁所");
        }
    }
}
複製程式碼

這個ShitTask描述了上廁所的一個流程,先獲取到廁所的鎖,然後判斷廁所是否可用,如果不可用,則在一個死迴圈裡不斷的判斷廁所是否可用,直到廁所可用為止,然後上完廁所釋放鎖走人。

然後我們看看現實世界都發生了什麼吧:

public class Test {
    public static void main(String[] args) {
        Washroom washroom = new Washroom();
        new Thread(new RepairTask(washroom), "REPAIR-THREAD").start();

        try {
            Thread.sleep(1000L);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        new Thread(new ShitTask(washroom, "狗哥"), "BROTHER-DOG-THREAD").start();
        new Thread(new ShitTask(washroom, "貓爺"), "GRANDPA-CAT-THREAD").start();
        new Thread(new ShitTask(washroom, "王尼妹"), "WANG-NI-MEI-THREAD").start();
    }
}
複製程式碼

學校先讓維修工進入廁所維修,然後包括狗哥、貓爺、王尼妹在內的上廁所大軍就開始圍著廁所打轉轉的旅程,我們看一下執行結果:

維修工 獲取了廁所的鎖
廁所維修中,維修廁所是一件辛苦活,需要很長時間。。。
維修工把廁所修好了,準備釋放鎖了
王尼妹 獲取了廁所的鎖
王尼妹 上完了廁所
貓爺 獲取了廁所的鎖
貓爺 上完了廁所
狗哥 獲取了廁所的鎖
狗哥 上完了廁所
複製程式碼

看起來沒有神馬問題,但是再回頭看看程式碼,發現有兩處特別彆扭的地方:

  1. 在main執行緒開啟REPAIR-THREAD執行緒後,必須呼叫sleep方法等待一段時間才允許上廁所執行緒開啟。

    如果REPAIR-THREAD執行緒和其他上廁所執行緒一塊兒開啟的話,就有可能上廁所的人,比如狗哥先獲取到廁所的鎖,然後維修工壓根兒連廁所也進不去。但是真實情況可能真的這樣的,狗哥先到了廁所,然後維修工才到。不過狗哥的處理應該不是一直待在廁所裡,而是先出來等著,啥時候維修工說修好了他再進去。所以這點有些彆扭~

  2. 在一個上廁所的人獲取到廁所的鎖的時候,必須不斷判斷WashroomisAvailable欄位是否為true

    如果一個人進入到廁所發現廁所仍然處在不可用狀態的話,那它應該在某個地方休息,啥時候維修工把廁所修好了,再叫一下等著上廁所的人就好了嘛,沒必要自己不停的去檢查廁所是否被修好了。

總結一下,就是一個執行緒在獲取到鎖之後,如果指定條件不滿足的話,應該主動讓出鎖,然後到專門的等待區等待,直到某個執行緒完成了指定的條件,再通知一下在等待這個條件完成的執行緒,讓它們繼續執行

如果你覺得上邊這句話比較繞的話,我來給你翻譯一下: 當上狗哥獲取到廁所門鎖之後,如果廁所處於不可用狀態,那就主動讓出鎖,然後到等待上廁所的隊伍裡排隊`等待`,直到維修工把廁所修理好,把廁所的狀態置為可用後,維修工再通知需要上廁所的人,然他們正常上廁所。

具體使用方式

為了實現這個構想,java裡提出了一套叫wait/notify的機制。當一個執行緒獲取到鎖之後,如果發現條件不滿足,那就主動讓出鎖,然後把這個執行緒放到一個等待佇列等待去,等到某個執行緒把這個條件完成後,就通知等待佇列裡的執行緒他們等待的條件滿足了,可以繼續執行啦!

如果不同執行緒有不同的等待條件腫麼辦,總不能都塞到同一個等待佇列裡吧?是的,java裡規定了每一個鎖都對應了一個等待佇列,也就是說如果一個執行緒在獲取到鎖之後發現某個條件不滿足,就主動讓出鎖然後把這個執行緒放到與它獲取到的鎖對應的那個等待佇列裡,另一個執行緒在完成對應條件時需要獲取同一個鎖,在條件完成後通知它獲取的鎖對應的等待佇列。這個過程意味著鎖和等待佇列建立了一對一關聯

怎麼讓出鎖並且把執行緒放到與鎖關聯的等待佇列中以及怎麼通知等待佇列中的執行緒相關條件已經完成java已經為我們規定好了。我們知道,其實就是個物件而已,在所有物件的老祖宗類Object中定義了這麼幾個方法:

public final void wait() throws InterruptedException
public final void wait(long timeout) throws InterruptedException
public final void wait(long timeout, int nanos) throws InterruptedException

public final void notify();
public final void notifyAll();
複製程式碼
方法名 說明
wait() 線上程獲取到鎖後,呼叫鎖物件的本方法,執行緒釋放鎖並且把該執行緒放置到與鎖物件關聯的等待佇列
wait(long timeout) wait()方法相似,只不過等待指定的毫秒數,如果超過指定時間則自動把該執行緒從等待佇列中移出
wait(long timeout, int nanos) 與上邊的一樣,只不過超時時間粒度更小,即指定的毫秒數迦納秒數
notify() 通知一個在與該鎖物件關聯的等待佇列的執行緒,使它從wait()方法中返回繼續往下執行
notifyAll() 與上邊的類似,只不過通知該等待佇列中的所有執行緒

瞭解了這些方法的意思以後我們再來改寫一下ShitTask

public class ShitTask implements Runnable {

    // ... 為節省篇幅,省略相關欄位和構造方法

    @Override
    public void run() {
        synchronized (washroom.getLock()) {
            System.out.println(name + " 獲取了廁所的鎖");
            while (!washroom.isAvailable()) {
                try {
                    washroom.getLock().wait();  //呼叫鎖物件的wait()方法,讓出鎖,並把當前執行緒放到與鎖關聯的等待佇列
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println(name + " 上完了廁所");
        }
    }
}
複製程式碼

看,原來我們在判斷廁所是否可用的死迴圈里加了這麼一段程式碼:

washroom.getLock().wait(); 
複製程式碼

這段程式碼的意思就是讓出廁所的鎖,並且把當前執行緒放到與廁所的鎖相關聯的等待佇列裡。

然後我們也需要修改一下維修任務:

public class RepairTask implements Runnable {

    // ... 為節省篇幅,省略相關欄位和構造方法

    @Override
    public void run() {

        synchronized (washroom.getLock()) {
            System.out.println("維修工 獲取了廁所的鎖");
            System.out.println("廁所維修中,維修廁所是一件辛苦活,需要很長時間。。。");

            try {
                Thread.sleep(5000L);    //用執行緒sleep表示維修的過程
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            washroom.setAvailable(true);    //維修結束把廁所置為可用狀態
            
            washroom.getLock().notifyAll(); //通知所有在與鎖物件關聯的等待佇列裡的執行緒,它們可以繼續執行了
            System.out.println("維修工把廁所修好了,準備釋放鎖了");
        }
    }
}
複製程式碼

大家可以看出來,我們在維修結束後加了這麼一行程式碼:

washroom.getLock().notifyAll();
複製程式碼

這個程式碼表示將通知所有在與鎖物件關聯的等待佇列裡的執行緒,它們可以繼續執行了。

在使用java的wait/notify機制修改了ShitTaskRepairTask後,我們在復原一下整個現實場景:

public class Test {
    public static void main(String[] args) {
        Washroom washroom = new Washroom();
        new Thread(new ShitTask(washroom, "狗哥"), "BROTHER-DOG-THREAD").start();
        new Thread(new ShitTask(washroom, "貓爺"), "GRANDPA-CAT-THREAD").start();
        new Thread(new ShitTask(washroom, "王尼妹"), "WANG-NI-MEI-THREAD").start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        
        new Thread(new RepairTask(washroom), "REPAIR-THREAD").start();
    }
}
複製程式碼

在這個場景中,我們可以刻意讓著急上廁所的先到達了廁所,維修工最後抵達廁所,來看一下加了wait/notify機制的程式碼的執行結果是:

狗哥 獲取了廁所的鎖
貓爺 獲取了廁所的鎖
王尼妹 獲取了廁所的鎖
維修工 獲取了廁所的鎖
廁所維修中,維修廁所是一件辛苦活,需要很長時間。。。
維修工把廁所修好了,準備釋放鎖了
王尼妹 上完了廁所
貓爺 上完了廁所
狗哥 上完了廁所
複製程式碼

從執行結果可以看出來,狗哥、貓爺、王尼妹雖然先到達了廁所並且獲取到鎖,但是由於廁所處於不可用狀態,所以都先呼叫wait()方法讓出了自己獲得的鎖,然後躲到與這個鎖關聯的等待佇列裡,直到維修工修完了廁所,通知了在等待佇列中的狗哥、貓爺、王尼妹,他們才又開始繼續執行上廁所的程式~

通用模式

經過上邊的廁所案例,大家應該對wait/notify機制有了大致瞭解,下邊我們總結一下這個機制的通用模式。首先看一下等待執行緒的通用模式:

  1. 獲取物件鎖。
  2. 如果某個條件不滿足的話,呼叫鎖物件的wait方法,被通知後仍要檢查條件是否滿足。
  3. 條件滿足則繼續執行程式碼。

通用的程式碼如下:

synchronized (物件) {
    處理邏輯(可選)
    while(條件不滿足) {
        物件.wait();
    }
    處理邏輯(可選)
}
複製程式碼

除了判斷條件是否滿足和呼叫wait方法以外的程式碼,其他的處理邏輯是可選的。

下邊再來看通知執行緒的通用模式:

  1. 獲得物件的鎖。
  2. 完成條件。
  3. 通知在等待佇列中的等待執行緒。
synchronized (物件) {
    完成條件
    物件.notifyAll();、
}
複製程式碼

小貼士: 別忘了同步方法也是使用鎖的喔,靜態同步方法的鎖物件是該類的`Class物件`,成員同步方法的鎖物件是`this物件`。所以如果沒有刻意強調,下邊所說的同步程式碼塊也包含同步方法。

瞭解了wait/notify的通用模式之後,使用的時候需要特別小心,需要注意下邊這些方面:

  • 必須在同步程式碼塊中呼叫waitnotify或者notifyAll方法

    有的童鞋會有疑問,為啥wait/notify機制的這些方法必須都放在同步程式碼塊中才能呼叫呢?wait方法的意思只是讓當前執行緒停止執行,把當前執行緒放在等待佇列裡,notify方法的意思只是從等待佇列裡移除一個執行緒而已,跟加鎖有什麼關係?

    答:因為wait方法是執行在等待執行緒裡的,notify或者notifyAll是執行在通知執行緒裡的。而執行wait方法前需要判斷一下某個條件是否滿足,如果不滿足才會執行wait方法,這是一個先檢查後執行的操作,不是一個原子性操作,所以如果不加鎖的話,在多執行緒環境下等待執行緒和通知執行緒的執行順序可能是這樣的:

    image_1c1ufvde41bas7g2184e1ma4peg16.png-40.3kB

    也就是說當等待執行緒已經判斷條件不滿足,正要執行wait方法,此時通知執行緒搶先把條件完成並且呼叫了notify方法,之後等待執行緒才執行到wait方法,這會導致等待執行緒永遠停留在等待佇列而沒有人再去notify它。所以等待執行緒中的判斷條件是否滿足、呼叫wait方法和通知執行緒中完成條件、呼叫notify方法都應該是原子性操作,彼此之間是互斥的,所以用同一個鎖來對這兩個原子性操作進行同步,從而避免出現等待執行緒永久等待的尷尬局面。

    如果不在同步程式碼塊中呼叫waitnotify或者notifyAll方法,也就是說沒有獲取鎖就呼叫wait方法,就像這樣:

    物件.wait();
    複製程式碼

    是會丟擲IllegalMonitorStateException異常的。

  • 在同步程式碼塊中,必須呼叫獲取的鎖物件的waitnotify或者notifyAll方法

    也就是說不能隨便呼叫一個物件的waitnotify或者notifyAll方法。比如等待執行緒中的程式碼是這樣的:

    synchronized (物件1) {
        while(條件不滿足) {
            物件2.wait();    //隨便呼叫一個物件的wait方法
        }
    }
    複製程式碼

    通知執行緒中的程式碼是這樣的:

    synchronized (物件1) {
        完成條件
        物件2.notifyAll();
    }
    複製程式碼

    對於程式碼物件2.wait(),表示讓出當前執行緒持有的物件2的鎖,而當前執行緒持有的是物件1的鎖,所以這麼寫是錯誤的,也會丟擲IllegalMonitorStateException異常的。意思就是如果當前執行緒不持有某個物件的鎖,那它就不能呼叫該物件的wait方法來讓出該鎖。所以如果想讓等待執行緒讓出當前持有的鎖,只能呼叫物件1.wait()。然後這個執行緒就被放置到與物件1相關聯的等待佇列中,在通知執行緒中只能呼叫物件1.notifyAll()來通知這些等待的執行緒了。

  • 在等待執行緒判斷條件是否滿足時,應該使用while,而不是if

    也就是說在判斷條件是否滿足的時候要使用while

    while(條件不滿足) { //正確✅
        物件.wait();
    }
    複製程式碼

    而不是使用if

    if(條件不滿足) { //錯誤❌
        物件.wait();
    }
    複製程式碼

    這個是因為在多執行緒條件下,可能在一個執行緒呼叫notify之後立即又有一個執行緒把條件改成了不滿足的狀態,比如在維修工把廁所修好之後通知大家上廁所吧的瞬間,有一個小屁孩以迅雷不及掩耳之勢又給廁所坑裡塞了個瓶子,廁所又被置為不可用狀態,等待上廁所的還是需要再判斷一下條件是否滿足才能繼續執行。

  • 在呼叫完鎖物件的notify或者notifyAll方法後,等待執行緒並不會立即從wait()方法返回,需要呼叫notify()或者notifyAll()的執行緒釋放鎖之後,等待執行緒才從wait()返回繼續執行。

    也就是說如果通知執行緒在呼叫完鎖物件的notify或者notifyAll方法後還有需要執行的程式碼,就像這樣:

    synchronized (物件) {
        完成條件
        物件.notifyAll();
        ... 通知後的處理邏輯
    }
    複製程式碼

    需要把通知後的處理邏輯執行完成後,把鎖釋放掉,其他執行緒才可以從wait狀態恢復過來,重新競爭鎖來執行程式碼。比方說在維修工修好廁所並通知了等待上廁所的人們之後,他還沒有從廁所出來,而是在廁所的牆上寫了 "XXX到此一遊"之類的話之後才從廁所出來,從廁所出來才代表著釋放了鎖,狗哥、貓爺、王尼妹才開始爭搶進入廁所的機會。

  • notify方法只會將等待佇列中的一個執行緒移出,而notifyAll方法會將等待佇列中的所有執行緒移出

    大家可以把上邊程式碼中的notifyAll方法替換稱notify方法,看看執行結果~

waitsleep的區別

眼尖的小夥伴肯定發現,waitsleep這兩個方法都可以讓執行緒暫停執行,而且都有InterruptedException的異常說明,那麼它們的區別是啥呢?

  • waitObject的成員方法,而sleepThread的靜態方法

    只要是作為鎖的物件都可以在同步程式碼塊中呼叫自己的wait方法,sleepThread的靜態方法,表示的是讓當前執行緒休眠指定的時間。

  • 呼叫wait方法需要先獲得鎖,而呼叫sleep方法是不需要的

    在一次強調,一定要在同步程式碼塊中呼叫鎖物件的wait方法,前提是要獲得鎖!前提是要獲得鎖!前提是要獲得鎖!而sleep方法隨時呼叫~

  • 呼叫wait方法的執行緒需要用notify來喚醒,而sleep必須設定超時值

  • 執行緒在呼叫wait方法之後會先釋放鎖,而sleep不會釋放鎖

    這一點可能是最重要的一點不同點了吧,狗哥、貓爺、王尼妹這些執行緒一開始是獲取到廁所的鎖了,但是呼叫了wait方法之後主動把鎖讓出,從而讓維修工得以進入廁所維修。如果狗哥在發現廁所是不可用的條件時選擇呼叫sleep方法的話,執行緒是不會釋放鎖的,也就是說維修工無法獲得廁所的鎖,也就修不了廁所了~ 大家一定要謹記這一點啊!

總結

  1. 執行緒間需要通過通訊才能協作解決某個複雜的問題。

  2. 可變共享變數是天然的通訊媒介,但是使用的時候一定要保證執行緒安全性,通常使用volatile變數或synchronized來保證執行緒安全性。

  3. 一個執行緒在獲取到鎖之後,如果指定條件不滿足的話,應該主動讓出鎖,然後到專門的等待區等待,直到某個執行緒完成了指定的條件,再通知一下在等待這個條件完成的執行緒,讓它們繼續執行。這個機制就是wait/notify機制。

  4. 等待執行緒的通用模式:

    synchronized (物件) {
        處理邏輯(可選)
        while(條件不滿足) {
            物件.wait();
        }
        處理邏輯(可選)
    }
    複製程式碼

    可以分為下邊幾個步驟:

    • 獲取物件鎖。
    • 如果某個條件不滿足的話,呼叫鎖物件的wait方法,被通知後仍要檢查條件是否滿足。
    • 條件滿足則繼續執行程式碼。
  5. 通知執行緒的通用模式:

    synchronized (物件) {
        完成條件
        物件.notifyAll();、
    }
    複製程式碼

    可以分為下邊幾個步驟:

    • 獲得物件的鎖。
    • 完成條件。
    • 通知在等待佇列中的等待執行緒。
  6. waitsleep的區別

    • wait是Object的成員方法,而sleep是Thread的靜態方法。

    • 呼叫wait方法需要先獲得鎖,而呼叫sleep方法是不需要的。

    • 呼叫wait方法的執行緒需要用notify來喚醒,而sleep必須設定超時值。

    • 執行緒在呼叫wait方法之後會先釋放鎖,而sleep不會釋放鎖。

題外話

寫文章挺累的,有時候你覺得閱讀挺流暢的,那其實是背後無數次修改的結果。如果你覺得不錯請幫忙轉發一下,萬分感謝~ 這裡是我的公眾號,裡邊有更多技術乾貨,時不時扯一下犢子,歡迎關注:

java併發程式設計系列:wait/notify機制

小冊

另外,作者還寫了一本MySQL小冊:《MySQL是怎樣執行的:從根兒上理解MySQL》的連結 。小冊的內容主要是從小白的角度出發,用比較通俗的語言講解關於MySQL進階的一些核心概念,比如記錄、索引、頁面、表空間、查詢優化、事務和鎖等,總共的字數大約是三四十萬字,配有上百幅原創插圖。主要是想降低普通程式設計師學習MySQL進階的難度,讓學習曲線更平滑一點~ 有在MySQL進階方面有疑惑的同學可以看一下:

java併發程式設計系列:wait/notify機制

相關文章