併發程式設計之 wait notify 方法剖析

莫那·魯道發表於2019-03-03
併發程式設計之 wait notify 方法剖析

前言

2018 元旦快樂。

摘要:

  1. notify wait 如何使用?
  2. 為什麼必須在同步塊中?
  3. 使用 notify wait 實現一個簡單的生產者消費者模型
  4. 底層實現原理

1. notify wait 如何使用?

今天我們要學習或者說分析的是 Object 類中的 wait notify 這兩個方法,其實說是兩個方法,這兩個方法包括他們的過載方法一共有5個,而Object 類中一共才 12 個方法,可見這2個方法的重要性。我們先看看 JDK 中的程式碼:

public final native void notify();

public final native void notifyAll();
 
public final void wait() throws InterruptedException {
    wait(0);
}

public final native void wait(long timeout) throws InterruptedException;

public final void wait(long timeout, int nanos) throws InterruptedException {
    if (timeout < 0) {
        throw new IllegalArgumentException("timeout value is negative");
    }

    if (nanos < 0 || nanos > 999999) {
        throw new IllegalArgumentException(
                            "nanosecond timeout value out of range");
    }

    if (nanos > 0) {
        timeout++;
    }

    wait(timeout);
}
複製程式碼

就是這五個方法。其中有3個方法是 native 的,也就是由虛擬機器本地的c程式碼執行的。有2個 wait 過載方法最終還是呼叫了 wait(long) 方法。

首先還是 know how。來一個最簡單的例子,看看如何使用這兩個方法。

package cn.think.in.java.two;

import java.util.concurrent.TimeUnit;

public class WaitNotify {

  final static Object lock = new Object();

  public static void main(String[] args) {

    new Thread(new Runnable() {
      @Override
      public void run() {
        System.out.println("執行緒 A 等待拿鎖");
        synchronized (lock) {
          try {
            System.out.println("執行緒 A 拿到鎖了");
            TimeUnit.SECONDS.sleep(1);
            System.out.println("執行緒 A 開始等待並放棄鎖");
            lock.wait();
            System.out.println("被通知可以繼續執行 則 繼續執行至結束");
          } catch (InterruptedException e) {
          }
        }
      }
    }, "執行緒 A").start();

    new Thread(new Runnable() {
      @Override
      public void run() {
        System.out.println("執行緒 B 等待鎖");
        synchronized (lock) {
          System.out.println("執行緒 B 拿到鎖了");
          try {
            TimeUnit.SECONDS.sleep(5);
          } catch (InterruptedException e) {
          }
          lock.notify();
          System.out.println("執行緒 B 隨機通知 Lock 物件的某個執行緒");
        }
      }
    }, "執行緒 B").start();
  }


}

複製程式碼

執行結果:

執行緒 A 等待拿鎖
執行緒 B 等待鎖
執行緒 A 拿到鎖了
執行緒 A 開始等待並放棄鎖
執行緒 B 拿到鎖了
執行緒 B 隨機通知 Lock 物件的某個執行緒
被通知可以繼續執行 則 繼續執行至結束

在上面的程式碼中,執行緒 A 和 B 都會搶這個 lock 物件的鎖,A 的運氣比較好(也可能使 B 拿到鎖),他先拿到了鎖,然後呼叫了 wait 方法,放棄了鎖,並掛起了自己,這個時候等待鎖的 B 就拿到了鎖,然後通知了A,但是請注意,通知完畢之後,B 執行緒並沒有執行完同步程式碼塊中的程式碼,因此,A 還是拿不到鎖的,因此無法執行,等到B執行緒執行完畢,出了同步塊,這個時候 A 執行緒才被啟用得以繼續執行。

使用 wait 方法和 notify 方法可以使 2 個無關的執行緒進行通訊。也就是面試題中常提到的執行緒之間如何通訊。

如果沒有 wait 方法和 noitfy 方法,我們如何讓兩個執行緒通訊呢?簡單的辦法就是讓某個執行緒迴圈去檢查某個標記變數,比如:

while (value != flag) {
  Thread.sleep(1000);
}
doSomeing();
複製程式碼

上面的這段程式碼在條件不滿足使就睡眠一段時間,這樣做到目的是防止過快的”無效嘗試“,這種方式看似能夠實現所需的功能,但是卻存在如下問題:

  1. 難以確保及時性。因為等待的1000時間會導致時間差。
  2. 難以降低開銷,如果確保了及時性,休眠時間縮短,將大大消耗CPU。

但是有了Java 自帶的 wait 方法 和 notify 方法,一切迎刃而解。官方說法是等待/通知機制。一個執行緒在等待,另一個執行緒可以通知這個執行緒,實現了執行緒之間的通訊。

2. 為什麼必須在同步塊中?

注意,這兩個方法的使用必須是在 synchroized 同步塊中,並且在當前物件的同步塊中,如果在 A 物件的方法中呼叫 B 物件的 wait 或者 notify 方法,虛擬機器會丟擲 IllegalMonitorStateException,非法的監視器異常,因為你這個執行緒持有的監視器和你呼叫的監視器的不是一個物件。

那麼為什麼這兩個方法一定要在同步塊中呢?

這裡要說一個專業名詞:競態條件。什麼是競太條件呢?

當兩個執行緒競爭同一資源時,如果對資源的訪問順序敏感,就稱存在競態條件。

競態條件會導致程式在併發情況下出現一些bugs。多執行緒對一些資源的競爭的時候就會產生競態條件,如果首先要執行的程式競爭失敗排到後面執行了,那麼整個程式就會出現一些不確定的bugs。這種bugs很難發現而且會重複出現,這是因為執行緒間會隨機競爭。

假設有2個執行緒,分別是生產者和消費者,他們有各自的任務。

1.1生產者檢查條件(如快取滿了)-> 1.2生產者必須等待
2.1消費者消費了一個單位的快取 -> 2.2重新設定了條件(如快取沒滿) -> 2.3呼叫notifyAll()喚醒生產者

我們希望的順序是: 1.1->1.2->2.1->2.2->2.3
但是由於CPU執行是隨機的,可能會導致 2.3 先執行,1.2 後執行,這樣就會導致生產者永遠也醒不過來了!

所以我們必須對流程進行管理,也就是同步,通過在同步塊中並結合 wait 和 notify 方法,我們可以手動對執行緒的執行順序進行調整。

3. 使用 notify wait 實現一個簡單的生產者消費者模型

雖然很多書中都不建議我們直接使用 notify 和 wait 方法進行併發程式設計,但仍然需要我們重點掌握。樓主寫了一個簡單的生產者消費者例子:

簡單的快取類:


public class Queue {

  final int num;
  final List<String> list;
  boolean isFull = false;
  boolean isEmpty = true;


  public Queue(int num) {
    this.num = num;
    this.list = new ArrayList<>();
  }


  public synchronized void put(String value) {
    try {
      if (isFull) {
        System.out.println("putThread 暫停了,讓出了鎖");
        this.wait();
        System.out.println("putThread 被喚醒了,拿到了鎖");
      }

      list.add(value);
      System.out.println("putThread 放入了" + value);
      if (list.size() >= num) {
        isFull = true;
      }
      if (isEmpty) {
        isEmpty = false;
        System.out.println("putThread 通知 getThread");
        this.notify();
      }
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }

  public synchronized String get(int index) {
    try {
      if (isEmpty) {
        System.err.println("getThread 暫停了,並讓出了鎖");
        this.wait();
        System.err.println("getThread 被喚醒了,拿到了鎖");
      }

      String value = list.get(index);
      System.err.println("getThread 獲取到了" + value);
      list.remove(index);

      Random random = new Random();
      int randomInt = random.nextInt(5);
      if (randomInt == 1) {
        System.err.println("隨機數等於1, 清空集合");
        list.clear();
      }

      if (getSize() < num) {
        if (getSize() == 0) {
          isEmpty = true;
        }
        if (isFull) {
          isFull = false;
          System.err.println("getThread 通知 putThread 可以新增了");
          Thread.sleep(10);
          this.notify();
        }
      }
      return value;


    } catch (InterruptedException e) {
      e.printStackTrace();
    }
    return null;
  }


  public int getSize() {
    return list.size();
  }


複製程式碼

生產者執行緒:

class PutThread implements Runnable {

  Queue queue;

  public PutThread(Queue queue) {
    this.queue = queue;
  }

  @Override
  public void run() {
    int i = 0;
    for (; ; ) {
      i++;
      queue.put(i + "號");

    }
  }
}

複製程式碼

消費者執行緒:

class GetThread implements Runnable {

  Queue queue;

  public GetThread(Queue queue) {
    this.queue = queue;
  }

  @Override
  public void run() {
    for (; ; ) {
      for (int i = 0; i < queue.getSize(); i++) {
        try {
          Thread.sleep(1000);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
        String value = queue.get(i);

      }
    }
  }
}

複製程式碼

大家有興趣可以跑跑看,能夠加深這兩個方法的理解,實際上,JDK 內部的阻塞佇列也是類似這種實現,但是,不是用的 synchronized ,而是使用的重入鎖。

基本上經典的生產者消費者模式的有著如下規則:

等待方遵循如下規則:

  1. 獲取物件的鎖。
  2. 如果條件不滿足,那麼呼叫物件的 wait 方法,被通知後仍要檢查條件。
  3. 條件滿足則執行相應的邏輯。

對應的虛擬碼入下:

synchroize( 物件 ){
    while(條件不滿足){
      物件.wait();
    }
    對應的處理邏輯......
}
複製程式碼

通知方遵循如下規則:

  1. 獲得物件的鎖。
  2. 改變條件。
  3. 通知所有等待在物件上的執行緒。

對應的虛擬碼如下:

synchronized(物件){
  改變條件
  物件.notifyAll();
}
複製程式碼

4. 底層實現原理

知道了如何使用,就得知道他的原理到底是什麼?

首先我們看,使用這兩個方法的順序一般是什麼?

  1. 使用 wait ,notify 和 notifyAll 時需要先對呼叫物件加鎖。
  2. 呼叫 wait 方法後,執行緒狀態有 Running 變為 Waiting,並將當前執行緒放置到物件的 等待佇列
  3. notify 或者 notifyAll 方法呼叫後, 等待執行緒依舊不會從 wait 返回,需要呼叫 noitfy 的執行緒釋放鎖之後,等待執行緒才有機會從 wait 返回。
  4. notify 方法將等待佇列的一個等待執行緒從等待佇列種移到同步佇列中,而 notifyAll 方法則是將等待佇列種所有的執行緒全部移到同步佇列,被移動的執行緒狀態由 Waiting 變為 Blocked。
  5. 從 wait 方法返回的前提是獲得了呼叫物件的鎖。

從上述細節可以看到,等待/通知機制依託於同步機制,其目的就是確保等待執行緒從 wait 方法返回後能夠感知到通知執行緒對變數做出的修改。

該圖描述了上面的步驟:

併發程式設計之 wait notify 方法剖析

WaitThread 獲得了物件的鎖,呼叫物件的 wait 方法,放棄了鎖,進入的等待佇列,然後 NotifyThread 拿到了物件的鎖,然後呼叫物件的 notify 方法,將 WatiThread 移動到同步佇列中,最後,NotifyThread 執行完畢,釋放鎖, WaitThread 再次獲得鎖並從 wait 方法返回繼續執行。

到這裡,關於應用層面的 wait 和 notify 基本就差不多了,後面的是關於虛擬機器層面的拋磚引玉,涉及到 Java 的內建鎖實現,synchronized 關鍵字底層實現,JVM 原始碼。算是本文的擴充套件吧。

注意:我們看到圖中出現了 Monitor 這個詞,也就是監視器,實際上,在 JDK 的註釋中,也有 The current thread must own this object`s monitor 這句話,當前執行緒必須擁有該物件的監視器。

如果我們編譯這段含有 synchronized 關鍵字的程式碼,就會發現有一段程式碼被 monitorenter 指令和 monitorexit 指令括住了,這就是 synchronized 在編譯期間做的事情,那麼,在位元組碼被執行的時侯,該指令對應的 c 程式碼將會被執行。這裡,我們必須打住,這裡已經開始涉及到 synchronized 的相關原理了,本篇文章不會討論這個。

wait noitfy 的答案都在 Java HotSpot 虛擬機器的 C 程式碼中。但 R 大告訴我們不要輕易閱讀虛擬機器原始碼,眾多細節可能會掩蓋抽象,導致學習效率不高。如果同學們有興趣,有大神寫了3篇文章專門從 HotSpot 中解析原始碼,地址:

Java的wait()、notify()學習三部曲之一:JVM原始碼分析
Java的wait()、notify()學習三部曲之二:修改JVM原始碼看引數
Java的wait()、notify()學習三部曲之三:修改JVM原始碼控制搶鎖順序
還有狼哥的 JVM原始碼分析之Object.wait/notify實現.

上面四篇文章都從 JVM 的原始碼層面解析了 wait ,notify 的實現原理,非常清楚。

拾遺

  1. wait(long) 方法,該方法引數是毫秒,也就是說,如果執行緒等待了指定的毫秒數,就會自動返回該執行緒。
  2. wait(long, int)方法,該方法增加了納秒級別的設定,演算法是,前面的毫秒加上後面的納秒,注意,是直接加一毫秒。
  3. notify 方法呼叫後,如果等待的執行緒很多,JDK 原始碼中說將會隨機找一個,但是 JVM 的原始碼中實際上是找第一個。
  4. notifyAll 和 notify 不會立即生效,必須等到呼叫方執行完同步程式碼塊,放棄鎖之後才起作用。

總結

好了,關於 wait noitfy 的使用和基本原理就介紹到這裡,不知道大家發現沒有,併發和虛擬機器高度相關。因此,可以說,學習併發的過程就是學習虛擬機器的過程。而閱讀虛擬機器裡的 openjdk 程式碼讓人頭大,但不管怎麼樣,醜媳婦遲早見公婆,openjdk 程式碼是一定要看的,加油!!!!

相關文章