超強圖文|併發編程【等待/通知機制】就是這個feel~

  • 你有一個思想,我有一個思想,咱們交換後,一我的就有兩個思想
  • If you can NOT explain it simply, you do NOT understand it well enough

併發編程爲何會有等待通知機制

上一篇文章說明了 Java併發死鎖解決思路 , 解決死鎖的思路之一就是 破壞請求和保持條件, 全部櫃員都要經過惟一的帳本管理員一次性拿到全部轉帳業務須要的帳本,就像下面這樣: html

沒有等待/通知機制以前,全部櫃員都經過死循環的方式不斷向帳本管理員申請全部帳本,程序的體現就是這樣:java

while(!accountBookManager.getAllRequiredAccountBook(this, target));

假如帳本管理員是年輕小夥,腿腳利落(即執行 getAllRequiredAccountBook方法耗時短),而且多個櫃員轉帳的業務衝突量不大,這個方案簡單粗暴且有效,櫃員只須要嘗試幾回就能夠成功(即經過少許的循環能夠實現)面試

過了好多年,年輕的帳本管理員變成了年邁的老人,行動遲緩(即執行 getAllRequiredAccountBook 耗時長),同時,多個櫃員轉帳的業務衝突量也變大,以前幾十次循環能作到的,如今可能就要申請成千上百,甚至上萬次才能完成一次轉帳編程

人工無限申請浪費口舌, 程序無限申請浪費CPU。聰明的人就想到了 等待/通知 機制併發

等待/通知機制

無限循環實在太浪費CPU,而理想狀況應該是這樣:函數

  • 櫃員A若是拿不到全部帳本,就傲嬌的再也不繼續問了(線程阻塞本身 wait)
  • 櫃員B歸還了櫃員A須要的帳本以後就主動通知櫃員A帳本可用(通知等待的線程 notify/notifyAll)

作到這樣,就能避免循環等待消耗CPU的問題了測試


現實中有太多場景都在應用等待/通知機制。歡迎觀臨紅浪漫,好比去XX辦證,去醫院就醫/體檢。優化

下面請自行腦補一下去醫院就醫或體檢的畫面, 總體流程相似這樣:ui

序號 就醫 程序解釋(本身的視角)
1 掛號成功,到診室門口排號候診 排號的患者(線程)嘗試獲取【互斥鎖】
2 大夫叫到本身,進入診室就診 本身【獲取到互斥鎖】
3 大夫簡單詢問,要求作檢查(患者缺少報告不能診斷病因) 進行【條件判斷】,線程要求的條件【沒知足】
4 本身出去作檢查 線程【主動釋放】持有的互斥鎖
5 大夫叫下一位患者 另外一位患者(線程)獲取到互斥鎖
6 本身拿到檢測報告 線程【曾經】要求的條件獲得知足(實則【被通知】)
7 再次在診室門口排號候診 再次嘗試獲取互斥鎖
8 ... ...

在【程序解釋】一列,我將關鍵字(排隊、鎖、等待、釋放....)已經用 【】 框了起來。Java 語言中,其內置的關鍵字 synchronized 和 方法wait(),notify()/notifyAll() 就能實現上面提到的等待/通知機制,咱們將這幾個關鍵字實現流程現形象化的表示一下:this

等待隊列圖

這可不是一個簡單的圖,下面還要圍繞這個圖作不少文章,不過這裏我必需要插播幾個面試基礎知識點了:

  1. 一個鎖對應一個【入口等待隊列】,不一樣鎖的入口等待隊列沒任何關係,說白了他們就不存在競爭關係。你想呀,不一樣患者進入眼科和耳鼻喉科看大夫一點衝突都沒有
  2. wait(), notify()/notifyAll() 要在 synchronized 內部被使用,而且,若是鎖的對象是this,就要 this.wait(),this.notify()/this.notifyAll() , 不然JVM就會拋出 java.lang.IllegalMonitorStateException 的。你想呀,等待/通知機制就是從【競爭】環境逐漸衍生出來的策略,不在鎖競爭內部使用或等待/通知錯了對象, 天然是不符合常理的

有了上面知識的鋪墊,要想將無限循環策略改成等待通知策略,你還須要問本身四個問題:

靈魂 4 問

咱們拿錢莊帳本管理員的例子依依作以上回答:

咱們優化錢莊轉帳的程序:

public class AccountBookManager {

    List<Object> accounts = new ArrayList<>(2);

    synchronized boolean getAllRequiredAccountBook( Object from, Object to){
        if(accounts.contains(from) || accounts.contains(to)){
            try{
        this.wait();
      }catch(Exception e){
        
      }
        } else{
            accounts.add(from);
            accounts.add(to);

            return true;
        }
    }
    // 歸還資源
    synchronized void releaseObtainedAccountBook(Object from, Object to){
        accounts.remove(from);
        accounts.remove(to);
    notify();
    }
}

就這樣【看】 【似】 【完】 【美】的解決了,其實上面的程序有兩個大坑:

坑一

在上面 this.wait() 處,使用了 if 條件判斷,會出現天大的麻煩,來看下圖(從下往上看):

notify 喚醒的那一刻,線程【曾經/曾經/曾經】要求的條件獲得了知足,從這一刻開始,到去條件等隊列中喚醒線程,再到再次嘗試獲取鎖是有時間差的,當再次獲取到鎖時,線程曾經要求的條件是不必定知足,因此須要從新進行條件判斷,因此須要將 if 判斷改爲 while 判斷

synchronized boolean getAllRequiredAccountBook( Object from, Object to){
        while(accounts.contains(from) || accounts.contains(to)){
            try{
        this.wait();
      }catch(Exception e){
        
      }
        } else{
            accounts.add(from);
            accounts.add(to);

            return true;
        }
}
一個線程能夠從掛起狀態變爲可運行狀態(也就是被喚醒),即便線程沒有被其餘線程調用 notify()/notifyAll() 方法進行通知,或被中斷,或者等待超時,這就是所謂的【 虛假喚醒】。雖然虛假喚醒不多發生,但要防患於未然, 作法就是不停的去測試該線程被喚醒條件是否知足

——摘自《Java併發編程之美》


有同窗可能還會產生疑問,爲何while就能夠?

由於被喚醒的線程再次獲取到鎖以後是從原來的 wait 以後開始執行的,wait在循環裏面,因此會再次進入循環條件從新進行條件判斷。

若是不理解這個道理就記住一句話:

從哪裏跌倒就從哪裏爬起來;在哪裏wait,就從wait那裏繼續向後執行

因此,這也就成了使用wait()的標準範式

至於坑二,是線程歸還所使用的帳戶以後使用 notify 而不是 notifyAll 進行通知,因爲坑很大,須要一些知識鋪墊來講明

爲何說盡可能使用 notifyAll

notify() 和 notifyAll() 到底啥區別?

notify() 函數

隨機喚醒一個:一個線程調用共享對象的 notify() 方法,會喚醒 一個在該共享變量上調用 wait() 方法後被掛起的線程,一個共享變量上可能有多個線程在等待,具體喚醒那一個,是 隨機的

notifyAll() 函數

喚醒全部: 與notify() 不一樣,notifyAll() 會喚醒在該共享變量上因爲調用wait() 方法而被掛起的 全部線程

看個很是簡單的程序例子吧

示例程序一

@Slf4j
public class NotifyTest {

    private static volatile Object resourceA = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(() -> {
                synchronized (resourceA){
                    log.info("threadA get resourceA lock");

                    try{
                        log.info("threadA begins to wait");
                        resourceA.wait();
                        log.info("threadA ends wait");
                    }catch (InterruptedException e){
                        log.error(e.getMessage());
                    }
                }
        });

        Thread threadB = new Thread(() -> {
            synchronized (resourceA){
                log.info("threadB get resourceA lock");

                try{
                    log.info("threadB begins to wait");
                    resourceA.wait();
                    log.info("threadB ends wait");
                }catch (InterruptedException e){
                    log.error(e.getMessage());
                }
            }
        });

        Thread threadC = new Thread(() -> {
            synchronized (resourceA){
                log.info("threadC begin to notify");
                resourceA.notify();
            }
        });

        threadA.start();
        threadB.start();

        Thread.sleep(1000);

        threadC.start();

        threadA.join();
        threadB.join();
        threadC.join();

        log.info("main thread over now");
    }
}

來看運行結果

程序中咱們使用notify()隨機通知resourceA的等待隊列的一個線程,threadA被喚醒,threadB卻沒有打印出 threadB ends wait 這句話,遺憾的死掉了

將 notify() 換成 notifyAll() 的結果想必你已經知道了

使用 notifyAll() 確實不會遺落等待隊列中的線程,但也產生了比較強烈的競爭,若是notify() 設計的自己就是 bug,那麼這個函數應該早就從 JDK 中移除了,它隨機通知一個線程的形式一定是有用武之地的

何時可使用 notify()

notify() 的典型的應用就是線程池(按照上面的三個條件你自問自答驗證一下是這樣嗎?)

這裏咱們拿一個 JUC 下的類來看看 notify() 的用處

Tips:

  • notify() 等同於 signal()
  • wait() 等同於 await()

在IDE中,打開 ArrayBlockingQueue.java

全部的入隊 public 方法offer()/put() 內部都調用了 private 的 enqueue() 方法

全部的出隊 public 方法poll()/take() 內部都調用了 private 的 dequeue() 方法

將這個模型進行精簡就是下面這個樣子:

public class SimpleBlockingQueue<T> {

    final Lock lock = new ReentrantLock();
    // 條件變量:隊列不滿
    final Condition notFull = lock.newCondition();
    // 條件變量:隊列不空
    final Condition notEmpty = lock.newCondition();

    // 入隊
    void enq(T x) {
        lock.lock();
        try {
            while (隊列已滿){
                // 等待隊列不滿
                notFull.await();
            }
            // 省略入隊操做...
            //入隊後,通知可出隊
            notEmpty.signal();
        }finally {
            lock.unlock();
        }
    }
    // 出隊
    void deq(){
        lock.lock();
        try {
            while (隊列已空){
                // 等待隊列不空
                notEmpty.await();
            }
            // 省略出隊操做...
            //出隊後,通知可入隊
            notFull.signal();
        }finally {
            lock.unlock();
        }
    }
}

若是知足上面這三個條件,notify() 的使用就恰到好處;咱們用使用 notify()的條件進行驗證

有的同窗看到這裏可能會稍稍有一些疑惑,await()/signal()wait()/notify() 組合的玩法看着不太同樣呢,你疑惑的沒有錯

由於 Java 內置的監視器鎖模型是 MESA 模型的精簡版

MESA模型

MESA 監視器模型中說,每個條件變量都對應一個條件等待隊列

對應到上面程序:

  • 隊列已盡是前提條件,條件變量A就是notFull,也就是notFull.await; notFull.signal
  • 隊列已空是前提條件,條件變量B就是notEmpty,也就是notEmpty.await; notEmpty.signal/sign

即使notFull.signalAll, 也和await在notEmpty 條件變量隊列的線程沒半毛錢關係

而Java內置監視器模型就只會有一個【隱形的】條件變量

  • 若是是synchronized修飾的普通方法,條件變量就是 this
  • 若是是synchronized修飾的靜態方法,條件變量就是類
  • 若是是synchronized塊,條件變量就是塊中的內容了

說完了這些,你有沒有恍然大悟的感受呢

總結

若是業務衝突不大,循環等待是一種簡單粗暴且有效的方式;可是當業務衝突大以後,通知/等待機制是必不可少的使用策略

經過這篇文章,相信你已經能夠經過靈魂4問,知道如何將循環等待改善成通知/等待模型了;另外也知道如何正確的使用通知/等待機制了

靈魂追問

  1. 錢莊轉帳的業務,條件都是判斷帳戶是否被支配,都是執行相同的轉帳業務,爲何就不能夠用notify() 而只能用notifyAll() 呢
  2. ResourceA的例子,爲何使用notify通知,程序沒有打印出 main thread over now, 而使用notifyAll() 卻打印出來了呢?

參考

感謝前輩們總結的精華,本身所寫的併發系列好多都參考瞭如下資料


下面的文章,就須要聊聊【線程的生命週期】了,只有熟知線程的生命週期,你才能更好的編寫併發程序。

我這面也在逐步總結常見的併發面試問題(總結ing......)答案整理好後會通知你們,請持續關注

相關文章
相關標籤/搜索