併發這麼講真是太簡單了

併發這麼講真是太簡單了

你有一個思想,我有一個思想,我們交換後,一個人就有兩個思想

If you can NOT explain it simply, you do NOT understand it well enough

現陸續將Demo代碼和技術文章整理在一起 Github實踐精選 ,方便大家閱讀查看,本文同樣收錄在此,覺得不錯,還請Star

併發這麼講真是太簡單了

併發編程為什麼會有等待通知機制

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

併發這麼講真是太簡單了

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

<code>while(!accountBookManager.getAllRequiredAccountBook(this, target)) 
;/<code>

假如賬本管理員是年輕小夥,腿腳利落(即執行 getAllRequiredAccountBook方法耗時短),並且多個櫃員轉賬的業務衝突量不大,這個方案簡單粗暴且有效,櫃員只需要嘗試幾次就可以成功(即通過少量的循環可以實現)

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

併發這麼講真是太簡單了

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

等待/通知機制

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

  • 櫃員A如果拿不到所有賬本,就傲嬌的不再繼續問了(線程阻塞自己 wait)
  • 櫃員B歸還了櫃員A需要的賬本之後就主動通知櫃員A賬本可用(通知等待的線程 notify/notifyAll)

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


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

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

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

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

併發這麼講真是太簡單了

這可不是一個簡單的圖,下面還要圍繞這個圖做很多文章,不過這裡我必須要插播幾個面試基礎知識點了:

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

有了上面知識的鋪墊,要想將無限循環策略改為等待通知策略,你還需要問自己四個問題:

靈魂 4 問

併發這麼講真是太簡單了

我們拿錢莊賬本管理員的例子依依做以上回答:

併發這麼講真是太簡單了

我們優化錢莊轉賬的程序:

<code>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();
}
}/<object>/<code>

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

併發這麼講真是太簡單了

坑一

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

併發這麼講真是太簡單了

notify 喚醒的那一刻,線程【曾經/曾經/曾經】要求的條件得到了滿足,從這一刻開始,到去條件等隊列中喚醒線程,再到再次嘗試獲取鎖是有時間差的,當再次獲取到鎖時,線程曾經要求的條件是

不一定滿足,所以需要重新進行條件判斷,所以需要將 if 判斷改成 while 判斷

<code>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;
}
}/<code>

一個線程可以從掛起狀態變為可運行狀態(也就是被喚醒),即使線程沒有被其他線程調用notify()/notifyAll() 方法進行通知,或被中斷,或者等待超時,這就是所謂的【虛假喚醒】。雖然虛假喚醒很少發生,但要防患於未然,做法就是不停的去測試該線程被喚醒條件是否滿足

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


有同學可能還會產生疑問,為什麼while就可以?

因為被喚醒的線程再次獲取到鎖之後是

從原來的 wait 之後開始執行的,wait在循環裡面,所以會再次進入循環條件重新進行條件判斷。

如果不理解這個道理就記住一句話:

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

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

併發這麼講真是太簡單了

至於坑二,是線程歸還所使用的賬戶之後使用 notify 而不是 notifyAll 進行通知,由於坑很大,需要一些知識鋪墊來說明

為什麼說盡量使用 notifyAll

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

notify() 函數


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

notifyAll() 函數

喚醒所有: 與notify() 不同,notifyAll() 會喚醒在該共享變量上由於調用wait() 方法而被掛起的所有線程

看個非常簡單的程序例子吧

示例程序一

<code>@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");
}

}/<code>

來看運行結果

併發這麼講真是太簡單了

程序中我們使用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() 方法

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

<code>public class SimpleBlockingQueue {

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();
}
}
}
/<code>

如果滿足上面這三個條件,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() 卻打印出來了呢?

參考

感謝前輩們總結的精華,自己所寫的併發系列好多都參考了以下資料

  • Java 併發編程實戰
  • Java 併發編程之美
  • 碼出高效
  • Java 併發編程的藝術
  • https://www.geeksforgeeks.org/difference-notify-notifyall-java/
  • ...

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

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

併發這麼講真是太簡單了


另外準備了很多Java技術棧資料,私信我就好


併發這麼講真是太簡單了



趣味原創解析Java技術棧問題,將複雜問題簡單化,將抽象問題圖形化落地 如果對我的專題內容感興趣,或搶先看更多內容,歡迎訪問我的博客 dayarch.top


分享到:


相關文章: