Java多線程(3)——線程間通信

本文主要學習線程間相互通信的內容。線程見需要通信,才能協同完成 工作 ,雖然這增加的這裏的複雜度,也很容易出錯,但是線程間通信是很重要也很不可缺少的功能。

1、等待與通知

1.1、wait、notify介紹

如果看一眼 java 最基礎的一個類Object的源碼的話,會發現Object類有兩個方法,wait、notify。所有的類都是默認繼承Object類的,所以我們創建的所有的類都有這兩個方法。

舉個例子,我們去麥當勞、或者奶茶店,買好了之後會得到小票,這時候店家會開始準備我們的餐飲,我們可以不用一直頂着店家看的,我們就站在一邊玩會手機(這其實就是 多線程 ,你在等待取餐的時間內去幹了別的),等到東西做好了,服務員會叫號,說xxxx號好了,這時候我們一聽,是我們的東西好了,於是放下手機去取餐。

這個例子就是等待通知。我們買好餐之後,拿到小票,就在一邊等着,這時候就相當於調用了wait方法,當餐好了,服務員叫我們,這相當於調用了notify方法,我們收到之後就停止wait去取餐了。

所以這裏的一個線程,就是我們點餐取餐,示例代碼如下。

String lock = "你的餐";
synchronized (lock) {
    // 點餐 付款
    lock.wait();
    // 取餐 吃飯
}

還有一個線程,是服務員,示例代碼如下。

String lock = "你的餐";
synchronized (lock) {
    // 備餐
    lock.notify();
    // 確認你的小票
}

這裏我們付款之後,wait等待(阻塞當前線程),這裏的線程是點餐取餐的過程,而我們自己相當於一個cpu,這時候可以分享cpu去執行其他線程的工作(比如玩手機)。服務員備餐,當備餐好了之後,調用notify,然後看看你的小票,確認沒問題把餐給你他就走了。這時候你從阻塞狀態恢復回來,取餐然後去吃飯。

這裏特別說明一下,wait調用之後,會釋放鎖(同時阻塞),這樣其他線程才能獲得鎖去工作。而notify執行之後不會立刻釋放鎖,需要同步語句塊所有內容執行完之後纔會釋放鎖,wait後面的代碼才能開始執行。所以上面的例子中,服務員叫你的號了,但是你不能立刻把餐拿走,需要先確認你的小票是不是這個號,確認完了他的工作纔算結束,你才能拿餐。

1.2、等待通知必須在同步語句塊中

可以是同步語句塊,也可以是synchronized修飾的方法中,總是需要處於同步狀態下,而這個等待、通知的主體也必須是同步狀態下監視的對象。

如果不在同步狀態下,會拋出IllegalMonitorStateException異常。

而且向我們上面的例子,同步監控的是lock字符串,那麼wait、notify的主體也就必須是lock;對於synchronized修飾的方法而言,由於鎖的是當前對象,所以主體應該是this。

1.3、線程狀態介紹

線程的狀態有new、runnable、running、blocked/time wait/sleeping、terminated。

(其實這裏的狀態介紹大家可以理解意思。在Java Thread.State裏面有對現場狀態的詳細定義,後面內容會詳細介紹。State中定義的狀態有new、runnable、blocked、waiting、timed_waiting、terminated。)

其中新建一個線程就是new,然後調用start方法,線程會進入runnable(可運行)狀態,但是這時候線程可能還沒開始運行,因爲他要爭搶cpu資源,所以不一定你調用了start方法,這個線程就可以啓動了。

當線程的爭搶到cpu資源了,那麼他就會進入running(運行中)狀態。當然runnable和running可能會互相轉換的,如果有更高優先級的線程爭搶到了cpu資源,那麼這個線程可能會進入到runnable狀態。線程進入runnable狀態有如下可能:

a、調用sleep之後經過的時間超過了指定的sleep時間(sleep結束之後重新進入runnable狀態爭搶cpu資源); 
b、線程調用的阻塞IO已經返回,阻塞方法執行完畢; 
c、線程成功的獲得了試圖同步的監視器; 
d、線程正在等待某個通知,其他線程發出了通知; 
e、處於掛起(suspend)狀態的線程調用了resume恢復方法。

blocked是阻塞的意思,time wait是處於等待的狀態,sleeping是處於sleep狀態。這三個狀態通常統稱爲一種狀態,他們比較相似。blocked比如遇到了一個IO操作,需要等待,而其他線程爭搶到了cpu資源,這時候當前線程就處於blocked狀態了。time wait比較好理解吧,就是處於wait阻塞的狀態。sleeping就是主動調用的sleep方法處於等待狀態。所以總結起來,可能引起進入blocked狀態的情況有下面5種:

a、線程調用sleep方法主動放棄; 
b、調用wait方法等待通知; 
c、調用了阻塞IO,等待返回(這裏比如發起一個http請求,需要等待服務員響應); 
d、線程試圖獲得某個對象監視器,但是這個同步對象正在被別的線程持有; 
e、調用了suspend主動掛起(這種方法已經被廢棄,容易引起死鎖,建議棄用)。

當run裏面的內容都運行完了,線程的工作也就結束了,這時候他就可以銷燬了,進入了terminated狀態。

這裏額外說一點,wait和sleep其實有這一點相似,就是在阻塞狀態時,調用了線程的interrupt方法,會出現InterruptedException異常。

1.4、其他補充

當一個對象監視器有多個線程正在wait的時候,這時候某個線程調用了notify之後,所有wait狀態的線程開始爭搶cpu資源,其中只有一個線程可以從wait狀態進入運行狀態,所以要想都喚醒,那就需要調用n遍notify方法,或者,調用notifyAll方法,這裏還是要看具體的業務場景了。

wait方法還有個帶參的方法,參數可以是個long,意思就是等待n毫秒,如果還沒有被喚醒,那麼就自己醒。當然如果在n毫秒以內也可以被notify喚醒。

假設你設計的程序中一個工作中只會執行一次wait、notify的話,那麼一定要注意他們是不是一定會按照順序執行的,假如先執行了notify,那麼在執行wait的話就沒人喚醒他了。

2、線程通信具體方式

2.1、wait、notify最簡單的使用

這種方式也很簡單,就不細說了。就是一個線程等待獲取另一個線程的數據的時候,首先在需要調用數據的前面執行wait,另外一個線程寫入數據,寫入之後執行notify方法通知等待的線程獲取數據。這樣就完成了線程間最簡單的通信了。

這個例子很簡單,我這裏就不寫示例了。

其實再複雜一點的話,就是讀取數據這邊,在讀取完了之後又要通知寫數據的線程寫數據,數據兩個線程其實在讀寫數據前後都需要wait和notify方法了。而這裏都要對數據這塊進行加鎖。這就是複雜之處了,由於寫數據的線程不會只有一個,讀數據的線程也不會只有一個。所以有可能這個寫線程的notify喚醒的是另一個寫線程的wait,這就出錯了,而且也可能導致所有讀線程的wait都得不到喚醒而產生死鎖。這裏有個簡單的解決辦法,就是調用notifyAll,這樣讀線程也會讀取了,而寫的時候判斷一下是否被寫過,讀的時候判斷下是否被讀過,這裏的判斷有很多辦法,交給大家自己去實驗一下了(其實加變量就可以了)。

2.2、管道通信,字節流、字符流

Java中提供了很多輸入輸出流,Stream,可以方便我們對數據進行操作。JDK提供了兩組類來實現線程間通信,分別是PipedInputStream與PipedOutputStream、PipedReader與PipedWriter。

關於管道的知識這裏就不詳細介紹了,大家有興趣可以自己搜索學習下。

3、join方法

3.1、join的作用

join最重要的一個作用就是等待當前線程執行完畢。舉個例子。

public static void main(String[] args) {
    TestThread t = new TestThread();
    t.start();
    t.join();
    System.out.println("我想最後說");
}

正常情況下,如果沒有這個join的調用的話,這個打印語句有可能是先於或者在線程運行中執行的,而調用了join方法之後,這個打印語句就會在t這個線程徹底執行完之後再打印了。

其實這有很多作用,這裏的main方法實際上是主線程,而t是子線程。如果我們不加這個join,實際上主線程會先於子線程結束。有時候我們需要等子線程執行完,比如修改一個字符串的內容,主線程再去獲取修改後的內容,所以一定要等子線程執行完纔可以。

3.2、join的原理

知道了join的作用之後,就需要知道他的原理了。實際上很簡單,join的內部是通過wait實現的。

所以join的一個特性我們就知道了,會被interrupt打斷,和wait一樣。

3.3、join其他內容

join內部調用的wait,所以他也有一個join(long)的方法,這個方法和wait一樣,也就是join多少毫秒,如果在這時間之後,線程還是沒有結束,那麼就不等了。

join(long)和sleep(long)區別是什麼呢,首先一個是如果在時間內線程執行結束,join等待的時間更少。另外一個就是join由於內部使用的是wait,所以在調用join之後,實際上是調用了對象的wait方法,所以會釋放當前對象的鎖,其他線程就可以獲取鎖了,可操作的內容就更多了。

另外,由於join內部調用的是wait,所以當被notify是,他同樣需要和其他當前對象正在wait狀態的線程進行鎖爭搶,所以有的時候也可能產生意外。

4、ThreadLocal類

不知道大家有沒有了解過這個類,其實我在最開始學mvc的時候就學到過,這個類就是一個線程共享變量的類。什麼叫做線程共享變量呢?

對於我們使用public static修飾的,是個靜態變量,大家都可以調用,而且就這一份。而線程共享變量,就是一個線程獨享的變量。就算這個變量的聲明就那一份,但是每個線程對這個變量的訪問是互不干擾的。

所以回到我最初說的,就這一個變量,我們在controller裏面去寫個值,在service、dao層去訪問這個變量都是可以得到的,而多個用戶的訪問又是互不相干的。而一次訪問映射的其實就是一個線程,controller去調用service、service調用dao,再到底層實現,都是一個在一個線程裏的。

4.1、get、set

ThreadLocal主要的兩個方法就是get和set了,很簡單,set放值,get取值。而ThreadLocal支持泛型,所以可以存取任何類型的對象。下面是示例。

public class ThreadTool {
    public static ThreadLocal<String> tl = new ThreadLocal<String>();
}

public class Run {
    public static void main(String[] args) {
        ThreadTool.tl.set("a");
        System.out.println(ThreadTool.tl.get());
    }
}

4.2、默認方法的重寫

ThreadLocal有個可重寫的方法,一個是initialValue方法,這個方法返回默認值。如果我們沒有set值的話,返回的會是null,而重寫了這個方法,可以返回我們指定的默認值,當然一般情況下我們是沒有必須要重寫的。

4.3、InheritableThreadLocal

通過這個類名我們應該也可以知道,這個類其實和繼承有關(其實不是)。其實是這個類可以讓子線程可以繼承父線程的內容。這樣,子線程和父線程通過這個類,就可以共享變量了。這個比較簡單,使用方法和TheadLocal一樣,就不舉例了。

但是這個就會出現坑了,由於正常情況下我們的ThreadLocal是在一個線程中使用的,併發問題都是出在多線程中的,所以ThreadLocal並不會出現併發訪問問題,而InheritableThreadLocal可能會出現在多個線程中了(父線程可以起多個子線程),所以線程多了,還是可能出現髒讀之類的問題的,這點要注意。

到此我們對於線程通信的內容就介紹完了。

本文原創於 趙伊凡BLOG , 轉載 請註明出處。

©原創文章,轉載請註明來源: 趙伊凡's Blog 

發佈了10 篇原創文章 · 獲贊 6 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章