Java多線程編程核心技術-第3章-線程間通信-讀書筆記

第 3 章 線程間通信

  線程是操作系統中獨立的個體,但這些個體如果不經過特殊的處理就不能成爲一個整體。線程間的通信就是成爲整體的必用方案之一,可以說,使線程間進行通信後,系統之間的交互性會更強大,在大大提高 cpu 利用率的同時還會使程序員對各線程任務在處理的過程中進行有效的把控與監督。

本章主要內容

使用 wait / notify 實現線程間的通信。
生產者 / 消費者模式的實現。
方法 join 的使用。
ThreadLocal 類的使用。

3.1 等待 / 通知機制

  線程與線程之間不是獨立的個體,它們彼此之間可以互相通信和協作。

3.1.1 方法內的變量爲線程安全

  通過 while 語句輪詢機制來檢測某一個條件,這樣湖浪費 CPU 資源。如果輪詢的時間間隔很小,更浪費 CPU 資源;如果輪詢的時間間隔很大,有可能會取不到想要得到的數據。所以就需要有一種機制來減少 CPU 的資源浪費,而且還可以實現在多個線程間通信,它就是“wait/notify”機制。

3.1.2 什麼是等待 / 通知機制

  比如廚師和服務員之間傳遞菜品就是一個等待 / 通知機制。

  多個線程共同訪問同一個比納涼,多個線程之間也實現了通信,但這種通信不是“等待 / 通知”,兩個線程完全是主動式讀取一個共享變量,在話費讀取時間的基礎上,讀到的值是不是想要的,並不能完全確定。

3.1.3 等待 / 通知機制的實現

  方法 wait() 的作用是使當前執行代碼的線程進行等待,wait() 方法是 Object 類的方法,該方法用來將當前線程置入“預執行隊列”中,並且在 wait() 所在的代碼行處停止執行,直到接到通知或被中斷爲止。在調用 wait() 之前,線程必須獲得該對象的對象級別鎖,即只能在同步方法或同步塊中調用 wait() 方法。在執行 wait() 方法後,當前線程釋放鎖。在從 wait() 返回前,線程與其他線程競爭重新獲得鎖。如果調用 wait() 時沒有持有適當的鎖,則拋出 IllegalMonitorStateException,它是 RuntimeException 的一個子類,因此,不需要 try-catch 語句進行捕捉異常。

  方法 notify() 也要在同步方法或同步塊中調用,即在調用前,線程也必須獲得該對象的對象級別鎖。如果調用 notify() 時沒有持有適當的鎖,也會拋出 IllegalMonitorStateException。該方法用來通知那些可能等待該對象的對象鎖的其他線程,如果有多個線程等待,則由線程規劃器隨機挑選出其中一個呈 wait 狀態的線程,對其發出通知 notify,並使它等待獲取該對象的對象鎖。需要說明的是,在執行 notify() 方法後,當前線程不會馬上釋放該對象鎖,呈 wait 狀態的線程也並不能馬上獲取該對象鎖,要等到執行 notify() 方法的線程將程序執行完,也就是退出 synchronized 代碼塊後,當前線程纔會釋放鎖,而呈 wait 狀態所在的線程纔可以獲取該對象鎖。當第一個獲得了該對象鎖的 wait 線程運行完畢以後,它會釋放掉該對象鎖,此時如果該對象沒有再次使用 notify 語句,則即便該對象已經空閒,其他 wait 狀態等待的線程由於沒有得到該對象的通知,還會繼續阻塞在 wait 狀態,直到這個對象發出一個 notify 或 notifyAll。

  用一句話來總結一下 wait 和 notify :wait 使線程停止運行,而 notify 使停止的線程繼續運行。

  關鍵字 synchroized 可以將任何一個 Object 對象作爲同步對象來看待,而 Java 爲每個 Object 都實現了 wait() 和 notify() 方法,它們必須用在被 synchronized 同步的 Object 的臨界區內。通過調用 wait() 方法可以使處於臨界區內的線程進入等待狀態,同時釋放被同步對象的鎖。而 notify 操作可以喚醒一個因調用了 wait 操作而處於阻塞狀態中的線程,使其進入就緒狀態。被重新喚醒的線程會試圖重新獲得獲得臨界區的控制權,也就是鎖,並繼續執行臨界區內 wait 之後的代碼。如果發出 wait 操作時沒有處於阻塞狀態中的線程,那麼該命令會被忽略。

  wait() 方法可以使調用該方法的線程釋放共享資源的鎖,然後從運行狀態推出,進入等待隊列,直到被再次喚醒。

  notify() 方法隨機喚醒等待隊列中等待同一共享資源的“一個”線程,並使該線程退出等待隊列,進入可運行狀態,也就是 notify() 方法僅通知“一個”線程。

  notifyAll() 放啊可以使所有正在等待隊列中等待同一共享資源的“全部”線程從等待狀態退出,進入可運行狀態。此時,優先級最高的那個線程最先執行,但也有可能是隨機執行,因爲這要取決與 JVM 虛擬機的實現。

  線程狀態切換示意圖:

  1)新創建一個新的線程對象後,再調用它的 start() 方法,系統會爲此線程分配 cpu 資源,使其處於 Runnable(可運行)狀態,這是一個準備運行的階段,如果線程搶佔到 CPU 資源,此線程就處於 Running(運行)狀態。

  2)Runnable 狀態和 Running 狀態可相互切換,因爲有可能線程運行一段時間後,有其他高優先級的線程搶佔了 CPU 資源,這是此線程就從 Running 狀態變成 Runnable 狀態。

  進程進入 Runnable 狀態大體分爲如下 5 中情況:

* 調用 sleep() 方法後經過的時間超過了指定的休眠時間。
* 線程調用的阻塞 IO 已經返回,阻塞方法執行完畢。
* 線程成功地獲得了試圖同步的監視器。
* 線程正在等待某個通知,其他線程發出了通知。
* 處於掛起狀態的線程調用了 resume 恢復方法。

  3)Blocked 是阻塞的意思,例如遇到了一個 IO 操作,此時 CPU 處於空閒狀態,可能會轉而把 CPU 時間片分配給其他線程,這時也可以稱爲“暫停”狀態。Blocked 狀態結束後,進入 Runnable 狀態,等待系統重新分配資源。

  出現阻塞的情況大體分爲如下 5 種:

* 線程調用 sleep 方法,主動放棄佔用的處理器資源。
* 線程調用了阻塞式 IO 方法,在該方法返回前,該線程被阻塞。
* 線程試圖獲得一個同步監視器,但該同步監視器正被其他線程所持有。
* 線程等待某個通知。
* 程序調用了 suspend 方法將該線程掛起。此方法容易導致死鎖,儘量避免使用該方法。

  4)run() 方法運行結束後進入銷燬階段,整個線程執行完畢。

  每個鎖對象都有兩個隊列,一個是就緒隊列,一個是阻塞隊列。就緒隊列存儲了將要獲得鎖的線程,阻塞隊列存儲了被阻塞的線程。一個線程被喚醒後,纔會進入就緒隊列,等待 CPU 的調度;反之,一個線程被 wait 後,就會進入阻塞隊列,等待下一次被喚醒。

3.1.4 方法 wait() 鎖釋放與 notify() 鎖不釋放

  當方法 wait() 被執行後,鎖被自動釋放,但執行完 notify() 方法,鎖卻不自動釋放。

  方法 sleep() 不釋放鎖。

  方法 notify() 輩子性後,不釋放鎖。

  必須執行完 notify() 方法所在的同步 synchronized 代碼塊後才釋放鎖。

3.1.5 當 interrupt 方法遇到 wait 方法

  當線程呈 wait() 狀態時,調用線程對象的 interrupt() 方法會出現 InterruptedException 異常。

  1)執行完同步代碼塊就會釋放對象的鎖。

  2)在執行同步代碼塊的過程中,遇到異常而導致線程終止,鎖也會被釋放。

  3)在執行同步代碼塊的過程中,執行了鎖所屬對象的 wait() 方法,這個線程會釋放對象鎖,而此線程對象會進入線程等待池中,等待被喚醒。

3.1.6 只通知一個線程

  調用 notify() 一次只隨機通知一個線程被喚醒。

  當多次調用 notify() 方法時,會隨機將等待 wait 狀態的線程進行喚醒。

  多次調用 notify() 方法喚醒了全部 WAITING 中的線程。

3.1.7 喚醒所有線程

  通過多次調用 notify() 方法來實現喚醒 3 個線程,但並不能保證系統中僅有 3 個線程,也就是若 notify() 方法的調用次數小於線程對象的數量,會出現有部分線程對象無法被喚醒的情況,爲了喚醒全部線程,可以使用 notifyAll() 方法。

3.1.8 方法 wait(long) 的使用

  帶一個參數的 wait(long) 方法的功能是等待某一時間內是否有線程對鎖進行喚醒,如果超過這個時間則自動喚醒。

3.1.9 通知過早

  如果通知過早,則會打亂程序正常的運行邏輯。

3.1.10 等待 wait 的條件發生變化

  在使用 wait/notify 模式時,還需要注意另外一種情況,也就是 wait 等待的條件發生了變化,也容易造成程序邏輯的混亂。

3.1.11 生產者/消費者模式實現

  等待 / 通知模式最經典的案例就是 “生產者/消費者” 模式。但此模式在使用上有幾種“變形”,還有一些小的注意事項,但原理都是基於 wait/notify 的。

1. 一生產與一消費:操作值

  只有一個生產者和一個消費者進行數據的交互,數據的 get 和 set 是交替進行的。

2. 多生產者與多消費者:操作數-假死

  “假死”的現象其實就是線程進入 WAITING 等待狀態。如果趣步線程都進入 WAITING 狀態,則線程就不再執行任何業務功能了,這個項目呈停止狀態。這在使用生產者與消費者模式時經常遇到。

  在代碼中已經通過 wait/notify 進行通信了,但不保證 notify 喚醒的是異類,也許是同類,比如 “生產者” 喚醒 “生產者”,或 “消費者” 喚醒 “消費者” 這樣的槍框。如果按這樣槍框運行的比率積少成多,就會導致所有的線程都不能繼續運行下去,大家都在等待,都呈 WAITING 狀態,程序最後也就呈 “假死” 狀態,不能繼續運行下去了。

  假死出現的主要原因是有可能連續喚醒同類。怎麼能解決這樣的問題呢?不光喚醒同類,將異類也一同喚醒就解決了。

3.多生產與多消費:操作值

  將 notify() 改成 notifyAll() 方法即可,它的原理就是不光通知同類線程,也包括異類。這樣就不至於出現假死的狀態了,程序會一直運行下去。

4.一生產與一消費:操作棧

  使生產者向堆棧 List 對象中放入數據,使消費者從 List 堆棧中取出數據。List 最大容量是 1,環境只有一個生產者與一個消費者。

  通過使用生產者 / 消費者模式,容器 size() 的值不會大於 1,值在 0 和 1 之間進行交替,也就是生產和消費這兩個過程在交替進行。

5.一生產與多消費—操作棧:解決 wait 條件改變與假死

  使用一個生產者向堆棧 List 對象中放入數據,而多個消費者從 List 堆棧中取出數據。List 最大容量還是 1。

  出現假死的解決辦法當然還是使用 notifyAll() 方法了。

6.多生產與一消費:操作棧

  使用生產者向堆棧 List 對象中放入數據,使用消費者從 List 堆棧中取出數據。List 最大容量還是 1,環境是多個生產者與一個消費者。

  不會有什麼問題。

7.多生產者與多消費者:操作棧

  使用生產者向棧 List 對象中放入數據,使用消費者從 List 棧中取出數據。List 最大容量是 1,環境是多個生產者與多個消費者。

  沒有什麼問題。

3.1.12 通過管道進行線程間通信:字節流

  在 Java 語言中提供了各種各樣的輸入 / 輸出六 Stream,使我們能夠很方便地對數據進行操作,其中管道流(pipeStream)是一種特殊的流,用於在不同線程間直接傳送數據。一個線程發送數據到輸出管道,另一個線程從輸入管道中讀數據。通過使用管道,實現不同線程間的通信,而無須藉助於類似臨時文件之類的東西。

  在 Java 的 JDK 中提供了 4 個類來使線程間可以進行通信:
1)PipedInputStream 和 PipedOutputStream
2)PipedReader 和 PipedWriter

  使用代碼 inputStream.connect(outputStream) 或 outputStream(inputStream) 的作用使兩個 Stream 之間產生通信鏈接,這樣纔可以將數據進行輸出與輸入。

3.1.13 通過管道進行線程間通信:字符流

  當然,在管道中還可以傳遞字符流。PipedReader 與 Pipedwriter。

3.1.14 實戰:等待 / 通知之交叉備份

  創建 20 個線程,其中 10 個線程將數據備份到 A 數據庫,另外 10 個線程將數據備份到 B 數據庫中,並且備份 A 數據庫和 B 數據庫使交叉進行的。

3.2 方法 join 的使用

  在很多情況下,主線程創建並啓動子線程,如果子線程中要進行大量的耗時運算,主線程往往將早於子線程結束之前結束。這時,如果主線程想等待子線程執行完成之後再結束,比如子線程處理一個數據,主線程要取得這個數據中的值,就要用到 join() 放啊了。方法 join() 的作用使等待線程對象銷燬。

3.2.1 學習方法 join 前的鋪墊

  舉例說明 join 方法存在的必要性。

3.2.2 用 join() 方法來解決

  方法 join 的作用是使所屬的線程對象 x 正常執行 run() 方法中的任務,而使當前線程 z 進行無限期的阻塞,等待線程 x 銷燬後再繼續執行線程 z 後面的代碼。

  方法 join 具有使線程排隊運行的作用,有些類似同步的運行效果。join 與 synchronized 的區別是:join 在內部使用 wait() 方法進行等待,而 synchronized 關鍵字使用的是 “對象監視器” 原理作爲同步。

3.2.3 方法 join 與異常

  在 join 過程中,如果當前線程對象被中斷,則當前線程出現異常。

  方法 join() 與 interrupted() 方法如果彼此遇到,則會出現異常。

3.2.4 方法 join(long)的使用

  方法 join(long) 中的參數是設定等待的時間。

  那使用 join(2000) 和使用 sleep(2000) 有什麼區別呢?在運行效果上並沒有區別,其實區別主要還是來自於 2 個方法對同步的處理上。

3.2.5 方法 join(long) 與 sleep(long) 的區別

  方法 join(long) 的功能在內部是使用 wait(long) 方法來實現,所以 join(long) 方法具有釋放鎖的特點。

  從源代碼中可以瞭解到,當執行 wait(long) 方法後,當前線程的鎖被釋放,那麼其他線程就可以調用此線程中的同步方法了。

  而 Thread.sleep(long) 方法卻不釋放鎖。

3.2.6 方法 join() 後面的代碼提前運行:出現意外

  在線程中運行 sleep(long) 方法,在主線程中調用 join(long) 方法等待線程運行,但是有時會導致主線程先運行完畢的情況。

3.2.7 方法 join() 後面的代碼提前運行:解釋意外

  join(long) 與線程爭搶鎖,如果 join(long) 爭搶到鎖,則主線程會先運行完畢;如果是線程爭搶到鎖,則線程先運行,然後主線程在運行完畢。

3.3 類 ThreadLocal 的使用

  變量的共享可以使用 public static 變量的形式,所有的線程都使用同一個 public static 變量。如果想實現每一個線程都有自己的共享變量該如何解決呢?JDK 中提供的類 ThreadLocal 正是爲了解決這樣的問題。

  類 ThreadLocal 主要解決的就是每個線程綁定自己的值,可以將 ThreadLocal 類比喻成全局存放數據的盒子,盒子中可以存儲每個線程的私有數據。

3.3.1 方法 get() 與 null

  類 ThreadLocal 解決的是變量在不同線程間的隔離性,也就是不同線程擁有自己的值,不同線程中的值是可以放入 ThreadLocal 類中進行保存的。get() 方法取值,set() 方法賦值。

3.3.2 驗證線程變量的隔離性

  雖然多個線程都向 ThreadLocal 對象中 set() 數據值,但每個線程還是能取出自己的數據。

3.3.3 解決 get() 返回 null 問題

  覆蓋 initialValue() 方法設置初始值。

3.3.4 再次驗證線程變量的隔離性

  覆蓋 initialValue() 方法設置初始值後,各個線程也各自擁有各自的初始值。

3.4 類 InheritableThreadLocal 的使用

  使用類 InheritableThreadLocal 可以在子線程中取得父線程繼承下來的值。

3.4.1 值繼承

  使用 InheritableThreadLocal 類可以讓子線程從父線程取得值。

3.4.2 值繼承再修改

  覆蓋 childValue() 方法可以在繼承的同時對值進行下一步的處理。

  但在使用 InheritableThreadLocal 類需要注意一點的是,如果子線程在取得值的同時,主線程將 InheritableThreadLocal 中的值進行更改,那麼子線程取到的值還是舊值。

3.5 本章總結

  可以將分散的線程對象進行批次的通信與協作。對任務的計劃規劃更加合理,不再是隨機的和盲目的了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章