淺談「Java」執行緒的生命週期與狀態
在「Java」中,「多執行緒」無疑是重要卻相對複雜的議題。
尤其是在「Android」的開發上,除了「Java」原生的「Thread」、「Executors」之外,還有專屬於「Android」的「Handler」、「AsyncTask」類等,舉例來說,「畫面刷新」就是一個牽涉到執行緒間溝通的行為;更別說目前主流用於處理「併發任務」的「RxJava」和「Kotlin Coroutines」。
而只要是執行緒,根其究底,就擺脫不了其執行緒的「生命週期」與「狀態」。
事實上,若以「JVM」面向探討「Kotlin」,那麼「Kotlin Coroutines」可視為是一種運行在「JVM」的「線程框架」,其仍必須受到「線程規格」的所有限制。
本文此僅探討「Java」中執行緒的生命週期與狀態。
概要
在開始介紹「多執行緒」的生命週期之前,幾個關於「執行緒」概念我們必須要先釐清。
硬體上的多執行緒
相信各位在購買電腦的「CPU」時,應該還聽過「八核八緒」、「八核十六緒」…等規格相關的介紹,如下:
在上圖,「CPU」規格一項中,其所顯示的「執行緒數量」,就是所謂的硬體上執行緒的數量,其亦可理解為「硬件級別的多功」。
在英特爾得「超執行緒」的技術尚未問世之前,硬體上的執行緒數量會與核心數量相等,其也代表硬體可同時運算的數量;但英特爾所研發的「超執行緒」技術實現了在一個「CPU」核心上運行兩個「邏輯執行序」的可能,因此,就有了上圖中「八核十六緒」的現象,而這也就是我們常聽到的「虛擬核心」概念。
軟體上的多執行緒
在介紹「軟體上的多執行緒」前,我們得先了解「執行緒」定義,根據維基百科上,「執行緒」就是作業系統能夠進行運算排程的最小單位,如下:
而「軟體上的多執行緒」,就是操作系統對同個硬體執行緒,進行時間上的切分,更具體地說,即便處理器只能執行一個執行緒,作業系統也可以通過快速的在不同執行緒之間進行切換,由於時間間隔很小,來給使用者造成一種多個執行緒同時執行的假象,如下:
簡單的說,軟體上的多執行緒與硬體上的多執行緒並非同層級的概念。
行程與執行緒的差異
行程,Process,又稱為「進程」,它是操作系統中的獨立區塊,在正常情況下,每一個行程都有其自己的地址空間,如堆疊、堆積…等;此外,各個行程間幾乎是彼此獨立、不共享。
備註:「Android OS」中的權限管理,就與進程相關。
執行緒,Thread,又稱為「線程」,是系統處理工作的基本單元,其每個進程中可以包含一個至多個「線程」,而同進程內線程將共享資源。
換句話說就是:「進程大於線程,線程運行在進程內;事實上,兩者並同層級的概念」。更詳細的內容可以參考「程序、行程」一文。
正文
在經過基本的觀念釐清後,就到了本次文章的主題:「執行緒的生命週期與狀態」。
開始執行緒
首先,先看一段基本的多執行緒代碼,如下:
上述那段代碼的行為簡單的敘述是「產生一個新的『執行緒物件』,並派予它打印『1 ~ 99』的工作」。
那以程式碼的角度來看會是「我們藉由『new』關鍵字生成一個『執行緒物件』,並將要交託的任務也就是打印『1 ~ 99』的任務覆寫於『run()』中,而後呼叫『start()』,使該『執行緒物件』進入『就緒狀態』,此時『執行緒物件』將會進到『等待池』中,與其它『執行緒物件』一同等待執行資源的獲得」。
這段描述的重點是「start()」是讓「執行緒物件」進入「就緒狀態」,也就是「Runnable State」,而非真正的開始執行任務;事實上,也僅有處於「就緒狀態」的「執行緒物件」才具有獲取執行資源的資格,但具體執行資源如何分配將會是由系統決定,我們僅能設置其「權重」。
根據源碼可以知道「start()」的實作關鍵在於「start0()」,如下:
而「start0()」其實是個「native」方法,如下:
其意味著該方法的行為依賴於平台本身的實作,與「Java」已經無關,因此被「native」修飾的方法又被稱為「平台方法」,事實上,與執行緒具體行為相關的方法,絕大多數皆屬於此類。
言歸正題,當「執行緒物件」獲取到系統分派的執行資源後,就會進入「執行狀態」,並真正開始執行被給予的任務;然後直到「任務完成」或「執行異常退出」時,該「執行緒物件」就會移到「死亡池」並等待回收。
上述為一執行緒最簡單的生命週期,示意圖如下:
退回:「yield()」
當「執行緒物件」處於「執行狀態」時,除了「任務完成」或「執行異常退出」的情況,還有「退回」與「中斷」。
在「Thread」類別中,有兩個類別方法與「退回」和「中斷」相關,其分別為「yield()」以及「sleep()」。
首先介紹的是「yield()」,它的功能是「退回」,它可以讓「執行緒物件」從「執行狀態」退回至「等待池」中,也就是回到「Runnable State」,並持續等待下次執行資源的分派。
事實上,用「退回」這個詞來解釋「yield()」可能不是很精確,「yield()」在官方文件的描述就只是:「當執行緒物件獲取執行資源時,讓出此次的執行資源獲取」,如下:
但筆者認為使用「退回」來描述可以很清楚地與「中斷」作為區別。
與「start()」類似,「yield()」的實作也屬於平台方法,如下:
因此同樣地,該方法的具體實現會依賴於平台並存在平台差異,詳細內容請參考「What are the main uses of yield(), and how does it differ from join() and interrupt()?」一文,因為與本文關係不大,筆者就不冗述了。
事實上,「yield()」在一般開發上的適用性不高,幾乎可說是沒有任何適用情境,也因此對於「yield()」有概念即可。
中斷:「sleep()」
與「退回」有點類似的「中斷」,「退回」是讓「執行緒物件」回到「等待池」,而「中斷」則是讓「執行緒物件」進入「阻斷狀態」,即「Blocked State」。
使執行緒中斷的方法是:「sleep()」,其同為「Thread」的類別方法,它會讓處於「執行狀態」的「執行緒物件」進入「阻斷狀態」;事實上,由呼叫「sleep()」而進入的「阻斷狀態」,筆者更偏好稱之為「休眠狀態」,以跟後續要介紹的「wait()」作區隔。
與「Runnable State」不同,當執行緒物件處於「Blocked State」時,其不能夠被系統分派執行資源的,當我們呼叫「sleep()」時,其方法要求傳入「時間參數」以定義最大的休眠時間,官方說明如下:
在加入「yield()」、「sleep()」和「Blocked State」的執行緒的生命週期圖如下:
事實上,「中斷」執行緒的方法除了「sleep()」外,還有一個常被用來與之比較的方法:「wait()」;其作用基本與「sleep()」類似,但「wait()」還牽扯到「同步鎖機制」。
同步鎖機制
同步鎖機制的關鍵是「synchronized」,假設我們要對一段操作上「同步鎖」,其示意代碼如下:
事實上,上面的代碼是比較精簡的撰寫方式,較完整的撰寫方式如下:
上述兩個代碼片段的意義是完全相同的,都是為「synchronized」所包覆的內容加上同步鎖;其中「this」代表物件自身,而在此處,物件的作僅是作為「標記」用,因此又稱為「標記物件」。
對於「標記物件」物件,我們很常聽到的說法是「給予旗標」,意即該段程式碼被「旗標」所管理,若當有一執行緒要存取被「旗標」管理的資源時,它就會佔有旗標,該情況也就意味:「別的執行緒就無法獲取該『旗標』,也就無法對該資源進行存取」。
這邊要釐清的是,此處的「this」僅作為「標記物件」,與「this」物件本身的「資源」無關,先看一段程式代碼,如下:
如上述代碼,其中「resStrA」與「resStrB」就會由不同的「標記物件」所管理,因此,在該情況下,我們就可以分別由兩條不同的執行緒同時分別的執行「setResStrA()」和「setResStrB()」。
簡單理解,在「同步的機制」中,「鎖物件」作用就僅是標記,所以又被稱為「物件鎖」,事實上,將上述代碼中的「objLock」換成其它類別的物件也是沒有問題的,但這並沒有意義。
然而,在多數情況下,同一物件的資源屬於「同關聯資源」,會彼此相互影響,加上方便,所以我們在撰寫代碼時,才會用「this」作為標記物件,讓整個「物件的資源」,共用同一把「物件鎖」;但必須釐清的是,這確實是兩個不同的概念。
不過在比較複雜的情況下,例如上述的代碼中的「resStrA」與「resStrB」,它們是不相影響的,對於此類「非同關聯資源」,我們就可以以不同的「旗標」來管理它們,以此程式執行增加效率。
關鍵是,並非同個物件就一定是「同關聯資源」,究其本質,我們應該關注的是「資源本身」,而非「物件本身」。
關於「同步鎖機制」就介紹到這,其它「執行緒安全」的相關機制還很多,如「volatile」修飾詞或「Atomic」相關類別都是,但其不再本文的範圍,我們就不一一介紹。
等待:「wait()」
與「sleep()」不同,「wait()」屬於「Object」的方法,官方說明如下:
由根據文件描述可知,「wait()」與「同步鎖」的機制相關。
當「執行緒物件」從「執行狀態」進到「Blocked State」中,其主要行為就是釋放由系統賦予的「執行資源」;就這點來說,「wait()」和「sleep()」是相同的。
而「wait()」和「sleep()」最大的差異在於對「同步鎖」的作用。
在此,倘若目標資源未受到「synchronized」修飾,那麼「sleep()」就如上述那樣,讓執行緒物件釋放「執行資源」並改為「Blocked State」。
而「wait()」?運行結果如下:
報錯,是的,就是丟出錯誤;因為「wait()」僅能作用於「synchronized」修飾的區塊;又或著說,「wait()」的作用目標是「物件鎖」本身,這個我們稍後再說。
那倘若目標資源受到「synchronized」修飾呢?
就「sleep()」來說,它將會持續佔有「旗標」,不會釋放,這也就是為什麼其強制要求傳入時間參數,若無該參數,就可能會發生「旗標」無限期被佔有的情況;從而導致其它執行緒皆無法對目標資源操作。
而「wait()」則會連同「旗標」一併釋放,即讓出執行權。
而喚醒的方法為為「notify()」或是「notifyAll()」,其與「wait()」同屬於物件的方法,官方描述如下:
事實上,此處的「Blocked State」與因「sleep()」而進入「Blocked State」是略有差異的,因「sleep()」而進入的「阻斷狀態」,為「休眠阻斷」,在某些文章會說它與「鎖」無關,也的確如此,若遇到上「鎖」的情況,帶走就是,反之,未上「鎖」的情況,也不會像「wait()」一樣出現錯誤。
而因「wait()」而進入的「阻斷狀態」,為「等待阻斷」,它會釋放所有資源與鎖,並直到被喚醒;其與「休眠阻斷」是有些差異的,程式碼如下:
上述代碼有一個地方要注意,就是若以「notify()」喚醒執行緒物件,其只會喚醒一個,但若同時有多個處於「阻斷狀態」的執行緒物件,它會隨機挑選一個喚醒,因此充滿不確定性,除非確保處於「阻斷狀態」的執行緒物件僅有一個,否則,我們通常會使用「notifyAll()」。
接著,我們來解釋為什麼說「wait()」的作用目標是「物件鎖」本身,其實不僅是「wait()」,「notify()」與「notifyAll()」都是,程式碼如下:
理解了吧,對於上述三個物件方法而言,其作用的對象是「物件鎖」本身,而非「線程」本身。
釐清一下觀念,所謂「同步鎖」指的是「旗標」,是個標記物件,它的目的是為了防止「同(一)關聯資源」被「兩條或兩條以上不同的執行緒」同時操作,造成資料錯亂的問題。
而執行緒是系統處理工作的基本單元,它就是個工作者,其有著各種運行狀態,包含「就緒」、「執行中」、「死亡」或「阻斷」等;在「同步機制」下,其需要佔有「旗標」才能工作。
在此,我們簡單總結一下「wait()」與「sleep()」:
首先,「wait()」屬於物件方法;「sleep()」屬於「Thread」的類別方法,被「static」修飾。
其次,雖然「sleep()」和「wait()」都會使得「執行緒物件」進入「阻斷狀態」並釋放「執行資源」,但「wait()」僅能作用於「synchronized」區塊,此外,因為「sleep()」進入「阻斷狀態」並不會釋放「鎖」,反之,因為「wait()」而進入「阻斷狀態」則會。
至目前,執行緒的生命週期圖如下:
等待:「join()」
接著,再介紹個同樣是用於執行緒相互等待的方法:「join()」,官方說明如下:
與「wait()」不同,「join()」是屬於「Thread」類別的方法。
嚴格來說,其功能與「sleep()」較為類似,但其等待不是時間,而是其它執行緒物件的完成,程式碼如下:
在上述的程式範例中,當執行到「17」行時,當前的執行緒就會進入「阻斷狀態」,依範例而言就是「Main Thread」,直到「thread」執行完成後,主執行緒就會自動醒來並回到「就緒狀態」。
發現其與「wait()」的差異了嗎?
是的,「join()」的作用對象是「執行緒物件」,而「wait()」的作用對象則是「鎖」本身。
至此,執行緒的生命週期大致上都介紹完了,圖如下:
中止:「interrupt()」
最後是與中止執行緒有關的的方法:「interrupt()」。
在介紹「interrupt()」之前,在早期用於中止執行緒的方法:「stop()」,官方介紹如下:
其範例程式碼如下:
執行可以看到,打印確實被中止了,如下:
接著,將其中的「stop()」換成「interrupt()」執行看看,程式碼如下:
然後就會發現:「打印並沒有被中止」,如下:
是的,不論嘗試幾次都是這樣的結果,這是因為「stop()」是「立刻」中止執行緒,而「interrupt()」是「保守性」的中止執行緒。
雖然「stop()」乍看之下相對直覺,但是它存在「不可預期」的風險;換句言之,倘若執行緒正在執行,我們是無法控制其被中止的時機,也因此,它已被棄用。
此外,與「stop()」相同且也被棄用的還有「suspend()」和「resume()」,其資訊可以參考「Why Are Thread.stop, Thread.suspend, Thread.resume and Runtime.runFinalizersOnExit Deprecated?」一文。
言歸正傳,雖然我們剛才說「interrupt()」是保守性的中止執行緒,但乍看之下,它似乎是沒有作用?
事實上,停止的時間點是需要我們自行實作的,範例如下:
上述的代碼是,我們僅能在「i % 10000 == 0」的條件下,才允許執行緒中斷,執行結果如下:
執行緒的確被中斷了,這樣理解了嗎?
所謂的「保守性中止」,就是需要我們自行實作中止的時機,而此機制為了們保有了收尾的彈性。
題外話,與「isInterrupted()」類似的方法是「Thread.interrupted()」,基本上兩者是相同的,但稍微要注意的是「Thread.interrupted()」會「清除線程的標記狀態」,詳細請參考「Java: Difference in usage between Thread.interrupted() and Thread.isInterrupted()?」。
此外,「interrupt()」除了能保守的中斷執行緒外,它還有個重要的功能,就是「它會讓目前處於『阻斷狀態』的『執行緒物件』退出,並拋出異常訊息:『InterruptedException』」,程式碼如下:
事實上,更嚴謹的說法是:「『interrupt()』僅適用於「當執行緒物件」是因為『sleep()』、『wait()』或『join()』而進到『阻斷狀態』的『執行緒物件』」;例如因為「I/O」而進入「阻斷狀態」就會無效,事實上,筆者認為這並非相同的概念了,詳細請參考「停止執行緒」一文,內容截圖如下:
總結
還是那句老話,雖然「多執行緒」是相對比較難懂的議題,但它非常重要,尤其是對「Android APPs」的開發者,弄懂它,有益無害。