## 08 集體協作,什么最重要?溝通!—線程的等待和通知
> 世界上最寬闊的是海洋,比海洋更寬闊的是天空,比天空更寬闊的是人的胸懷。
> ——雨果
通過前面幾節的學習,我們了解了在 Java 中如何啟動一個線程,并且學習了 Thread 類的 API 以及線程的狀態。假如將多線程比作多個機器人一起工作,那么我們講到現在,所生產的機器人確實能夠擔當干活的責任了。每個機器人各司其職,盡職盡責地去完成自己的工作。他們每個人不斷去查看自己的任務列表,有了新的任務就去工作,沒有的話會持續查看。OK,這樣沒有問題,機器人能夠一起把工作干完。但是你不覺得缺了點什么嗎?或者說你不覺得機器人少了什么器官?沒錯,嘴巴!這個過程太安靜了,居然沒有機器人說話!這在現實世界是不可想象的。在現實世界里,即便最簡單的兩人配合工作都需要溝通和交流。

我們回憶一下之前學生抄寫單詞的例子。那個例子中抄寫的次數提前預置,每個學生抄寫前領取一次抄寫的任務,然后更新剩余抄寫次數,直到所有抄寫次數全部完成。這個例子比較簡單,但是假如今天老師發飆了,除了抄寫 internationalization 這個單詞,她還會一直給你新的單詞抄寫作業。此時,作為學生在抄寫完 internationalization 后他有兩個選擇。一是不停的盯著老師,直到老師發給他新的作業。二是他先休息一會,老師準備好新的作業時再叫他。假如我是學生,我肯定選擇第二種。因為第一種太累了,要一直盯著老師。第一種方式在程序中叫做輪詢。而第二種方式就引出了我們本節要講解的 wait/notify。
## 1\. wait/notify 概念
我們先從概念上初步了解 wait/notify。原本 RUNNING 的線程,可以通過調用 wait 方法,進入 BLOCKING 狀態。此線程會放棄原來持有的鎖。而調用 notify 方法則會喚醒 wait 的線程,讓其繼續往下執行。
你可能有疑問,既然這兩個方法都和線程有關系,為什么沒有放在上一節線程 API 中講解呢?這是因為這兩個方法并不在 Thread 對象中,而是在 Object 中。也就是說所有的 Java 類都繼承了這兩個方法。所有 Java 類都會繼承這兩個方法的原因是 Java 中同步操作的需要。
## 2、同步
講到這里,我們必須要對線程同步有所了解。那么什么是線程同步呢?我們先看看什么是異步,異步其實就是指多個線程同時執行。但在多個線程同時執行的過程中,可能會訪問共享資源,此時我們希望確保多個線程在同一時間只能有一個線程訪問,此時就稱之為線程同步。
在多線程開發中最基本的同步方式就是通過 synchronized 關鍵字來實現。第三節中我們單詞抄寫的程序并沒有徹底解決線程安全問題,仍舊可能出現重復抄寫。這是因為我們對抄寫次數這個共享資源的訪問沒有做同步。現在我們使用 synchronized 關鍵字對抄寫單詞的核心邏輯進行改寫,如下:
~~~java
while (true) {
int leftCopyCount = 0;
//在同步代碼塊中訪問punishment,確保讀取和更新數量時,只有一個線程訪問到共享資源
synchronized (punishment){
if (punishment.getLeftCopyCount() > 0) {
leftCopyCount = punishment.getLeftCopyCount();
punishment.setLeftCopyCount( leftCopyCount - 1);
}
}
if(leftCopyCount>0){
System.out.println(threadName + "線程-" + name + "抄寫" + leftCopyCount + "。還要抄寫" + leftCopyCount - 1 + "次");
count++;
}else{
break;
}
}
~~~
原來的代碼如下:
~~~java
while (true) {
if (punishment.getLeftCopyCount() > 0) {
int leftCopyCount = punishment.getLeftCopyCount();
if (leftCopyCount == punishment.getLeftCopyCount()) {
punishment.setLeftCopyCount(leftCopyCount - 1);
System.out.println(threadName + "線程-" + name + "抄寫" + punishment.getWordToCopy() + "。還要抄寫" + leftCopyCount-1 + "次");
count++;
}
} else {
break;
}
}
~~~
可以做一下比較。修改后的代碼中,讀取和更新 leftCopyCount 的兩步操作放在了 synchronized 代碼塊中,在此代碼塊中的代碼會確保同一時間只有一個線程能夠執行。這也稱之為加上了鎖,只有獲取鎖的線程才能執行同步代碼,執行完成后則會釋放鎖。因此在 synchronized 代碼塊中操作 punishment 是安全的。當前線程取出來的 leftCopyCount 值,在同步代碼塊結束前,也就是 set 回去前,并不會被其它線程所改變。所以它并不需要像原來代碼那么啰嗦,取出來后更新前還要再比較一次。其實原來代碼即使又做了比較,也無法 100% 確保更新操作前沒有被別的線程修改。這在第三節的實驗中已經得到證實。正確的編寫方式應該把共享資源的操作放在 synchronized 代碼塊中,這樣才能 100% 確保程序的正確性。
注意修改后的代碼,并沒有把輸出抄寫內容放到 synchronized 代碼塊中。因為這一步操作其實和共享資源已經無關,所以沒必要再持有鎖,這會延長其它線程等待鎖的時間,降低了并行代碼的效率。這在我們實際開發中要注意,盡量把不需要同步的代碼移出 synchronized 代碼塊。
## 3、使用 wait/notify
了解完 synchronized,我們再回頭看 wait 操作。synchronized 關鍵字需要配合一個對象使用,其實這個對象可以是任何對象,只不過為了代碼好懂,這里使用了共享資源對象 punishment,語義上表示對該對象上鎖,但你換成其它任何對象一樣是可以的。
其實 synchronized 所使用的對象,只是用來記錄等待同步操作的線程集合。他相當于一位排隊管理員,所有線程都要在此排隊,并接受他的管理,他說誰能進就可以進。另外他維護了一個 wait set,所有調用了 wait 方法的線程都保存于此。一旦有線程調用了同步對像的 notify 方法,那么 wait set 中的線程就會被 notify,繼續執行自己的邏輯。
這也解釋了為什么 synchronized 的對象并不一定是共享資源對象。這個對象只是看門人,確保同步代碼塊中的代碼只有一個線程能夠進入執行,但這個看門的工作并不一定要共享資源對象來做。任何對象都可擔當此工作。
需要注意的是,我們對哪個對象做了 synchronized 操作,那么就只能在同步代碼塊中使用此對象進行 wait 和 notify 的操作。這也很好理解,只有當看門人在聽你講話時,他才能按你的要求去做事情。我們只有獲得了和同步對象的對話權,這個對象才能聽此線程的命令。無論是請求加入 wait set 還是要通知 wait set 中的線程出來,均是如此。
wait 和 notify 示例代碼如下:
~~~java
synchronized (punishment){
//do something
punishment.wait();
//continue to do something
}
~~~
假如此段代碼在 A 線程中。這段代碼會在執行一些邏輯后把 A 線程放入 punishment 對象的 wait set 中,并且 A 線程會釋放持有的鎖。
我們再看看另外一個 B 線程中的部分代碼:
~~~java
synchronized (punishment){
//do something
punishment.nofity();
//continue to do something
}
~~~
這段代碼會 notify 在 punishment 對象的 wait set 中的一個線程,將其彈出。比如此時 A 線程在 wait set 中,那么 A 線程將被彈出。被彈出的 A 線程會在獲取 CPU 資源后繼續執行 wait 方法后面的邏輯。
最后再說一下 notifyAll 方法。我們知道 notify 可以喚醒 wait set 中的一個線程,但是如果 wait set 中存在多于一個線程時,我們并無法控制哪個線程被喚醒。假如所有線程的執行邏輯都是一樣的,那么無所謂誰被喚醒,因為都是干一樣的工作。
但如果是本節前面提出的問題,一個老師線程負責留作業,一個學生線程負責寫作業。假如此時開啟了多個學生線程,當學生寫完作業后本來需要通知老師留作業,但被 notify 的并不一定的是老師線程,也可能 notify 了其他學生線程。
為了解決這個問題,我們可以使用 notifyAll 來喚醒所有在此對象的 wait set 上的線程。而獲得鎖的線程是否真的需要做什么工作是由自己控制的。如果學生線程先搶到 CPU 資源,但是由于作業列表為空,他又會選擇 wait 進入 wait set。此時他會釋放鎖。而老師線程此時會獲得鎖,在看到作業列表為空后,則會添加新的作業。通過 wait/notifyAll 讓多個線程交互,同時通過共享資源的狀態,各線程控制自己的邏輯。這樣的程序稱之為狀態驅動程序。也就是說是否真的執行邏輯,是由狀態值所決定的。如果狀態不滿足,即使被 notify 了,也會再次進入 wait set。
## 4、總結
本節首先簡單介紹了同步的概念,然后講解了如何通過 wait 和 notify 實現多線程間的溝通和協調。講了這么多,其實不如寫一寫代碼,更容易理解。在下節中我們將采用生產者 / 消費者模式,來開發一個多位老師留作業,多個學生一起完成的作業的程序。屆時,我們本節學習的內容都會被使用上。
}
- 前言
- 第1章 Java并發簡介
- 01 開篇詞:多線程為什么是你必需要掌握的知識
- 02 絕對不僅僅是為了面試—我們為什么需要學習多線程
- 03 多線程開發如此簡單—Java中如何編寫多線程程序
- 04 人多力量未必大—并發可能會遇到的問題
- 第2章 Java中如何編寫多線程
- 05 看若兄弟,實如父子—Thread和Runnable詳解
- 06 線程什么時候開始真正執行?—線程的狀態詳解
- 07 深入Thread類—線程API精講
- 08 集體協作,什么最重要?溝通!—線程的等待和通知
- 09 使用多線程實現分工、解耦、緩沖—生產者、消費者實戰
- 第3章 并發的問題和原因詳解
- 10 有福同享,有難同當—原子性
- 11 眼見不實—可見性
- 12 什么?還有這種操作!—有序性
- 13 問題的根源—Java內存模型簡介
- 14 僵持不下—死鎖詳解
- 第4章 如何解決并發問題
- 15 原子性輕量級實現—深入理解Atomic與CAS
- 16 讓你眼見為實—volatile詳解
- 17 資源有限,請排隊等候—Synchronized使用、原理及缺陷
- 18 線程作用域內共享變量—深入解析ThreadLocal
- 第5章 線程池
- 19 自己動手豐衣足食—簡單線程池實現
- 20 其實不用造輪子—Executor框架詳解
- 第6章 主要并發工具類
- 21 更高級的鎖—深入解析Lock
- 22 到底哪把鎖更適合你?—synchronized與ReentrantLock對比
- 23 按需上鎖—ReadWriteLock詳解
- 24 經典并發容器,多線程面試必備—深入解析ConcurrentHashMap上
- 25 經典并發容器,多線程面試必備—深入解析ConcurrentHashMap下
- 26不讓我進門,我就在門口一直等!—BlockingQueue和ArrayBlockingQueue
- 27 倒數計時開始,三、二、一—CountDownLatch詳解
- 28 人齊了,一起行動—CyclicBarrier詳解
- 29 一手交錢,一手交貨—Exchanger詳解
- 30 限量供應,不好意思您來晚了—Semaphore詳解
- 第7章 高級并發工具類及并發設計模式
- 31 憑票取餐—Future模式詳解
- 32 請按到場順序發言—Completion Service詳解
- 33 分階段執行你的任務-學習使用Phaser運行多階段任務
- 34 誰都不能偷懶-通過 CompletableFuture 組裝你的異步計算單元
- 35 拆分你的任務—學習使用Fork/Join框架
- 36 為多線程們安排一位經理—Master/Slave模式詳解
- 第8章 總結
- 37 結束語