[Java併發-7]java的線程小節

在 Java 領域,實現併發程序的主要手段就是多線程。線程是操做系統裏的一個概念,雖然各類不一樣的開發語言如 Java、C# 等都對其進行了封裝,但原理和思路都是相同都。Java 語言裏的線程本質上就是操做系統的線程,它們是一一對應的。java

在操做系統層面,線程也有「生老病死」,專業的說法叫有生命週期。對於有生命週期的事物,要學好它,只要能搞懂生命週期中各個節點的狀態轉換機制就能夠了。算法

雖然不一樣的開發語言對於操做系統線程進行了不一樣的封裝,可是對於線程的生命週期這部分,基本上是雷同的。因此,咱們能夠先來了解一下通用的線程生命週期模型,而後再詳細的學習一下 Java 中線程的生命週期。編程

通用的線程生命週期

通用的線程生命週期基本上能夠用下圖這個「五態模型」來描述。這五態分別是:網絡

初始狀態、可運行狀態、運行狀態、休眠狀態 和 終止狀態

圖片描述
通用線程狀態轉換圖——五態模型多線程

  1. 初始狀態:指的是線程已經被建立,可是還不容許分配 CPU 執行。這個狀態屬於編程語言特有的,不過這裏所謂的被建立,僅僅是在編程語言層面被建立,而在操做系統層面,真正的線程尚未建立。
  2. 可運行狀態:指的是線程能夠分配 CPU 執行。在這種狀態下,真正的操做系統線程已經被成功建立了,因此能夠分配 CPU 執行。
  3. 運行狀態:當有空閒的 CPU 時,操做系統會將其分配給一個處於可運行狀態的線程,被分配到 CPU 的線程的狀態就轉換成了運行狀態
  4. 休眠狀態:運行狀態的線程若是調用一個阻塞的 API(例如以阻塞方式讀文件)或者等待某個事件(例如條件變量),那麼線程的狀態就會轉換到 休眠狀態,同時釋放 CPU 使用權,休眠狀態的線程永遠沒有機會得到 CPU 使用權。當等待的事件出現了,線程就會從休眠狀態轉換到可運行狀態。
  5. 終止狀態:線程執行完或者出現異常就會進入 終止狀態,終止狀態的線程不會切換到其餘任何狀態,進入終止狀態也就意味着線程的生命週期結束了。
這五種狀態在不一樣編程語言裏會有簡化合並或者被細化。

Java 中線程的生命週期

Java 語言中線程共有六種狀態,分別是:併發

  1. NEW(初始化狀態)
  2. RUNNABLE(可運行 / 運行狀態)
  3. BLOCKED(阻塞狀態)
  4. WAITING(無時限等待)
  5. TIMED_WAITING(有時限等待)
  6. TERMINATED(終止狀態)

在操做系統層面,Java 線程中的 BLOCKED、WAITING、TIMED_WAITING 是一種狀態,即前面咱們提到的休眠狀態。也就是說編程語言

只要 Java 線程處於這三種狀態之一,那麼這個線程就永遠沒有 CPU 的使用權。

因此 Java 線程的生命週期能夠簡化爲下圖:ide

圖片描述
Java 中的線程狀態轉換圖工具

其中,BLOCKED、WAITING、TIMED_WAITING 能夠理解爲線程致使休眠狀態的三種緣由。那具體是哪些情形會致使線程從 RUNNABLE 狀態轉換到這三種狀態呢?而這三種狀態又是什麼時候轉換回 RUNNABLE 的呢?以及 NEW、TERMINATED 和 RUNNABLE 狀態是如何轉換的?性能

1. RUNNABLE 與 BLOCKED 的狀態轉換

只有一種場景會觸發這種轉換,就是線程等待 synchronized 的隱式鎖。synchronized 修飾的方法、代碼塊同一時刻只容許一個線程執行,其餘線程只能等待,這種狀況下,等待的線程就會從 RUNNABLE 轉換到 BLOCKED 狀態。而當等待的線程得到 synchronized 隱式鎖時,就又會從 BLOCKED 轉換到 RUNNABLE 狀態。

若是你熟悉操做系統線程的生命週期的話,可能會有個疑問:線程調用阻塞式 API 時,是否會轉換到 BLOCKED 狀態呢?在操做系統層面,線程是會轉換到休眠狀態的,可是在 JVM 層面,Java 線程的狀態不會發生變化,也就是說 Java 線程的狀態會依然保持 RUNNABLE 狀態。

JVM 層面並不關心操做系統調度相關的狀態 ,由於在 JVM 看來,等待 CPU 使用權(操做系統層面此時處於可執行狀態)與等待 I/O(操做系統層面此時處於休眠狀態)沒有區別,都是在等待某個資源,因此都納入了 RUNNABLE 狀態。

而咱們平時所謂的 Java 在調用阻塞式 API 時,線程會阻塞,指的是操做系統線程的狀態,並非 Java 線程的狀態。

2. RUNNABLE 與 WAITING 的狀態轉換

有三種場景會觸發這種轉換。

第一種場景,得到 synchronized 隱式鎖的線程,調用無參數的 Object.wait() 方法。

第二種場景,調用無參數的 Thread.join() 方法。其中的 join() 是一種線程同步方法,例若有一個線程對象 thread A,當調用 A.join() 的時候,執行這條語句的線程會等待 thread A 執行完,而等待中的這個線程,其狀態會從 RUNNABLE 轉換到 WAITING。當線程 thread A 執行完,原來等待它的線程又會從 WAITING 狀態轉換到 RUNNABLE。

第三種場景,調用 LockSupport.park() 方法。其中的 LockSupport 對象,也許你有點陌生,其實 Java 併發包中的鎖,都是基於它實現的。調用 LockSupport.park() 方法,當前線程會阻塞,線程的狀態會從 RUNNABLE 轉換到 WAITING。調用 LockSupport.unpark(Thread thread) 可喚醒目標線程,目標線程的狀態又會從 WAITING 狀態轉換到 RUNNABLE。

3. RUNNABLE 與 TIMED_WAITING 的狀態轉換

有五種場景會觸發這種轉換:

  1. 調用帶超時參數的 Thread.sleep(long millis) 方法;
  2. 得到 synchronized 隱式鎖的線程,調用帶超時參數的 Object.wait(long timeout) 方法;
  3. 調用帶超時參數的 Thread.join(long millis) 方法;
  4. 調用帶超時參數的 LockSupport.parkNanos(Object blocker, long deadline) 方法;
  5. 調用帶超時參數的 LockSupport.parkUntil(long deadline) 方法。

TIMED_WAITING 和 WAITING 狀態的區別,僅僅是觸發條件多了 超時參數。

4. 從 NEW 到 RUNNABLE 狀態

Java 剛建立出來的 Thread 對象就是 NEW 狀態,而建立 Thread 對象主要有兩種方法。一種是繼承 Thread 對象,重寫 run() 方法。示例代碼以下:

// 自定義線程對象
class MyThread extends Thread {
  public void run() {
    // 線程須要執行的代碼
    ......
  }
}
// 建立線程對象
MyThread myThread = new MyThread();

另外一種是實現 Runnable 接口,重寫 run() 方法,並將該實現類做爲建立 Thread 對象的參數。示例代碼以下:

// 實現 Runnable 接口
class Runner implements Runnable {
  @Override
  public void run() {
    // 線程須要執行的代碼
    ......
  }
}
// 建立線程對象
Thread thread = new Thread(new Runner());

NEW 狀態的線程,不會被操做系統調度,所以不會執行。Java 線程要執行,就必須轉換到 RUNNABLE 狀態。從 NEW 狀態轉換到 RUNNABLE 狀態很簡單,只要調用線程對象的 start() 方法就能夠了,示例代碼以下:

MyThread myThread = new MyThread();
// 從 NEW 狀態轉換到 RUNNABLE 狀態
myThread.start();

5. 從 RUNNABLE 到 TERMINATED 狀態

線程執行完 run() 方法後,會自動轉換到 TERMINATED 狀態,固然若是執行 run() 方法的時候異常拋出,也會致使線程終止。有時候咱們須要強制中斷 run() 方法的執行,例如 run() 方法訪問一個很慢的網絡,咱們等不下去了,想終止怎麼辦呢?Java 的 Thread 類裏面卻是有個 stop() 方法,不過已經標記爲 @Deprecated,因此不建議使用了。正確的姿式實際上是調用 interrupt() 方法。

stop() 和 interrupt() 方法的主要區別?

stop() 方法會真的殺死線程,不給線程喘息的機會,若是線程持有 ReentrantLock 鎖,被 stop() 的線程並不會自動調用 ReentrantLock 的 unlock() 去釋放鎖,那其餘線程就再也沒機會得到 ReentrantLock 鎖。因此該方法就不建議使用了,相似的方法還有 suspend() 和 resume() 方法,這兩個方法一樣也都不建議使用。

而 interrupt() 方法僅僅是通知線程,線程有機會執行一些後續操做,同時也能夠無視這個通知。被 interrupt 的線程,是怎麼收到通知的呢?一種是異常,另外一種是主動檢測。

當線程 A 處於 WAITING、TIMED_WAITING 狀態時,若是其餘線程調用線程 A 的 interrupt() 方法,會使線程 A 返回到 RUNNABLE 狀態,同時線程 A 的代碼會觸發 InterruptedException 異常。上面咱們提到轉換到 WAITING、TIMED_WAITING 狀態的觸發條件,都是調用了相似 wait()、join()、sleep() 這樣的方法,咱們看這些方法的簽名,發現都會 throws InterruptedException 這個異常。這個異常的觸發條件就是:其餘線程調用了該線程的 interrupt() 方法。

當線程 A 處於 RUNNABLE 狀態時,而且阻塞在 java.nio.channels.InterruptibleChannel 上時,若是其餘線程調用線程 A 的 interrupt() 方法,線程 A 會觸發 java.nio.channels.ClosedByInterruptException 這個異常;而阻塞在 java.nio.channels.Selector 上時,若是其餘線程調用線程 A 的 interrupt() 方法,線程 A 的 java.nio.channels.Selector 會當即返回。

還有一種是主動檢測,若是線程處於 RUNNABLE 狀態,而且沒有阻塞在某個 I/O 操做上,例如中斷計算圓周率的線程 A,這時就得依賴線程 A 主動檢測中斷狀態了。若是其餘線程調用線程 A 的 interrupt() 方法,那麼線程 A 能夠經過 isInterrupted() 方法,檢測是否是本身被中斷了。

Java線程的生命週期小結

多線程程序很難調試,出了 Bug 基本上都是靠日誌,靠線程 dump 來跟蹤問題,分析線程 dump 的一個基本功就是分析線程狀態,大部分的死鎖、飢餓、活鎖問題都須要跟蹤分析線程的狀態。

經過 jstack 命令或者 Java VisualVM 這個可視化工具將 JVM 全部的線程棧信息導出來,完整的線程棧信息不只包括線程的當前狀態、調用棧,還包括了鎖的信息。導出線程棧,分析線程狀態是診斷併發問題的一個重要工具。

建立多少線程纔是合適的?

在 Java 領域,實現併發程序的主要手段就是多線程,使用多線程仍是比較簡單的,可是使用多少個線程倒是個困難的問題。工做中,常常有人問,「各類線程池的線程數量調整成多少是合適的?

要解決這個問題,首先要分析如下兩個問題:

  1. 爲何要使用多線程?
  2. 多線程的應用場景有哪些?

爲何使用多線程

使用多線程,本質上就是提高程序性能。不過此刻談到的性能,首要問題是:如何度量性能。

度量性能的指標有不少,可是有兩個指標是最核心的,它們就是延遲和吞吐量。
延遲指的是發出請求到收到響應這個過程的時間;延遲越短,意味着程序執行得越快,性能也就越好。
吞吐量指的是在單位時間內能處理請求的數量;吞吐量越大,意味着程序能處理的請求越多,性能也就越好。這兩個指標內部有必定的聯繫(同等條件下,延遲越短,吞吐量越大),可是因爲它們隸屬不一樣的維度(一個是時間維度,一個是空間維度),並不能互相轉換。

咱們所謂提高性能,從度量的角度,主要是下降延遲,提升吞吐量。這也是咱們使用多線程的主要目的。那咱們該怎麼下降延遲,提升吞吐量呢?這個就要從多線程的應用場景提及了。

多線程的應用場景

要想「下降延遲,提升吞吐量」,對應的方法呢,基本上有兩個方向,一個方向是優化算法,另外一個方向是 將硬件的性能發揮到極致。前者屬於算法範疇,後者則是和併發編程相關了。其實計算機主要有主要是兩類:一個是 I/O,一個是 CPU。簡言之,在併發編程領域,提高性能本質上就是提高 I/O 的利用率和 CPU 的利用率。單獨來看,操做系統已經爲咱們作了利用率的優化了,可是解決的是針對單一的硬件利用率。咱們的程序執行中是既要CPU也要I/O的。因此對於咱們開發者,咱們最終須要解決 CPU 和 I/O 設備綜合利用率的問題。

下面咱們用一個簡單的示例來講明:如何利用多線程來提高 CPU 和 I/O 設備的利用率?假設程序按照 CPU 計算和 I/O 操做交叉執行的方式運行,並且 CPU 計算和 I/O 操做的耗時是 1:1。

以下圖所示,若是隻有一個線程,執行 CPU 計算的時候,I/O 設備空閒;執行 I/O 操做的時候,CPU 空閒,因此 CPU 的利用率和 I/O 設備的利用率都是 50%。

圖片描述

若是有兩個線程,以下圖所示,當線程 A 執行 CPU 計算的時候,線程 B 執行 I/O 操做;當線程 A 執行 I/O 操做的時候,線程 B 執行 CPU 計算,這樣 CPU 的利用率和 I/O 設備的利用率就都達到了 100%。

圖片描述

經過上面的圖示,很容易看出:單位時間處理的請求數量翻了一番,也就是說吞吐量提升了 1 倍。此時能夠逆向思惟一下,若是 CPU 和 I/O 設備的利用率都很低,那麼能夠嘗試經過增長線程來提升吞吐量.

建立多少線程合適?

建立多少線程合適,要看多線程具體的應用場景。咱們的程序通常都是 CPU 計算和 I/O 操做交叉執行的,因爲 I/O 設備的速度相對於 CPU 來講都很慢,因此大部分狀況下,I/O 操做執行的時間相對於 CPU 計算來講都很是長,這種場景咱們通常都稱爲 I/O 密集型計算;和 I/O 密集型計算相對的就是 CPU 密集型計算了,CPU 密集型計算大部分場景下都是純 CPU 計算。I/O 密集型程序和 CPU 密集型程序,計算最佳線程數的方法是不一樣的。

對於 CPU 密集型計算,多線程本質上是提高多核 CPU 的利用率,因此對於一個 4 核的 CPU,每一個核一個線程,理論上建立 4 個線程就能夠了,再多建立線程也只是增長線程切換的成本。因此,

對於 CPU 密集型的計算場景,理論上「線程的數量 =CPU 核數」就是最合適的。不過在工程上,線程的數量通常會設置爲「CPU 核數 +1」

由於當線程由於偶爾的內存頁失效或其餘緣由致使阻塞時,這個額外的線程能夠頂上,從而保證 CPU 的利用率。

對於 I/O 密集型的計算場景,好比前面咱們的例子中,若是 CPU 計算和 I/O 操做的耗時是 1:1,那麼 2 個線程是最合適的。若是 CPU 計算和 I/O 操做的耗時是 1:2,那多少個線程合適呢?是 3 個線程,以下圖所示:CPU 在 A、B、C 三個線程之間切換,對於線程 A,當 CPU 從 B、C 切換回來時,線程 A 正好執行完 I/O 操做。這樣 CPU 和 I/O 設備的利用率都達到了 100%。

圖片描述

三線程執行示意圖

經過上面這個例子,咱們會發現,對於 I/O 密集型計算場景,最佳的線程數是與程序中 CPU 計算和 I/O 操做的耗時比相關的,咱們能夠總結出這樣一個公式:

最佳線程數 =1 +(I/O 耗時 / CPU 耗時)

咱們令 R=I/O 耗時 / CPU 耗時,綜合上圖,能夠這樣理解:當線程 A 執行 IO 操做時,另外 R 個線程正好執行完各自的 CPU 計算。這樣 CPU 的利用率就達到了 100%。

多核 CPU,只須要等比擴大就能夠了,計算公式以下:

最佳線程數 =CPU 核數 * [ 1 +(I/O 耗時 / CPU 耗時)]

線程多少的總結

對於 I/O 密集型計算場景,I/O 耗時和 CPU 耗時的比值是一個關鍵參數,不幸的是這個參數是未知的,並且是動態變化的,因此工程上,咱們要估算這個參數,而後作各類不一樣場景下的壓測來驗證咱們的估計。因此壓測時,咱們須要重點關注 CPU、I/O 設備的利用率和性能指標(響應時間、吞吐量)之間的關係。耗時的比值須要使用APM工具觀察得出。

相關文章
相關標籤/搜索