首頁 > 軟體

分析java並行中的wait notify notifyAll

2021-06-11 16:01:13

一、前言

java 面試是否有被問到過,sleepwait 方法的區別,關於這個問題其實不用多說,大多數人都能回答出最主要的兩點區別:

  • sleep 是執行緒的方法, wait / notify / notifyAll 是 Object 類的方法;
  • sleep 不會釋放當前執行緒持有的鎖,到時間後程式會繼續執行,wait 會釋放執行緒持有的鎖並掛起,直到通過 notify 或者 notifyAll 重新獲得鎖。

另外還有一些引數、異常等區別,不細說了。本文重點記錄一下 wait / notify / notifyAll 的相關知識。

二、常見的同步場景

開發中常常遇到這樣的場景:

一個執行緒執行過程中,需要開啟另外一個子執行緒去做某個耗時的操作(通過休眠3秒模擬),並且**等待**子執行緒返回結果,主執行緒再根據返回的結果繼續往下執行。

這裡注意我上面加*兩個字「等待」。如果不需要等待,單純只是對子執行緒的結果做處理,我們大可註冊回撥方法解決問題,此文不再贅述介面回撥。

此處場景就是主執行緒停下來等待子執行緒執行完畢後,主執行緒再繼續執行。針對該場景下面給出實現:

2.1、設定一個判斷的標誌位

volatile boolean flag = false;

public void test(){
    //...

    Thread t1 = new Thread(() -> {
        try {
            Thread.sleep(3000);
            System.out.println("--- 休眠 3 秒");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            flag = true;
        }
    });
    t1.start();

    while(!flag){

    }
    System.out.println("--- work thread run");
}

上面的程式碼,執行結果:

強調一點,宣告標誌位的時候,一定注意 volatile 關鍵字不能忘,如果不加該關鍵字修飾,程式可能進入死迴圈。這是同步中的可見性問題,在 《java 並行——內建鎖》 中有記錄。

顯然,這個實現方案並不好,本來主執行緒什麼也不用做,卻一直在競爭資源,做空迴圈,效能上不好,所以並不推薦。

2.2、執行緒的 join 方法

public void test(){
    //...

    Thread t1 = new Thread(() -> {
        try {
            Thread.sleep(3000);
            System.out.println("--- 休眠 3 秒");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });

    t1.start();

    try {
        t1.join();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("--- work thread continue");
}

上面的程式碼,執行結果同上。利用 Thread 類的 join 方法實現了同步,達到了效果,但是 join 方法不能一定保證效果,在不同的 cpu 上,可能呈現出意想不到的結果,所以儘量不要用上述方法。

2.3、使用閉鎖 CountDownLatch

不清楚閉鎖的新同學可以瞭解下java並行中的執行緒。

public void test(){
    //...

    final CountDownLatch countDownLatch = new CountDownLatch(1);

    Thread t1 = new Thread(() -> {
        try {
            Thread.sleep(3000);
            System.out.println("--- 休眠 3 秒");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            countDownLatch.countDown();
        }
    });

    t1.start();

    try {
        countDownLatch.await();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("--- work thread run");
}

上面的程式碼,執行結果同上。同樣可以實現上述效果,執行結果和上面一樣。該方法推薦使用。

2.4、利用 wait / notify 優化標誌位方法

為了方便對比,首先給 2.1 中的迴圈方法增加一些列印。修改後的程式碼如下:

volatile boolean flag = false;

public void test() {
    //...
    Thread t1 = new Thread(() -> {
        try {
            Thread.sleep(3000);
            System.out.println("--- 休眠 3 秒");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            flag = true;
        }
    });
    t1.start();

    while (!flag) {
        try {
            System.out.println("---while-loop---");
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    System.out.println("--- work thread run");
}

執行結果如下:

事實證明,while 迴圈確實一直在執行。

為了使該執行緒再不需要執行的時候不搶佔資源,我們可以利用 wait 方法將其掛起,在需要它執行的時候,再利用 notify 方法將其喚醒。這樣達到優化的目的,優化後的程式碼如下:

volatile boolean flag = false;

public void test() {
    //...
    final Object obj = new Object();
    Thread t1 = new Thread(() -> {
        synchronized (obj) {
            try {
                Thread.sleep(3000);
                System.out.println("--- 休眠 3 秒");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                flag = true;
            }
            obj.notify();
        }
    });
    t1.start();

    synchronized (obj) {
        while (!flag) {
            try {
                System.out.println("---while-loop---");
                Thread.sleep(500);
                obj.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    System.out.println("--- work thread run");

}

執行結果:

結果證明,優化後的程式,迴圈只執行了一次。

三、理解 wait / notify / notifyAll

在Java中,每個物件都有兩個池,鎖(monitor)池和等待池

3.1、鎖池

鎖池:假設執行緒A已經擁有了某個物件的鎖,而其它的執行緒想要呼叫這個物件的某個synchronized方法(或者synchronized塊),由於這些執行緒在進入物件的synchronized方法之前必須先獲得該物件的鎖的擁有權,但是該物件的鎖目前正被執行緒A擁有,所以這些執行緒就進入了該物件的鎖池中。

3.2、等待池

等待池:假設一個執行緒A呼叫了某個物件的wait()方法,執行緒A就會釋放該物件的鎖(因為wait()方法必須出現在synchronized中,這樣自然在執行wait()方法之前執行緒A就已經擁有了該物件的鎖),同時執行緒A就進入到了該物件的等待池中。如果另外的一個執行緒呼叫了相同物件的notifyAll()方法,那麼處於該物件的等待池中的執行緒就會全部進入該物件的鎖池中,準備爭奪鎖的擁有權。如果另外的一個執行緒呼叫了相同物件的notify()方法,那麼僅僅有一個處於該物件的等待池中的執行緒(隨機)會進入該物件的鎖池.

3.3、notify 和 notifyAll 的區別

3.3.1、wait()

public final void wait() throws InterruptedException,IllegalMonitorStateException
該方法用來將當前執行緒置入休眠狀態,直到接到通知或被中斷為止。在呼叫 wait()之前,執行緒必須要獲得該物件的物件級別鎖,即只能在同步方法或同步塊中呼叫 wait()方法。進入 wait()方法後,當前執行緒釋放鎖。在從 wait()返回前,執行緒與其他執行緒競爭重新獲得鎖。如果呼叫 wait()時,沒有持有適當的鎖,則丟擲 IllegalMonitorStateException,它是 RuntimeException 的一個子類,因此,不需要 try-catch 結

3.3.2、notify()

public final native void notify() throws IllegalMonitorStateException
該方法也要在同步方法或同步塊中呼叫,即在呼叫前,執行緒也必須要獲得該物件的物件級別鎖,的如果呼叫 notify()時沒有持有適當的鎖,也會丟擲 IllegalMonitorStateException。
該方法用來通知那些可能等待該物件的物件鎖的其他執行緒。如果有多個執行緒等待,則執行緒規劃器任意挑選出其中一個 wait()狀態的執行緒來發出通知,並使它等待獲取該物件的物件鎖(notify 後,當前執行緒不會馬上釋放該物件鎖,wait 所在的執行緒並不能馬上獲取該物件鎖,要等到程式退出 synchronized 程式碼塊後,當前執行緒才會釋放鎖,wait所在的執行緒也才可以獲取該物件鎖),但不驚動其他同樣在等待被該物件notify的執行緒們。當第一個獲得了該物件鎖的 wait 執行緒執行完畢以後,它會釋放掉該物件鎖,此時如果該物件沒有再次使用 notify 語句,則即便該物件已經空閒,其他 wait 狀態等待的執行緒由於沒有得到該物件的通知,會繼續阻塞在 wait 狀態,直到這個物件發出一個 notify 或 notifyAll。這裡需要注意:它們等待的是被 notify 或 notifyAll,而不是鎖。這與下面的 notifyAll()方法執行後的情況不同。

3.3.3、notifyAll()

public final native void notifyAll() throws IllegalMonitorStateException

該方法與 notify ()方法的工作方式相同,重要的一點差異是:

notifyAll 使所有原來在該物件上 wait 的執行緒統統退出 wait 的狀態(即全部被喚醒,不再等待 notify 或 notifyAll,但由於此時還沒有獲取到該物件鎖,因此還不能繼續往下執行),變成等待獲取該物件上的鎖,一旦該物件鎖被釋放(notifyAll 執行緒退出呼叫了 notifyAll 的 synchronized 程式碼塊的時候),他們就會去競爭。如果其中一個執行緒獲得了該物件鎖,它就會繼續往下執行,在它退出 synchronized 程式碼塊,釋放鎖後,其他的已經被喚醒的執行緒將會繼續競爭獲取該鎖,一直進行下去,直到所有被喚醒的執行緒都執行完畢。

四、生產者與消費者模式

生產者與消費者問題是並行程式設計裡面的經典問題。接下來說說利用wait()和notify()來實現生產者和消費者並行問題:
顯然要保證生產者和消費者並行執行不出亂,主要要解決:當生產者執行緒的快取區為滿的時候,就應該呼叫wait()來停止生產者繼續生產,而當生產者滿的緩衝區被消費者消費掉一塊時,則應該呼叫notify()喚醒生產者,通知他可以繼續生產;同樣,對於消費者,當消費者執行緒的快取區為空的時候,就應該呼叫wait()停掉消費者執行緒繼續消費,而當生產者又生產了一個時就應該呼叫notify()來喚醒消費者執行緒通知他可以繼續消費了。

下面是一個簡單的程式碼實現:

package com.sharpcj;

import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Test {
    public static void main(String[] args) {
        Reposity reposity = new Reposity(600);
        ExecutorService threadPool = Executors.newCachedThreadPool();
        for(int i = 0; i < 10; i++){
            threadPool.submit(new Producer(reposity));
        }

        for(int i = 0; i < 10; i++){
            threadPool.submit(new Consumer(reposity));
        }
        threadPool.shutdown();
    }
}


class Reposity {
    private static final int MAX_NUM = 2000;
    private int currentNum;

    private final Object obj = new Object();

    public Reposity(int currentNum) {
        this.currentNum = currentNum;
    }

    public void in(int inNum) {
        synchronized (obj) {
            while (currentNum + inNum > MAX_NUM) {
                try {
                    System.out.println("入貨量 " + inNum + " 執行緒 " + Thread.currentThread().getId() + "被掛起...");
                    obj.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            currentNum += inNum;
            System.out.println("執行緒: " + Thread.currentThread().getId() + ",入貨:inNum = [" + inNum + "], currentNum = [" + currentNum + "]");
            obj.notifyAll();
        }
    }

    public void out(int outNum) {
        synchronized (obj) {
            while (currentNum < outNum) {
                try {
                    System.out.println("出貨量 " + outNum + " 執行緒 " + Thread.currentThread().getId() + "被掛起...");
                    obj.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            currentNum -= outNum;
            System.out.println("執行緒: " + Thread.currentThread().getId() + ",出貨:outNum = [" + outNum + "], currentNum = [" + currentNum + "]");
            obj.notifyAll();
        }
    }
}

class Producer implements Runnable {
    private Reposity reposity;

    public Producer(Reposity reposity) {
        this.reposity = reposity;
    }

    @Override
    public void run() {
        reposity.in(200);
    }
}

class Consumer implements Runnable {
    private Reposity reposity;

    public Consumer(Reposity reposity) {
        this.reposity = reposity;
    }

    @Override
    public void run() {
        reposity.out(200);
    }
}

執行結果:

五、總結

1.呼叫wait方法和notify、notifyAll方法前必須獲得物件鎖,也就是必須寫在synchronized(鎖物件){......}程式碼塊中。

2.當執行緒呼叫了wait方法後就釋放了物件鎖,否則其他執行緒無法獲得物件鎖。

3.當呼叫 wait() 方法後,執行緒必須再次獲得物件鎖後才能繼續執行。

4.如果另外兩個執行緒都在 wait,則正在執行的執行緒呼叫notify方法只能喚醒一個正在wait的執行緒(公平競爭,由JVM決定)。

5.當使用notifyAll方法後,所有wait狀態的執行緒都會被喚醒,但是隻有一個執行緒能獲得鎖物件,必須執行完while(condition){this.wait();}後才釋放物件鎖。其餘的需要等待該獲得物件鎖的執行緒執行完釋放物件鎖後才能繼續執行。

6.當某個執行緒呼叫notifyAll方法後,雖然其他執行緒被喚醒了,但是該執行緒依然持有著物件鎖,必須等該同步程式碼塊執行完(右大括號結束)後才算正式釋放了鎖物件,另外兩個執行緒才有機會執行。

7.第5點中說明, wait 方法的呼叫前的條件判斷需放在迴圈中,否則可能出現邏輯錯誤。另外,根據程式邏輯合理使用 wait 即 notify 方法,避免如先執行 notify ,後執行 wait 方法,執行緒一直掛起之類的錯誤。

以上就是分析java並行中的wait notify notifyAll的詳細內容,更多關於java並行wait notify notifyAll的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com