等待喚醒(wait / notify)機制


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

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方法,這是一個先檢查后執行的操作,不是一個原子性操作,所以如果不加鎖的話,在多線程環境下等待線程和通知線程的執行順序可能是這樣的:

    img

    也就是說當等待線程已經判斷條件不滿足,正要執行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方法,看看執行結果~

wait和sleep的區別

眼尖的小伙伴肯定發現,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方法,被通知后仍要檢查條件是否滿足。
  • 條件滿足則繼續執行代碼。
  1. 通知線程的通用模式:

    synchronized (對象) {
        完成條件
        對象.notifyAll();、
    }
    

    可以分為下邊幾個步驟:

    • 獲得對象的鎖。

    • 完成條件。

    • 通知在等待隊列中的等待線程。

  2. waitsleep的區別

    • wait是Object的成員方法,而sleep是Thread的靜態方法。
    • 調用wait方法需要先獲得鎖,而調用sleep方法是不需要的。
    • 調用wait方法的線程需要用notify來喚醒,而sleep必須設置超時值。
    • 線程在調用wait方法之后會先釋放鎖,而sleep不會釋放鎖。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM