執行緒間的通訊
JVM在執行時會將自己管理的記憶體區域,劃分為不同的資料區,稱為執行時資料區。每個執行緒都有自己私有的記憶體空間,如下圖示:
Java執行緒按照自己虛擬機器棧中的方法程式碼一步一步的執行下去,在這一過程中不可避免的會使用到執行緒共享的記憶體區域堆或方法區。為了防止多個執行緒在同一時刻訪問同一個記憶體地址,需要互相告知自己的狀態以避免資源爭奪。
執行緒的通訊方式主要分為三種方式:①共享記憶體②訊息傳遞③管道流
共享記憶體:執行緒之間通過對共享記憶體的讀-寫來實現隱式通訊。Java中的具體實現是:volatile共享記憶體。
訊息傳遞:執行緒之間通過明確的傳送訊息來實現顯示通訊。Java中的具體實現是:等待/通知機制(wait/notify),join方法。
管道流:管道輸入/輸出流。
1、等待/通知機制
其過程是:執行緒A由於某些原因,自主呼叫了物件o的wait方法,進入WAITING狀態,釋放佔有的鎖並等待通知。而執行緒B則呼叫物件o的notify方法或notifyall方法進行通知,執行緒A會收到通知,並從wait方法中返回,繼續執行後面的程式碼。
可以發現,執行緒A和執行緒B就是通過物件o的wait方法和notify方法來傳送訊息,進行通訊。
wait方法和notify方法是Object類的方法,而Object類是所有類的父類,因此所有物件都實現了Object類的方法。即所有的物件都具有wait方法和notify方法。
方法 | 作用 | 備註 |
---|---|---|
wait | 執行緒呼叫共享物件的wait()方法後會進入WAITING狀態,釋放佔有的物件鎖並等待其他執行緒的通知或中斷才從該方法返回。 | 該方法可以傳引數,wait(long n):超時等待n毫秒,進入TIME-WAITING狀態,如果在n毫秒內沒有通知或中斷,則自行返回 |
notify | 執行緒呼叫共享物件的notify()方法後會通知一個呼叫了wait方法並在此等待的執行緒返回。但由於在共享變數上等待的執行緒可能不止一個,故具體通知哪一個執行緒是隨機的。 | notifyAll()方法與notify()方法作用一致,不過notify是隨機通知一個執行緒,而notifyAll則是通知所有在該共享變數上等待的執行緒 |
由於執行緒的等待/通知機制需要藉助共享物件,所以在呼叫wait方法前,執行緒必須先獲得該物件的鎖,即只能在同步方法或同步塊(synchronized程式碼塊)中呼叫wait方法,在呼叫wait方法後,執行緒釋放鎖。
同樣的notify方法在呼叫前也需要獲得物件的鎖,即也只能在同步方法或同步塊中呼叫notify方法。若有多個執行緒在等待,則執行緒排程器會隨機挑選一個執行緒來通知。需要注意的是,被通知的執行緒並不會在得到通知後就馬上從wait方法返回,而是需要等待獲得物件的鎖後才能從wait方法返回。而呼叫了notify方法的執行緒也並不會在呼叫時就馬上釋放物件的鎖,而是在執行完同步方法或同步塊(synchronized程式碼塊)後,才釋放物件的鎖。因此,被通知的執行緒要等呼叫了notify的執行緒釋放鎖後,才能從wait方法中返回。
綜上所述,等待/通知機制的經典正規化如下:
/**
* 等待執行緒(呼叫wait方法的執行緒)
*/
synchronized(共享物件){ //同步程式碼塊,進入條件是獲得鎖
while(判斷條件){ //進行wait執行緒任務的條件不滿足時進入
共享物件.wait()
}
執行緒任務程式碼
}
/**
* 通知執行緒(呼叫notify方法的執行緒)
*/
synchronized(共享物件){ //同步程式碼塊,進入條件是獲得鎖
執行緒任務程式碼
改變wait執行緒任務的條件
共享物件.notify()
}
根據以上正規化,有程式碼如下:
public class WaitNotify {
static boolean flag = true; //等待執行緒繼續執行往下執行的條件
static Object lock = new Object(); //上鎖的物件
public static void main(String[] args) throws InterruptedException {
Thread waitThread = new Thread(new WaitRunnable(),"waitThread"); //以WaitRunnable為任務類的執行緒
Thread notifyThread = new Thread(new NotifyRunnable(),"notifyThread"); //以NotifyRunnable為任務類的執行緒
waitThread.start(); //wait執行緒啟動
Thread.sleep(2000); //主執行緒休眠2s
notifyThread.start(); //notify執行緒啟動
}
/**
* Runnable等待實現類
* synchronized關鍵字:可以修飾方法或者以同步塊的形式來使用
*/
static class WaitRunnable implements Runnable{
@Override
public void run() {
//對lock加鎖
synchronized(lock){
//判斷,若flag為true,則繼續等待(wait)
while(flag){
try {
System.out.println(
Thread.currentThread().getName()+
"---flag為true,等待 @"+
new SimpleDateFormat("hh:mm:ss").format(new Date())
);
lock.wait(); //等待,並釋放鎖資源
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//若flag為false,則進行工作
System.out.println(
Thread.currentThread().getName()+
"---flag為false,執行 @"+
new SimpleDateFormat("hh:mm:ss").format(new Date())
);
}
}
}
/**
* Runnable通知實現類
*/
static class NotifyRunnable implements Runnable{
@Override
public void run(){
//對lock加鎖
synchronized(lock){
//以NotifyRunnable為任務類的執行緒釋放lock鎖,並進行通知後,以Wait為任務類的執行緒才可以跳出迴圈
System.out.println(
Thread.currentThread().getName()+
"---當前持有鎖,釋放 @"+
new SimpleDateFormat("hh:mm:ss").format(new Date())
);
lock.notifyAll(); //通知所有正在等待的執行緒從wait返回
flag = false;
try {
Thread.sleep(5000); //notifyThread執行緒休眠5s
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//再次對lock加鎖,並休眠
synchronized (lock){
System.out.println(
Thread.currentThread().getName()+
"---再次持有鎖,休眠 @"+
new SimpleDateFormat("hh:mm:ss").format(new Date())
);
try {
Thread.sleep(2000); //再次讓notifyThread執行緒休眠2s
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
//該程式碼示例來自《Java併發程式設計的藝術》
其結果如下:
waitThread---flag為true,等待 @01:53:51
notifyThread---當前持有鎖,釋放 @01:53:53
waitThread---flag為false,執行 @01:53:58
notifyThread---再次持有鎖,休眠 @01:53:58
以上程式碼根據等待/通知的經典正規化,設定一個執行緒是否繼續往下執行的條件變數flag,以及一個共享物件lock,並使用synchronized關鍵字對lock上鎖。
waitThread執行緒是等待執行緒,在啟動時會嘗試獲得鎖,成功則進入synchronized程式碼塊。在synchronized程式碼塊中,如果條件不滿足(即flag為true),則waitThread執行緒會進入while迴圈,並在迴圈體中呼叫wait方法,進入WAITING狀態及釋放鎖資源。直到有其他執行緒呼叫notify方法通知才從wait方法返回。
notifyThread執行緒是通知執行緒,在啟動時也會嘗試獲得鎖,成功則同樣進入synchronized程式碼塊。在synchronized程式碼塊中,notifyThread執行緒會改變條件,使waitThread執行緒可以繼續往下執行(即令flag為false),同時notifyThread執行緒也會呼叫notyfiAll方法,讓waitThread執行緒收到通知。
但注意,notifyThread執行緒並不會在呼叫notyfiAll方法後就馬上釋放鎖,而是在執行完synchronized程式碼塊的內容後才釋放鎖。我們在notifyThread執行緒呼叫notyfiAll後,將該執行緒休眠5s。可以從列印結果發現,在notifyThread執行緒休眠的5s中,即使waitThread執行緒得到了通知,且繼續執行的條件也已滿足(flag為flase),但waitThread執行緒在這5s中依然沒有得到執行。在notifyThread執行緒5s的休眠時間結束後,並從synchronized程式碼塊退出,waitThread執行緒才繼續執行。所以,等待執行緒在得到通知後,仍然需要等待通知執行緒釋放鎖,並且在嘗試獲得鎖成功後才能真正從wait方法中返回,並繼續執行。
2、共享記憶體
有如下程式碼,
/**
* @Author Feng Jian
* @Date 2021/1/20 13:18
* @Version 1.0
*/
public class JMMTest {
private static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread My_Thread = new Thread(new Runnable() {
@Override
public void run() {
while(run){
//...
}
}
}, "My_Thread");
My_Thread.start(); //啟動My_Thread執行緒
System.out.println(Thread.currentThread().getName()+"正在休眠@"+new SimpleDateFormat("hh:mm:ss").format(new Date())+"--"+run);
Thread.sleep(1000); //主執行緒休眠1s
run = false; //改變My_Thread執行緒執行條件,但My_Thread執行緒並不會停下
System.out.println(Thread.currentThread().getName()+"正在執行@"+new SimpleDateFormat("hh:mm:ss").format(new Date())+"--"+run);
}
}
定義了一個變數run,並以此作為My_Thread執行緒中while迴圈執行的條件。在啟動My_Thread執行緒,並使主執行緒休眠1s後,改變變數run的值。其結果如下:
可以看出,即使是run的值已經改變,但My_Thread執行緒依然不會停下來。為什麼呢?這就需要了解Java的記憶體模型(JMM)。
我們知道,CPU要從記憶體中讀取出資料來進行計算,但實際上CPU並不總是直接從記憶體中讀取資料。由於CPU和記憶體間(常稱之為主存)的速度不匹配(CPU的速度比主存快得多),為了有效利用CPU,使用多級cache的機制,如圖
因此,CPU讀取資料的順序是:暫存器-快取記憶體-主存。主存中的部分資料,會先拷貝一份放到cache中,當CPU計算時,會直接從cache中讀取資料,計算完畢後再將計算結果放置到cache中,最後在主存中重新整理計算結果。因此每個CPU都會擁有一份拷貝。
以上只是CPU訪問記憶體,進行計算的基本方式。實際上,不同的硬體,訪問過程會存在不同程度的差異。比如,不同的計算機,CPU和主存間可能會存在三級快取、四級快取、五級快取等等的情況。
為了遮蔽掉各種硬體和作業系統的記憶體訪問差異,實現讓 Java 程式在各種平臺下都能達到一致的記憶體訪問效果,定義了Java的記憶體模型(Java Memory Model,JMM)。
JMM 的主要目標是定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到主存和從主存中取出變數這樣的底層細節。這裡的變數指的是能夠被多個執行緒共享的變數,它包括了例項欄位、靜態欄位和構成陣列物件的元素,方法內的區域性變數和方法的引數為執行緒私有,不受JMM的影響。
Java的記憶體模型如下,
JMM定義了執行緒和主記憶體之間的關係:執行緒之間的共享變數儲存在主記憶體中,每個執行緒都有一個私有的本地記憶體,本地記憶體中儲存著主記憶體中的共享變數的副本。
JMM規定:將所有共享變數放到主記憶體中,當執行緒使用變數時,會把其中的變數複製到自己的本地記憶體,執行緒讀寫時操作的是本地記憶體中的變數副本。一個執行緒不能訪問其他執行緒的本地記憶體。
本地記憶體其實只是一個抽象的概念,它實際上並不真實存在,其包含了快取、寫緩衝區、暫存器以及其他的硬體和編譯器的優化。
在多執行緒環境下,由於每個執行緒都有主記憶體中共享變數的副本,所以當執行緒執行時,讀取的是自己本地記憶體中的共享變數的副本,這就產生了執行緒的安全問題:比如主記憶體中的共享變數i為1,執行緒A和B從主記憶體取出變數i,放入自己的本地記憶體中成為共享變數i的副本。當執行緒A執行時,會直接從自己的本地記憶體中讀取副本變數i的值,進行加1計算,完成後更新本地記憶體中的副本i的值,再寫回到主記憶體中,此時主記憶體中的i的值為2。
而如果此時執行緒B也需要用到變數i的值,則它並不會去主記憶體中讀取i的值,而是直接在自己的本地記憶體中讀取i的副本,而此時執行緒B的本地記憶體中的副本i的值依然為1,而不是經過執行緒A修改後的,主記憶體中的值2。
這也是為什麼在上述程式碼中,main執行緒明明已經修改了變數run的值,但My_Thread執行緒依然在執行while迴圈的原因。如圖所示,
這同樣是JMM所要處理的多執行緒可見性的問題:當一個共享變數在多個執行緒的工作記憶體中都有副本時,如果一個執行緒修改了這個共享變數的副本值,那麼其他執行緒應該能夠看到這個被修改後的值。即如何保證指令不會受 cpu 快取的影響。
回到上述的程式碼,如何使My_Thread執行緒能接收到main執行緒已經修改run = false
的資訊?即My_Thread執行緒和main執行緒如何能夠通訊。
根據Java的記憶體模型,這兩個執行緒如果需要通訊,則必須經歷以下兩步:
①main執行緒把本地記憶體中修改過的共享變數run的值重新整理到主記憶體中。
②My_Thread執行緒到主記憶體中去讀取main執行緒之前已經更新過的共享變數run的值。
這意味著,兩個執行緒的通訊必須經過主記憶體。Java提供volitale關鍵字實現這一要求。
volitale關鍵字可以用來修飾欄位(成員變數),告知Java程式任何對該變數的訪問都要從共享記憶體(主記憶體)中獲取,而對它的改變都必須同步重新整理回共享記憶體,故volitale關鍵字可以保證所有執行緒對變數訪問的可見性。即對共享變數的讀寫都需要經過主記憶體,因此達到執行緒通過共享記憶體進行通訊的目的。
知道了執行緒之間如何通過共享記憶體進行通訊,我們改寫一下上述程式碼,使main執行緒修改完run = false
後,My_Thread執行緒中的while迴圈即立即停止。
實際上只需要給共享變數run加上volitale關鍵字即可:
private static volatile boolean run = true;
修改後的執行結果如下:
可見,在main執行緒修改共享變數run的值後,即重新整理回主記憶體。而My_Thread執行緒讀取主記憶體中的run發現值為false後即停止了while迴圈。
實際上,也可以使用synchronized關鍵字來保證記憶體可見性問題,實現執行緒通訊。其機制是:在synchronized修飾的同步塊中,如果對一個共享變數進行操作,將會清空執行緒本地記憶體中此變數的值,並在使用這個共享變數前重新在主記憶體中讀取這個變數的值。而在同步塊執行完畢,釋放鎖資源時,則必須先把此共享變數同步回主記憶體中。
3、管道流
由於還未學習使用到,先暫時略過。。。
以上內容為本人在學習過程中所做的筆記。參考的書籍、文章或部落格如下:
[1]方騰飛,魏鵬,程曉明. Java併發程式設計的藝術[M].機械工業出版社.
[2]霍陸續,薛賓田. Java併發程式設計之美[M].電子工業出版社.
[3]Simen郎. 拜託,執行緒間的通訊真的很簡單.知乎.https://zhuanlan.zhihu.com/p/138689342
[4]極樂君.Java執行緒記憶體模型,執行緒、工作記憶體、主記憶體.知乎.https://zhuanlan.zhihu.com/p/25474331