Java 線程的狀態及轉換

閃客:小宇你怎麼了,我看你臉色很不好呀。

小宇:今天去面試了,面試官問我 Java 線程的狀態及其轉化。

閃客:哦哦,很常見的面試題呀,不是有一張狀態流轉圖嘛。

小宇:我知道,可是我每次面試的時候,腦子裏記過的流轉圖就變成這樣了。

閃客:哈哈哈。

小宇:你還笑,氣死我了,你能不能給我講講這些亂七八糟的狀態呀。

閃客:沒問題,還是老規矩,你先把所有狀態都忘掉,聽我從頭道來!

小宇:好滴。

線程狀態的實質

===============

首先你得明白,當我們說一個線程的狀態時,說的是什麼?

沒錯,就是一個變量的值而已。

哪個變量?

Thread 類中的一個變量,叫

private volatile int threadStatus = 0;

這個值是個整數,不方便理解,可以通過映射關係(VM.toThreadState),轉換成一個枚舉類。

public enum State {

    NEW,

    RUNNABLE,

    BLOCKED,

    WAITING,

    TIMED_WAITING,

    TERMINATED;

}

所以,我們就盯着 threadStatus 這個值的變化就好了。

就是這麼簡單。

NEW

===========

現在我們還沒有任何 Thread 類的對象呢,也就不存在線程狀態一說。

一切的起點,要從把一個 Thread 類的對象創建出來,開始說起。

Thread t = new Thread();

當然,你後面可以接很多參數。

Thread t = new Thread(r, "name1");

你也可以 new 一個繼承了 Thread 類的子類。

Thread t = new MyThread();

你說線程池怎麼不 new 就可以有線程了呢?人家內部也是 new 出來的。

public class Executors {
  static class DefaultThreadFactory implements ThreadFactory {
  public Thread newThread(Runnable r) {
          Thread t = new Thread(...);
          ...
          return t;
      }
  }
}

總是,一切的開始,都要調用 Thread 類的構造方法。

而這個構造方法,最終都會調用 Thread 類的 init() 方法。

private void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc, boolean inheritThreadLocals) {
  ...
  this.grout = g;
  this.name = name;
  ...
  tid = nextThreadID();
}

這個 init 方法,僅僅是給該 Thread 類的對象中的屬性,附上值,除此之外啥也沒幹。

它沒有給 theadStatus 再次賦值,所以它的值仍然是其默認值。

而這個值對應的狀態,就是 STATE.NEW,非要翻譯成中文,就叫初始態吧。

因此說了這麼多,其實就分析出了,新建一個 Thread 類的對象,就是創建了一個新的線程,此時這個線程的狀態,是 NEW(初始態)

之後的分析,將弱化 threadStatus 這個整數值了,就直接說改變了其線程狀態,大家知道其實就只是改變了 threadStatus 的值而已。

RUNNABLE

================

你說,剛剛處於 NEW 狀態的線程,對應操作系統裏的什麼狀態呢?

一看你就沒仔細看我上面的分析。

Thread t = new Thread();

只是做了些表面功夫,在 Java 語言層面將自己的一個對象中的屬性附上值罷了,根本沒碰到操作系統級別的東西呢。

所以這個 NEW 狀態,不論往深了說還是往淺了說,還真就只是個無聊的枚舉值而已。

下面,精彩的故事纔剛剛開始。

躺在堆內存中無所事事的 Thread 對象,在調用了 start() 方法後,才顯現生機。

t.start();

這個方法一調用,那可不得了,最終會調用到一個討厭的 native 方法裏。

private native void start0();

看來改變狀態就並不是一句 threadStatus = xxx 這麼簡單了,而是有本地方法對其進行了修改。

九曲十八彎跟進 jvm 源碼之後,調用到了這個方法。

hotspot/src/os/linux/vm/os_linux.cpppthread_create(...);

大名鼎鼎的 unix 創建線程的方法,pthread_create。

此時,在操作系統內核中,纔有了一個真正的線程,被創建出來。

而 linux 操作系統,是沒有所謂的剛創建但沒啓動的線程這種說法的,創建即刻開始運行。

雖然無法從源碼發現線程狀態的變化,但通過 debug 的方式,我們看到調用了 Thread.start() 方法後,線程的狀態變成了 RUNNABLE,運行態。

那我們的狀態圖又豐富了起來。

通過這部分,我們知道如下幾點:

  1. 在 Java 調用 start() 後,操作系統中才真正出現了一個線程,並且立刻運行。

  2. Java 中的線程,和操作系統內核中的線程,是一對一的關係。

  3. 調用 start 後,線程狀態變爲 RUNNABLE,這是由 native 方法裏的某部分代碼造成的。

RUNNING 和 READY

=======================

CPU 一個核心,同一時刻,只能運行一個線程。

具體執行哪個線程,要看操作系統 的調度機制。

所以,上面的 RUNNABLE 狀態,準確說是,得到了可以隨時準備運行的機會的狀態。

而處於這個狀態中的線程,也分爲了正在 CPU 中運行的線程,和一堆處於就緒中等待 CPU 分配時間片來運行的線程。

處於就緒中的線程,會存儲在一個就緒隊列中,等待着被操作系統的調度機制選到,進入 CPU 中運行。

當然,要注意,這裏的 RUNNING 和 READY 狀態,是我們自己爲了方便描述而造出來的。

無論是 Java 語言,還是操作系統,都不區分這兩種狀態,在 Java 中統統叫 RUNNABLE。

TERMINATED

==================

當一個線程執行完畢(或者調用已經不建議的 stop 方法),線程的狀態就變爲 TERMINATED。

此時這個線程已經無法死灰復燃了,如果你此時再強行執行 start 方法,將會報出錯誤。

java.lang.IllegalThreadStateException

很簡單,因爲 start 方法的第一行就是這麼直戳了當地寫的。

public synchronized void start() {
    if (threadStatus != 0)
        throw new IllegalThreadStateException();
    ...
}

誒,那如果此時強行把 threadStatus 改成 0,會怎麼樣呢?你可以試試喲。

BLOCKED

===============

上面把最常見,最簡單的線程生命週期講完了。

初始 -- 運行 -- 終止

沒有發生任何的障礙。

接下來,就稍稍複雜一點了,我們讓線程碰到些障礙。

首先創建一個對象 lock。

public static final Object lock = new Object();

一個線程,執行一個 sychronized 塊,鎖對象是 lock,且一直持有這把鎖不放。

new Thread(() -> {
    synchronized (lock) {
        while(true) {}
    }
}).start();

另一個線程,也同樣執行一個鎖對象爲 lock 的 sychronized 塊。

new Thread(() -> {
    synchronized (lock) {
       ...
    }
}).start();

那麼,在進入 synchronized 塊時,因爲無法拿到鎖,會使線程狀態變爲 BLOCKED

同樣,對於 synchronized 方法,也是如此。

當該線程獲取到了鎖後,便可以進入 synchronized 塊,此時線程狀態變爲 RUNNABLE。

因此我們得出如下轉換關係。

當然,這只是線程狀態的改變,線程還發生了一些實質性的變化。

我們不考慮虛擬機對 synchronized 的極致優化。

當進入 synchronized 塊或方法,獲取不到鎖時,線程會進入一個該鎖對象的同步隊列

當持有鎖的這個線程,釋放了鎖之後,會喚醒該鎖對象同步隊列中的所有線程,這些線程會繼續嘗試搶鎖。如此往復。

比如,有一個鎖對象 A,線程 1 此時持有這把鎖。線程 2、3、4 分別嘗試搶這把鎖失敗。

線程 1 釋放鎖,線程 2、3、4 重新變爲 RUNNABLE,繼續搶鎖,假如此時線程 3 搶到了鎖。

如此往復。

WAITING

===============

這部分是最複雜的,同時也是面試中考點最多的,將分成三部分講解。聽我說完後你會發現,這三部分有很多相同但地方,不再是孤立的知識點。

wait/notify


我們在剛剛的 synchronized 塊中加點東西。

new Thread(() -> {
    synchronized (lock) {
       ...
       lock.wait();
       ...
    }
}).start();

當這個 lock.wait() 方法一調用,會發生三件事。

  1. 釋放鎖對象 lock(隱含着必須先獲取到這個鎖纔行)

  2. 線程狀態變成 WAITING

  3. 線程進入 lock 對象的等待隊列

什麼時候這個線程被喚醒,從等待隊列中移出,並從 WAITING 狀態返回 RUNNABLE 狀態呢?

必須由另一個線程,調用同一個對象的 notify/notifyAll 方法。

new Thread(() -> {
    synchronized (lock) {
       ...
       lock.notify(); 
       ...
    }
}).start();

只不過 notify 是隻喚醒一個線程,而 notifyAll 是喚醒所有等待隊列中的線程。

但需要注意,被喚醒後的線程,從等待隊列移出,狀態變爲 RUNNABLE,但仍然需要搶鎖,搶鎖成功了,纔可以從 wait 方法返回,繼續執行。

如果失敗了,就和上一部分的 BLOCKED 流程一樣了。

所以我們的整個流程圖,現在變成了這個樣子。

join


主線程這樣寫。

public static void main(String[] args) {
  thread t = new Thread(...);
  t.start();
  t.join();
  ...
}

當執行到 t.join() 的時候,主線程會變成 WAITING 狀態,直到線程 t 執行完畢,主線程纔會變回 RUNNABLE 狀態,繼續往下執行。

看起來,就像是主線程執行過程中,另一個線程插隊加入(join),而且要等到其結束後主線程才繼續。

因此我們的狀態圖,又多了兩項。

那 join 又是怎麼神奇地實現這一切呢?也是像 wait 一樣放到等待隊列麼?

打開 Thread.join() 的源碼,你會發現它非常簡單。

// Thread.java
// 無參的 join 有用的信息就這些,省略了額外分支
public synchronized void join() {
  while (isAlive()) {
      wait();
  }
}

也就是說,他的本質仍然是執行了 wait() 方法,而鎖對象就是 Thread t 對象本身。

那從 RUNNABLE 到 WAITING,就和執行了 wait() 方法完全一樣了。

那從 WAITING 回到 RUNNABLE 是怎麼實現的呢?

主線程調用了 wait ,需要另一個線程 notify 纔行,難道需要這個子線程 t 在結束之前,調用一下 t.notifyAll() 麼?

答案是否定的,那就只有一種可能,線程 t 結束後,由 jvm 自動調用 t.notifyAll(),不用我們程序顯示寫出。

沒錯,就是這樣。

怎麼證明這一點呢?道聽途說可不行,老子今天非要扒開 jvm 的外套。

果然,找到了如下代碼。

我們看到,虛擬機在一個線程的方法執行完畢後,執行了個 ensure_join 方法,看名字就知道是專門爲 join 而設計的。

而繼續跟進會發現一段關鍵代碼,lock.notify_all,這便是一個線程結束後,會自動調用自己的 notifyAll 方法的證明。

所以,其實 join 就是 wait,線程結束就是 notifyAll。現在,是不是更清晰了。

park/unpark


有了上面 wait 和 notify 的機制,下面就好理解了。

一個線程調用如下方法。

LockSupport.park()

該線程狀態會從 RUNNABLE 變成 WAITING、

另一個線程調用

LockSupport.unpark(Thread 剛剛的線程)

剛剛的線程會從 WAITING 回到 RUNNABLE

但從線程狀態流轉來看,與 wait 和 notify 相同。

從實現機制上看,他們甚至更爲簡單。

  1. park 和 unpark 無需事先獲取鎖,或者說跟鎖壓根無關。

  2. 沒有什麼等待隊列一說,unpark 會精準喚醒某一個確定的線程。

  3. park 和 unpark 沒有順序要求,可以先調用 unpark

關於第三點,就涉及到 park 的原理了,這裏我只簡單說明。

線程有一個計數器,初始值爲 0

調用 park 就是

如果這個值爲 0,就將線程掛起,狀態改爲 WAITING。如果這個值爲 1,則將這個值改爲 0,其餘的什麼都不做。

調用 unpark 就是

將這個值改爲 1

然後我用三個例子,你就基本明白了。

// 例子1
LockSupport.unpark(Thread.currentThread()); // 1
LockSupport.park(); // 0
System.out.println("可以運行到這");
// 例子2
LockSupport.unpark(Thread.currentThread()); // 1
LockSupport.unpark(Thread.currentThread()); // 1
LockSupport.park(); // 0
System.out.println("可以運行到這");
// 例子3
LockSupport.unpark(Thread.currentThread()); // 1
LockSupport.unpark(Thread.currentThread()); // 1
LockSupport.park(); // 0
LockSupport.park(); // WAITING
System.out.println("不可以運行到這");

park 的使用非常簡單,同時也是 JDK 中鎖實現的底層。它的 JVM 及操作系統層面的原理很複雜,改天可以專門找一節來講解。

現在我們的狀態圖,又可以更新了。

TIMED_WAITING

這部分就再簡單不過了,將上面導致線程變成 WAITING 狀態的那些方法,都增加一個超時參數,就變成了將線程變成 TIMED_WAITING 狀態的方法了,我們直接更新流程圖。

這些方法的唯一區別就是,從 TIMED_WAITING 返回 RUNNABLE,不但可以通過之前的方式,還可以通過到了超時時間,返回 RUNNABLE 狀態。

就這樣。

還有,大家看。

wait 需要先獲取鎖,再釋放鎖,然後等待被 notify。

join 就是 wait 的封裝。

park 需要等待 unpark 來喚醒,或者提前被 unpark 發放了喚醒許可。

那有沒有一個方法,僅僅讓線程掛起,只能通過等待超時時間到了再被喚醒呢。

這個方法就是

Thread.sleep(long)

我們把它補充在圖裏,這一部分就全了。

再把它加到全局圖中。

後記

====================

Java 線程的狀態,有六種

NEW

RUNNABLE

BLOCKED

WAITING

TIMED_WAITING

TERMINATED

而經典的線程五態模型,有五種狀態

創建

就緒

執行

阻塞

終止

不同實現者,可能有合併和拆分。

比如 Java 將五態模型中的就緒和執行,都統一成 RUNNABLE,將阻塞(即不可能得到 CPU 運行機會的狀態)細分爲了 BLOCKED、WAITING、TIMED_WAITING,這裏我們不去評價好壞。

也就是說,BLOCKED、WAITING、TIMED_WAITING 這幾個狀態,線程都不可能得到 CPU 的運行權,你叫它掛起、阻塞、睡眠、等待,都可以,很多文章,你也會看到這幾個詞沒那麼較真地來回用。

再說兩個你可能困惑的問題。

調用 jdk 的 Lock 接口中的 lock,如果獲取不到鎖,線程將掛起,此時線程的狀態是什麼呢?

有多少同學覺得應該和 synchronized 獲取不到鎖的效果一樣,是變成 BLOCKED 狀態?

不過如果你仔細看我上面的文章,有一句話提到了,jdk 中鎖的實現,是基於 AQS 的,而 AQS 的底層,是用 park 和 unpark 來掛起和喚醒線程,所以應該是變爲 WAITING 或 TIMED_WAITING 狀態。

調用阻塞 IO 方法,線程變成什麼狀態?

比如 socket 編程時,調用如 accept(),read() 這種阻塞方法時,線程處於什麼狀態呢?

答案是處於 RUNNABLE 狀態,但實際上這個線程是得不到運行權的,因爲在操作系統層面處於阻塞態,需要等到 IO 就緒,才能變爲就緒態。

但是在 Java 層面,JVM 認爲等待 IO 與等待 CPU 執行權,都是一樣的,人家就是這麼認爲的,這裏我仍然不討論其好壞,你覺得這麼認爲不爽,可以自己設計一門語言,那你想怎麼認爲,別人也拿你沒辦法。

比如要我設計語言,我就認爲可被 CPU 調度執行的線程,處於死亡態。這樣我的這門語言一定會有個經典面試題,爲什麼閃客把可運行的線程定義爲死亡態呢?

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/kjeWEXJfDz5pM3HLhYs9Xw