天天看點

了解 java 線程

作者:粥屋與包屋

什麼是線程?

thread 是運作程序中獨立的操作序列。任何程序都可以有多個并發運作的線程,使你的應用程式能夠并行解決多個任務。線程是語言處理并發性的重要組成部分。

我喜歡将多線程應用程式可視化為一組序列時間線,如圖 1 所示。注意,應用程式以一個線程(主線程)開始。該線程啟動其他線程,這些線程可以啟動其他線程,依此類推。記住,每個線程都是獨立于其他線程的。例如,主線程可以在應用程式本身之前就結束執行。當所有線程停止時,程序停止。

了解 java 線程

圖 1

一個給定的線程上的指令總是以定義的順序進行。如果指令A在同一線程的指令B之前,你總是知道A會在B之前發生。但是,由于兩個線程是互相獨立的,你不能對兩個指令A和B說同樣的話,每個指令都在一個單獨的線程上。在這種情況下,要麼A可以在B之前執行,要麼反之亦然(圖 2)。有時我們會說一種情況比另一種情況更有可能,但我們無法知道一個流程會如何持續執行。

了解 java 線程

圖 2

在很多情況下,你會看到線程執行被工具直覺地表示為序列時間線。圖 3 顯示了 VisualVM(我們在後續文章中使用的 profiler 工具)以序列時間線的方式呈現線程執行。

了解 java 線程

圖 3

線程的生命周期

可視化線程執行之後,了解線程執行的另一個重要方面是了解線程生命周期。在整個執行過程中,線程會經曆多個狀态(圖 4)。當使用 profiler 或 thread dump 時,我們經常會引用線程的狀态,這在試圖弄清楚執行時很重要。了解線程如何從一種狀态轉換到另一種狀态,以及線程在每種狀态下的行為對于跟蹤和研究應用程式的行為至關重要。

圖 4 直覺地展示了線程狀态以及線程如何從一種狀态轉換到另一種狀态。我們可以确定Java線程的以下主要狀态:

  • New — 線程在執行個體化之後(啟動之前)就處于這種狀态。在這種狀态下,線程是一個簡單的 Java 對象。應用程式還不能執行它定義的指令。
  • Runnable — 線程在 start() 方法被調用後就處于這個狀态。在這種狀态下,JVM 可以執行線程定義的指令。在這種狀态下,JVM 會逐漸地在兩個子狀态之間移動線程:
    • Ready — 線程不會執行,但 JVM 可以随時讓它執行。
    • Running — 線程正在執行。CPU 目前執行它定義的指令。
  • Blocked — 線程啟動了,但暫時脫離了可運作狀态,是以 JVM 無法執行其指令。這種狀态可以讓JVM 暫時“隐藏”線程,讓 JVM 無法執行線程,進而幫助我們控制線程的執行。阻塞時,線程可能處于下列子狀态之一:
    • Monitored — 線程被同步塊(控制對同步塊通路的對象)的螢幕暫停,并等待被釋放以執行該塊。
    • Waiting — 在執行過程中,調用了螢幕的 wait() 方法,這将導緻目前線程暫停。線程将保持阻塞,直到調用 notify() 或 notifyAll() 方法允許 JVM 釋放正在執行的線程。
    • Sleeping — 調用 Thread 類中的 sleep() 方法,暫停目前線程一段指定的時間。時間作為參數傳遞給 sleep() 方法。這段時間過後,線程就可以運作了。
    • Parked — 幾乎和等待一樣,線程會在有人調用 park() 方法後顯示為停止,這會阻塞目前線程,直到調用 unpark() 方法。
  • Dead — 一個線程在完成它的指令集後就會死亡或終止,一個錯誤或異常使它暫停,或者它被另一個線程中斷。線程一旦死亡,就不能重新啟動。
了解 java 線程

圖 4

圖 4 還顯示了線程狀态之間可能的轉換:

  • 一旦有人調用線程的 start() 方法,線程就會從 new 變為runnable。
  • 一旦線程處于 runnable 狀态,它就會在就緒狀态和運作狀态之間搖擺不定。JVM 決定執行哪個線程以及何時執行。
  • 有時,線程會阻塞。它可以通過幾種方式進入阻塞狀态:
    • 調用 Thread 類中的 sleep() 方法,使目前線程處于臨時阻塞狀态。
    • 有人調用了 join() 方法,導緻目前線程等待另一個線程。
    • 有人調用了螢幕的 wait() 方法,暫停目前線程的執行,直到調用 notify() 或 notifyAll() 方法。
    • 同步塊的螢幕暫停一個線程的執行,直到另一個活動線程完成同步塊的執行。
  • 線程在結束執行或被另一個線程中斷時可能會進入死亡(終止)狀态。JVM 認為從阻塞狀态到死亡狀态的轉換是不可接受的。如果一個被阻塞的線程被另一個線程中斷,則轉換過程會抛出 InterruptedException 異常。

同步線程

開發人員使用這些方法在多線程架構中控制線程。不正确的同步也是許多問題的根本原因,您必須調查和解決這些問題。我們将概述同步線程最常用的方法。

1 Synchronized 塊

同步線程的最簡單方法是使用同步代碼塊,這通常是Java開發人員學習同步線程的第一個概念。其目的是通過同步代碼在同一時間隻允許一個線程——禁止對給定的代碼段進行并發執行。有兩種選擇:

  • 塊同步— 在給定的代碼塊上應用 synchronized 修飾符
  • 方法同步— 在方法上應用 synchronized 修飾符

下面的代碼片段是一個同步塊的例子:

synchronized (a) { // 括号之間的對象是同步塊的螢幕。
    // 同步指令塊定義在花括号之間。
    // do something
}           

下面的代碼片段展示了方法同步:

// 應用于方法的 synchronized 修飾符
synchronized void m() {
    // 花括号之間定義方法的整個代碼塊是同步的。
    // do something
}           

使用 synchronized 關鍵字的兩種方式工作起來是一樣的,盡管它們看起來有點不同。你會發現每個同步塊有兩個重要的組成部分:

  • 螢幕— 管理同步指令執行的對象
  • 指令塊 — 實際的指令,是同步的

方法同步似乎缺少螢幕,但對于這種文法,螢幕實際上是隐含的。對于非靜态方法,執行個體 “this” 将被用作螢幕,而對于靜态方法,同步塊将使用類的類型執行個體。

螢幕(不能為 null)是對同步塊有意義的對象。該對象決定線程是否可以進入并執行同步指令。從技術上講,這個規則很簡單:一旦線程進入同步塊,它就會獲得螢幕上的一個鎖。在擁有鎖的線程釋放它之前,同步塊中不會接受其他線程。為了簡化問題,我們假設線程隻有在退出同步塊時才釋放鎖。圖 5 展示了一個可視化的例子。想象一下,兩個同步的代碼塊位于應用程式的不同部分,但由于它們都使用相同的螢幕 M1(相同的對象執行個體),一個線程一次隻能在其中一個代碼塊中執行。沒有一條指令A、B或C會被并發調用(至少不會從目前 synchronized 塊中調用)。

了解 java 線程

圖 5

然而,應用程式可能定義多個同步塊。螢幕連結了多個同步塊,但當兩個同步塊使用兩個不同的螢幕時(圖 6),這些塊是不同步的。在圖 6 中,第一個和第二個同步塊也彼此同步,因為它們使用了相同的螢幕。但這兩個塊并沒有與第三個塊同步。其結果是,定義在第三個同步塊中的指令D可以與前兩個同步塊中的任何指令并發執行。

了解 java 線程

圖 6

在使用 profiler 或 thread dump 等工具調查問題時,您需要了解線程被阻塞的方式。這些資訊可以說明發生了什麼、為什麼或者是什麼導緻給定的線程沒有執行。圖 7 展示了 VisualVM 如何監控一個同步塊阻塞了一個線程。

了解 java 線程

圖 7

2 使用 wait(), notify(), 和 notifyAll()

阻塞線程的另一種方式是要求它等待一個未定義的時間。使用同步塊螢幕的 wait() 方法,可以訓示線程無限期地等待。其他線程可以 “告訴” 等待它繼續工作的線程。你可以使用螢幕的 notify() 或 notifyAll() 方法來做到這一點。這些方法通常用于提高應用程式的性能,防止沒有意義的線程執行。與此同時,錯誤地使用這些方法可能會導緻死鎖,或者線程無限期地等待,而從未被釋放執行。

請記住,wait()、notify() 和 notifyAll() 隻有在同步塊中使用時才有意義。這些方法是同步塊的螢幕的行為,是以你不能在沒有螢幕的情況下使用它們。使用 wait() 方法,螢幕會在未定義的時間内阻塞線程。當阻塞線程時,它也釋放它獲得的鎖,以便其他線程可以進入該螢幕同步的塊。當調用 notify() 方法時,線程可以再次執行。圖 8 總結了 wait() 和 notify() 方法。

了解 java 線程

圖 8

圖 9 展示了一個更具體的場景。在後面的文章,我們使用了一個實作生産者-消費者方式的應用程式示例,其中多個線程共享資源。生産者線程将值添加到共享資源,消費者線程使用這些值。但是,如果共享資源不再有價值,會發生什麼呢?消費者不會從此時執行中受益。從技術上講,它們仍然可以執行,但沒有價值可以使用,是以允許 JVM 執行它們将導緻系統中不必要的資源消耗。更好的方法是,當共享資源沒有價值時,“告訴” 消費者等待,并在生産者添加新值後繼續執行。

了解 java 線程

圖 9

3 加入線程

一種非常常見的線程同步方法是通過使一個線程等待另一個線程完成其執行來連接配接線程。與等待/通知模式不同的是,線程不會等待通知。線程隻是等待另一個線程完成它的執行。圖 10 展示了可以從這種同步技術中獲益的場景。

假設您必須基于從兩個不同的獨立來源檢索的資料來實作一些資料處理。通常,從第一個資料源檢索資料大約需要5秒,從第二個資料源擷取資料大約需要8秒。如果按順序執行操作,擷取所有資料所需的時間是5 + 8 = 13秒。但你知道更好的方法。由于資料源是兩個獨立的資料庫,如果使用兩個線程,則可以同時從兩個資料源擷取資料。但是,您需要確定處理資料的線程在開始之前等待檢索資料的兩個線程完成。要實作這一點,需要讓處理線程與檢索資料的線程進行連接配接(如圖 10 所示)。

了解 java 線程

圖 10

在許多情況下,連接配接線程是一種必要的同步技術。但如果使用不當,它也會導緻問題。例如,如果一個線程正在等待另一個線程,被卡住或永遠不會結束,那麼加入它的線程将永遠不會執行。

4 在指定時間内阻塞線程

有時候線程需要等待一定的時間。在這種情況下,線程處于 “定時等待” 狀态或 “睡眠” 狀态。下面的操作是導緻線程定時等待的最常見操作:

  • sleep() — 你總是可以在 Thread 類中使用靜态的 sleep() 方法,讓目前執行代碼的線程等待固定的時間。
  • wait(long timeout)— 帶 timeout 參數的 wait 方法可以和不帶參數的 wait() 方法一樣使用。但是,如果你提供了一個參數,如果沒有提前通知,線程将等待給定的時間。
  • join(long timeout) — 他的操作與 join() 方法相同,但會等待最大逾時時間,逾時時間由參數指定。

我在應用程式中發現的一個常見的反模式是使用 sleep() 讓線程等待,而不是第4章中讨論的 wait() 方法。以我們讨論的生産者-消費者架構為例。你可以使用 sleep() 而不是 wait(),但是消費者應該休眠多長時間才能確定生産者有時間運作并向共享資源添加值呢?這個問題我們沒有答案。例如,讓線程睡眠 100 毫秒(如圖 11 所示)可能太長或太短。在大多數情況下,如果您遵循這種方法,您最終不會獲得最佳性能。

了解 java 線程

圖 11

5 用阻塞對象同步線程

JDK 提供了一套令人印象深刻的同步線程的工具。在這些類中,多線程架構中使用的一些最著名的類是

  • Semaphore — 用于限制執行給定代碼塊的線程數量的對象
  • CyclicBarrier — 一個對象,你可以使用它來確定在執行給定的代碼塊時,至少有給定數量的線程處于活動狀态
  • Lock — 提供更廣泛同步選項的對象
  • Latch — 一個對象,您可以使用它使一些線程等待,直到其他線程中的某些邏輯執行完畢

這些對象是進階實作,每個都部署了定義好的機制,以簡化某些場景中的實作。在大多數情況下,這些對象會因為使用方式不當而引起麻煩,在很多情況下,開發人員會過度使用它們來編寫代碼。我的建議是使用你能找到的最簡單的解決方案來解決問題,并且在使用任何這些對象之前,確定你正确地了解它們是如何工作的。

多線程架構中的常見問題

在研究多線程架構時,您将确定常見問題,這些問題是各種意外行為(意外輸出或性能問題)的根本原因。提前了解這些問題可以幫助你更快地識别問題的來源并解決它。這些問題如下:

  • 競态條件—兩個或多個線程競争修改共享資源。
  • 死鎖—兩個或多個線程在互相等待時發生阻塞。
  • Livelocks—兩個或多個線程不滿足停止并繼續運作的條件,而沒有執行任何有用的工作。
  • 饑餓—當 JVM 執行其他線程時,某個線程會持續被阻塞。線程永遠不會執行它定義的指令。

1 競态條件

當多個線程試圖同時更改同一資源時,就會發生競态條件。當這種情況發生時,我們可能會遇到意想不到的結果或異常。通常,我們使用同步技術來避免這些情況。如圖 12 所示。線程 T1 和線程 T2 同時嘗試改變變量 x 的值。線程 T1 嘗試增加該值,而線程 T2 嘗試減少該值。這種情況可能會導緻重複執行應用程式的不同輸出。可能有以下情況:

  • 操作執行後,x 可能是 5— 如果 T1 先改變值,而 T2 讀取已經改變的變量值,或者相反,變量的值仍然是 5。
  • 操作執行後,x 可以是 4 — 如果兩個線程同時讀取 x 的值,但 T2 最後寫了這個值,x 将是(T2 讀取的值,5,減去 1)。
  • 操作執行後,x 可能是 6 — 如果兩個線程同時讀取 x 的值,但 T1 最後寫入 x 的值,則 x 将為 6 (T1 讀取的值為 5,加 1)。

這種情況通常會導緻意想不到的輸出。在多線程架構中,可能存在多個執行流,是以很難再現這樣的場景。有時,它們隻發生在特定的環境中,這使得調查變得困難。

了解 java 線程

圖 12

2 死鎖

死鎖是指兩個或多個線程暫停,然後等待彼此的某些操作來繼續執行的情況(圖 13)。死鎖會導緻應用程式(至少是應用程式的一部分)當機,進而阻止某些功能的運作。

了解 java 線程

圖 13 死鎖示例在 T1 等待 T2 繼續執行,T2 等待 T1 的情況下,線程處于死鎖狀态。

圖 14 說明了代碼發生死鎖的方式。在這個例子中,一個線程獲得了資源a上的鎖,另一個線程獲得了資源b上的鎖,但是每個線程也需要其他線程獲得的資源來繼續執行。線程T1等待線程T2釋放資源A,但與此同時,線程T2等待線程T1釋放資源B。兩個線程都不能繼續,因為它們都在等待對方釋放所需的資源,進而導緻死鎖。

了解 java 線程

圖 14

圖 14 中給出的例子很簡單,但它隻是一個說教性的例子。現實場景通常更難以調查和了解,可能涉及兩個以上的線程。注意,同步塊并不是線程陷入死鎖的唯一方式。了解這種情況的最好方法是使用你在後續文章學到的調查技術。

3 Livelocks

Livelocks 或多或少與死鎖相反。當線程處于 livelock 中時,條件總是以這樣一種方式變化,即使線程應該在給定條件下停止,它們也會繼續執行。線程不能停止,它們持續運作,通常會無緣無故地消耗系統資源。在應用程式執行過程中,Livelocks 會導緻性能問題。

圖 15 展示了一個帶有序列圖的 livelock。兩個線程T1和T2在循環中運作。為了停止執行,T1 在最後一次疊代之前使一個條件為真。下一次 T1 回到條件時,它期望它為 true 并停止。然而,這并沒有發生,因為另一個線程 T2 将其更改為 false。T2 也處于同樣的情況。每個線程改變條件,這樣它就可以停止,但與此同時,條件的每次改變都會導緻另一個線程繼續運作。

了解 java 線程

圖 15

這是一個簡化的場景。在現實世界中,更複雜的場景可能會引起 Livelocks,并且可能涉及兩個以上的線程。

4 饑餓

另一個常見的問題是饑餓,盡管在今天的應用程式中不太可能出現。饑餓是由于某個線程不斷被排除在執行之外,即使它是可運作的。線程希望執行它的指令,但是JVM不斷地允許其他線程通路系統的資源。因為線程不能通路系統的資源并執行它定義的指令集,是以我們說它正在挨餓。

在早期的JVM版本中,當開發人員為給定線程設定了低得多的優先級時,就會出現這種情況。如今,JVM實作在處理這些情況時要聰明得多,是以(至少在我的經驗中)饑餓場景不太可能出現。