【JAVA併發第三篇】線程間通訊

線程間的通訊

JVM在運行時會將本身管理的內存區域,劃分爲不一樣的數據區,稱爲運行時數據區。每一個線程都有本身私有的內存空間,以下圖示:java

在這裏插入圖片描述

Java線程按照本身虛擬機棧中的方法代碼一步一步的執行下去,在這一過程當中不可避免的會使用到線程共享的內存區域堆或方法區。爲了防止多個線程在同一時刻訪問同一個內存地址,須要互相告知本身的狀態以免資源爭奪。編程

線程的通訊方式主要分爲三種方式:①共享內存②消息傳遞③管道流數組

共享內存:線程之間經過對共享內存的讀-寫來實現隱式通訊。Java中的具體實現是:volatile共享內存。緩存

消息傳遞:線程之間經過明確的發送消息來實現顯示通訊。Java中的具體實現是:等待/通知機制(wait/notify),join方法。安全

管道流:管道輸入/輸出流。多線程

一、等待/通知機制

其過程是:線程A因爲某些緣由,自主調用了對象o的wait方法,進入WAITING狀態,釋放佔有的鎖並等待通知。而線程B則調用對象o的notify方法或notifyall方法進行通知,線程A會收到通知,並從wait方法中返回,繼續執行後面的代碼。併發

能夠發現,線程A和線程B就是經過對象o的wait方法和notify方法來發送消息,進行通訊。ide

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方法中返回,並繼續執行。

二、共享內存

有以下代碼,

/**
 * @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修飾的同步塊中,若是對一個共享變量進行操做,將會清空線程本地內存中此變量的值,並在使用這個共享變量前從新在主內存中讀取這個變量的值。而在同步塊執行完畢,釋放鎖資源時,則必須先把此共享變量同步回主內存中。

三、管道流

因爲還未學習使用到,先暫時略過。。。

以上內容爲本人在學習過程當中所作的筆記。參考的書籍、文章或博客以下:
[1]方騰飛,魏鵬,程曉明. Java併發編程的藝術[M].機械工業出版社.
[2]霍陸續,薛賓田. Java併發編程之美[M].電子工業出版社.
[3]Simen郎. 拜託,線程間的通訊真的很簡單.知乎.https://zhuanlan.zhihu.com/p/138689342
[4]極樂君.Java線程內存模型,線程、工做內存、主內存.知乎.https://zhuanlan.zhihu.com/p/25474331

相關文章
相關標籤/搜索