併發王者課-青銅8:分工協作-從本質認知執行緒的狀態和動作方法

秦二爺發表於2021-05-31

歡迎來到《併發王者課》,本文是該系列文章中的第8篇

在本篇文章中,我將從多執行緒的本質出發,為你介紹執行緒相關的狀態和它們的變遷方式,並幫助你掌握這塊知識點

一、多執行緒的本質是分工協作

如果你是王者的玩家,那麼你一定知道王者中的眾多英雄分為主要分為幾類,比如法師戰士坦克輔助等等。一些玩家對這些分類可能並不瞭解,甚至會覺得,幹嘛要搞得這麼複雜,幹不完了嘛。這...當然不可以

抱此想法的如果不是青銅玩家,想必就是戰場上的那些個人英雄主義玩家,在他們眼裡沒有團隊。然而,只有王者知道,比賽勝利的關鍵,在於團隊的分工協作各自為戰必將一團亂麻、潰不成軍,正所謂單絲不成線,獨木難成林

分工協作無處不在,峽谷中需要分工協作,現實中我們的工作更是社會化分工的結果,因為社會的本質就是分工協作

而我要告訴你的是,在併發程式設計裡,多執行緒的本質也是分工協作,每個執行緒恰似一個英雄,有著自己的職責、狀態和技能(動作方法)。所謂執行緒的狀態、方法實現不過都是為了完成執行緒間的分工協作。換句話說,執行緒狀態的存在不是目的,而是實現分工協作的方式。所以,理解執行緒的執行緒狀態和驅動方法,首先要理解它們為什麼而存在

IMG_5120

二、從協作認知執行緒的狀態

執行緒的狀態是執行緒在協作過程中的瞬時特徵。根據協作的需要,執行緒總共有六種狀態,分別是NEWRUNNABLEWAITINGTIMED_WAITINGBLOCKEDTERMINATED等。比如,我們建立一個英雄哪吒的執行緒neZhaPlayer

Thread neZhaPlayer = new Thread(new NeZhaRunnable());

那麼,執行緒建立之後,接下來它將在下圖所示的六種狀態中變遷。剛建立的執行緒處於NEW的狀態,而如果我們呼叫neZhaPlayer.start(),那它將會進入RUNNABLE狀態。

六種不同狀態的含義是這樣的:

  • NEW:執行緒新建但尚未啟動時所處的狀態,比如上面的neZhaPlayer
  • RUNNABLE:在 Java 虛擬機器中執行的執行緒所處狀態。需要注意的是,雖然執行緒當前正在被執行,但可能正在等待其他執行緒釋放資源;
  • WAITING無限期等待另一個執行緒執行特定操作來解除自己的等待狀態;
  • TIMED_WAITING限時等待另一個執行緒執行或自我解除等待狀態;
  • BLOCKED被阻塞等待其他執行緒釋放Monitor Lock;
  • TERMINATED:執行緒執行結束。

在任意特定時刻,一個執行緒都只能處於上述六種狀態中的一種。需要你注意的是RUNNABLE這個狀態,它有些特殊。確切地說,它包含READYRUNNING兩個細分狀態,下一章節的圖示中有明確標示。

另外,前面我們已經介紹過Thread類,對於執行緒各狀態的表述,你可以直接閱讀JDK中的Thread.State列舉,並可以通過Thread.getState()檢視當前執行緒的瞬時狀態。

三、從執行緒狀態變遷看背後的方法驅動

和人類的交流類似,在多執行緒的協作時,它們也需要交流。所以,執行緒狀態的變遷需就要不同的方法來實現交流,比如剛建立的執行緒需要通過呼叫start()將執行緒狀態由NEW變遷為RUNNABLE

下圖所展示的正是執行緒間的狀態變遷以及相關的驅動方法,你可以先大概瀏覽一遍,隨後再結合下文的各關鍵方法的表述深入理解。

需要注意的是,本文不會詳細介紹執行緒狀態相關的所有方法,這既不現實也毫無必要。上面這幅寶藏圖示是理解本文所述知識的核心,下面所介紹的幾個主要方法也並非為了你記憶,而是為了讓你更好理解上面這幅圖

在你理解了這幅寶圖之後,你便可以完全自行去了解其他更多的方法。

1. start:對戰開始,敵軍還有5秒到達戰場

public class NeZhaRunnable implements Runnable {
    public void run() {
        System.out.println("我是哪吒,我去上路");
    }
}

Thread neZhaPlayer = new Thread(new NeZhaRunnable());
neZhaPlayer.start();

start()方法主要將完成執行緒狀態從NEWRUNNABLE的變遷,這裡有兩個點:

  • 建立新的執行緒;
  • 由新的執行緒執行其中的run()方法。

需要注意的是,你不可以重複呼叫start()方法,否則會丟擲IllegalThreadStateException異常。

2. wait和notify:我在等你,好了請告訴我

哪吒每次在使用完大招後,都需要經歷幾十秒的冷卻時間才可以再次使用,接下來我們通過程式碼片段來模擬這個過程。

我們先定義一個Player類,這個類中包含了fight()refreshSkills()兩個方法,分別用於進攻和技能重新整理,程式碼片段如下。

public class Player {
    public void fight() {
        System.out.println("大招未就緒,冷卻中...");
        synchronized (this) {
            try {
                this.wait();
                System.out.println("大招已就緒,發起進攻!");
            } catch (InterruptedException e) {
                System.out.println("大招冷卻被中斷!");
            }
        }
    }
    public void refreshSkills() {
        System.out.println("技能重新整理中...");
        synchronized (this) {
            this.notifyAll();
            System.out.println("技能已重新整理!");
        }
    }
}

隨後,我們寫一段main()方法使用剛才建立的Player。注意,這裡我們建立了兩個執行緒分別呼叫Player中的不同方法

public static void main(String[] args) throws InterruptedException {
        final Player neZha = new Player();
        Thread neZhaFightThread = new Thread() {
            public void run() {
                neZha.fight();
            }
        };
        Thread skillRefreshThread = new Thread() {
            public void run() {
                neZha.refreshSkills();
            }
        };
        neZhaFightThread.start();
        skillRefreshThread.start();
    }

程式碼執行結果如下:

大招未就緒,冷卻中...
技能重新整理中...
技能已重新整理!
大招已就緒,發起進攻!

Process finished with exit code 0

從執行的結果看,符合預期。相信你已經看到了,在上面的程式碼中我們使用了wait()notify()兩個函式。這兩個執行緒是如何協作的呢?往下看。

首先,neZhaAttachThread呼叫了neZha.fight()這個方法。可是,當哪吒想發起進攻的時候,竟然大招還沒有冷卻!於是,這個執行緒不得不通過wait()方法進入等待佇列

緊接著,skillRefreshThread呼叫了neZha.refreshSkills()這個方法。並且,在執行結束後又呼叫了notify()方法。有趣的事情發生了,前面處於等待佇列中的neZhaAttachThread竟然又“復活”了,並且大喊了一聲:大招已經就緒,發起進攻!

這是怎麼回事?理解這塊邏輯,你需要了解以下幾個知識點:

  • wait():看到wait()時,你可以簡單粗暴地認為每個物件都有一個類似於休息室的等待佇列,而wait()正是把當前執行緒送進了等待佇列並暫停繼續執行;
  • notify():如果說wait()是把當前執行緒送進了等待佇列,那麼notify()則是從等待佇列中取出執行緒。此外,和notify()具有相似功能的還有個notifyAll()。與notify()不同的是,notifyAll()會取出等待佇列中的所有執行緒;

看到這,你是不是覺得wait()notify()簡直是完美的一對?其實不然。真相不僅不完美,還很不靠譜!

wait()notify()在執行時都必須先獲得鎖,這也是你在程式碼中看到synchronized的原因。notify()在釋放鎖的時候,會從等待佇列中取出執行緒,此時的執行緒必須獲得鎖之後才能繼續執行。那麼,問題來了。如果佇列中有多個執行緒時,notify()能取出指定的執行緒嗎?答案是不能!

換句話說,如果佇列中有多個執行緒,你將無法預料後續的執行結果!notifyAll()雖然可以取出所有的執行緒,但最終也只能有一個執行緒能獲得鎖。

是不是有點懵?懵就對了。所以你看,wait()notify()是不是很不靠譜?因此,如果你需要在專案程式碼中使用它們,請務必要小心謹慎!

此外,如果你閱讀過《Effective Java》,可以看到在這本書裡作者Josh Bloch也是強烈建議不要隨便使用這對組合。因為它們就像Java中的“組合語言”,確實複雜且不容易控制,如果有相似的併發場景需要處理,可以考慮使用Java中的其他高階的併發工具。

3. interrupt:做完這一單,我就退隱江湖

在王者的遊戲中,如果英雄血量沒了,可以回城補血。回城大概需要5秒左右,如果在回城的過程中,突然被攻擊或需要移位,那麼回城就會中斷。接下來,下面我們看看怎麼模擬回城中的中斷。

現在Player中定義backHome()方法用於回城。

 public void backHome() {
        System.out.println("回城中...");
        synchronized (this) {
            try {
                this.wait();
                System.out.println("已回城");
            } catch (InterruptedException e) {
                System.out.println("回城被中斷!");
            }
        }
    }

接下來啟動新的執行緒呼叫backHome()回城補血。

public static void main(String[] args) throws InterruptedException {
        final Player neZha = new Player();
        Thread neZhaBackHomeThread = new Thread() {
            public void run() {
                neZha.backHome();
            }
        };
        neZhaBackHomeThread.start();
        neZhaBackHomeThread.interrupt();
    }

執行結果如下:

回城中...
回城被中斷!

Process finished with exit code 0

可以看到,回城被中斷了,因為我們呼叫了interrupt()方法!那麼,線上程中的中斷是怎麼回事?往下看。

在Thread中,我們可以通過interrupt()中斷執行緒。然而,如果你細心的話,還會發現Thread中除了interrupt()方法之外,竟然還有兩個長相酷似的方法:interrupted()isInterrupted()。這就要小心了。

  • interrupt():將執行緒設定為中斷狀態;
  • interrupted():取消執行緒的中斷狀態;
  • isInterrupted():判斷執行緒是否處於中斷狀態,而不會變更執行緒狀態。

不得不說,interrupt()interrupted()這兩個方法的命名實在糟糕,你在編碼時可不要學習它,方法的名字應該清晰明瞭表達出其意圖

那麼,當我們呼叫interrupt()時,所呼叫物件的執行緒會立即丟擲InterruptedException異常嗎?其實不然,這裡容易產生誤解

interrupt()方法只是改變了執行緒中的中斷狀態而已,並不會直接丟擲中斷異常。中斷異常的丟擲必須是當前執行緒在執行wait()sleep()join()時才會丟擲。換句話說,如果當前執行緒正在處理其他的邏輯運算,不會被中斷,直到下次執行wait()sleep()join()

4. join:稍等,等我結束你再開始

在前面的示例中,哪吒發起進攻和技能重新整理兩個執行緒是同時開始的。然而,我們在前面已經說了wait()notify()並不靠譜,所以我們想在技能重新整理結束後再執行後續動作。

public static void main(String[] args) throws InterruptedException {
        final Player neZha = new Player();
        Thread neZhaFightThread = new Thread() {
            public void run() {
                neZha.fight();
            }
        };
        Thread skillRefreshThread = new Thread() {
            public void run() {
                neZha.refreshSkills();
            }
        };
       
        skillRefreshThread.start();
        skillRefreshThread.join(); //這裡是重點
        neZhaFightThread.start();
    }

主執行緒呼叫join()時,會阻塞當前執行緒繼續執行,直到目標執行緒中的任務執行完畢。此外,在呼叫join()方法時,也可以設定超時時間。

小結

以上就是關於執行緒狀態及變遷的全部內容。在本文中,我們介紹了多執行緒的本質是協作,而狀態和動作方法是實現協作的方式。無論是面試還是其他的資料中,執行緒的狀態方法都是重點。然而,我希望你明白了的是,對於本文知識點的掌握,不要從靜態的角度死記硬背,而是要動靜結合,從動態的方法認知靜態的狀態

正文到此結束,恭喜你又上了一顆星✨

夫子的試煉

在本文中,我們並沒有提到yield()Thread.sleep()Thread.current()等方法。不過,如果你感興趣的話,不妨檢索資料:

  • 瞭解yield()並對比它和join()的不同;
  • 瞭解wait()並對比它和Thread.sleep()的不同;
  • 瞭解Thread.current()的主要用法和它的實現。

延伸閱讀


關於作者


關注公眾號【庸人技術笑談】,獲取及時文章更新。記錄平凡人的技術故事,分享有品質(儘量)的技術文章,偶爾也聊聊生活和理想。不販賣焦慮,不做標題黨。

如果本文對你有幫助,歡迎點贊關注監督,我們一起從青銅到王者

相關文章