[深入理解Java虛擬機器]第十二章 Java記憶體模型與執行緒-Java與執行緒

Coding-lover發表於2015-11-08

併發不一定要依賴多執行緒(如PHP中很常見的多程式併發),但是在Java裡面談論併發,大多數都與執行緒脫不開關係。既然我們這本書探討的話題是Java虛擬機器的特性,那講到Java執行緒,我們就從Java執行緒在虛擬機器中的實現開始講起。

執行緒的實現

我們知道,執行緒是比程式更輕量級的排程執行單位,執行緒的引入,可以把一個程式的資源分配和執行排程分開,各個執行緒既可以共享程式資源(記憶體地址、檔案I/O等),又可以獨立排程(執行緒是CPU排程的基本單位)。

主流的作業系統都提供了執行緒實現,Java語言則提供了在不同硬體和作業系統平臺下對執行緒操作的統一處理,每個已經執行start()且還未結束的java.lang.Thread類的例項就代表了一個執行緒。我們注意到Thread類與大部分的Java API有顯著的差別,它的所有關鍵方法都是宣告為Native的。在Java API中,一個Native方法往往意味著這個方法沒有使用或無法使用平臺無關的手段來實現(當然也可能是為了執行效率而使用Native方法,不過,通常最高效率的手段也就是平臺相關的手段)。正因為如此,作者把本節的標題定為“執行緒的實現”而不是“Java執行緒的實現”。

實現執行緒主要有3種方式:使用核心執行緒實現、使用使用者執行緒實現和使用使用者執行緒加輕量級程式混合實現。

1.使用核心執行緒實現

核心執行緒(Kernel-Level Thread,KLT)就是直接由作業系統核心(Kernel,下稱核心)支援的執行緒,這種執行緒由核心來完成執行緒切換,核心通過操縱排程器(Scheduler)對執行緒進行排程,並負責將執行緒的任務對映到各個處理器上。每個核心執行緒可以視為核心的一個分身,這樣作業系統就有能力同時處理多件事情,支援多執行緒的核心就叫做多執行緒核心(Multi-Threads Kernel)。

程式一般不會直接去使用核心執行緒,而是去使用核心執行緒的一種高階介面——輕量級程式(Light Weight Process,LWP),輕量級程式就是我們通常意義上所講的執行緒,由於每個輕量級程式都由一個核心執行緒支援,因此只有先支援核心執行緒,才能有輕量級程式。這種輕量級程式與核心執行緒之間1:1的關係稱為一對一的執行緒模型,如圖12-3所示。

圖 12-3 輕量級程式與核心執行緒之間1:1的關係

由於核心執行緒的支援,每個輕量級程式都成為一個獨立的排程單元,即使有一個輕量級程式在系統呼叫中阻塞了,也不會影響整個程式繼續工作,但是輕量級程式具有它的侷限性:首先,由於是基於核心執行緒實現的,所以各種執行緒操作,如建立、析構及同步,都需要進行系統呼叫。而系統呼叫的代價相對較高,需要在使用者態(User Mode)和核心態(Kernel Mode)中來回切換。其次,每個輕量級程式都需要有一個核心執行緒的支援,因此輕量級程式要消耗一定的核心資源(如核心執行緒的棧空間),因此一個系統支援輕量級程式的數量是有限的。

2.使用使用者執行緒實現

從廣義上來講,一個執行緒只要不是核心執行緒,就可以認為是使用者執行緒(User Thread,UT),因此,從這個定義上來講,輕量級程式也屬於使用者執行緒,但輕量級程式的實現始終是建立在核心之上的,許多操作都要進行系統呼叫,效率會受到限制。

而狹義上的使用者執行緒指的是完全建立在使用者空間的執行緒庫上,系統核心不能感知執行緒存在的實現。使用者執行緒的建立、同步、銷燬和排程完全在使用者態中完成,不需要核心的幫助。如果程式實現得當,這種執行緒不需要切換到核心態,因此操作可以是非常快速且低消耗的,也可以支援規模更大的執行緒數量,部分高效能資料庫中的多執行緒就是由使用者執行緒實現的。這種程式與使用者執行緒之間1:N的關係稱為一對多的執行緒模型,如圖12-4所示。

圖 12-4 程式與使用者執行緒之間1:N的關係

使用使用者執行緒的優勢在於不需要系統核心支援,劣勢也在於沒有系統核心的支援,所有的執行緒操作都需要使用者程式自己處理。執行緒的建立、切換和排程都是需要考慮的問題,而且由於作業系統只把處理器資源分配到程式,那諸如“阻塞如何處理”、“多處理器系統中如何將執行緒對映到其他處理器上”這類問題解決起來將會異常困難,甚至不可能完成。因而使用使用者執行緒實現的程式一般都比較複雜,除了以前在不支援多執行緒的作業系統中(如DOS)的多執行緒程式與少數有特殊需求的程式外,現在使用使用者執行緒的程式越來越少了,Java、Ruby等語言都曾經使用過使用者執行緒,最終又都放棄使用它。

3.使用使用者執行緒加輕量級程式混合實現

執行緒除了依賴核心執行緒實現和完全由使用者程式自己實現之外,還有一種將核心執行緒與使用者執行緒一起使用的實現方式。在這種混合實現下,既存在使用者執行緒,也存在輕量級程式。使用者執行緒還是完全建立在使用者空間中,因此使用者執行緒的建立、切換、析構等操作依然廉價,並且可以支援大規模的使用者執行緒併發。而作業系統提供支援的輕量級程式則作為使用者執行緒和核心執行緒之間的橋樑,這樣可以使用核心提供的執行緒排程功能及處理器對映,並且使用者執行緒的系統呼叫要通過輕量級執行緒來完成,大大降低了整個程式被完全阻塞的風險。在這種混合模式中,使用者執行緒與輕量級程式的數量比是不定的,即為N:M的關係,如圖12-5所示,這種就是多對多的執行緒模型。

許多UNIX系列的作業系統,如Solaris、HP-UX等都提供了N:M的執行緒模型實現。

圖 12-5 使用者執行緒與輕量級程式之間N:M的關係

4.Java執行緒的實現

Java執行緒在JDK 1.2之前,是基於稱為“綠色執行緒”(Green Threads)的使用者執行緒實現的,而在JDK 1.2中,執行緒模型替換為基於作業系統原生執行緒模型來實現。因此,在目前的JDK版本中,作業系統支援怎樣的執行緒模型,在很大程度上決定了Java虛擬機器的執行緒是怎樣對映的,這點在不同的平臺上沒有辦法達成一致,虛擬機器規範中也並未限定Java執行緒需要使用哪種執行緒模型來實現。執行緒模型只對執行緒的併發規模和操作成本產生影響,對Java程式的編碼和執行過程來說,這些差異都是透明的。

對於Sun JDK來說,它的Windows版與Linux版都是使用一對一的執行緒模型實現的,一條Java執行緒就對映到一條輕量級程式之中,因為Windows和Linux系統提供的執行緒模型就是一對一的。

而在Solaris平臺中,由於作業系統的執行緒特性可以同時支援一對一(通過Bound Threads或Alternate Libthread實現)及多對多(通過LWP/Thread Based Synchronization實現)的執行緒模型,因此在Solaris版的JDK中也對應提供了兩個平臺專有的虛擬機器引數:-XX:+UseLWPSynchronization(預設值)和-XX:+UseBoundThreads來明確指定虛擬機器使用哪種執行緒模型。

Windows下有纖程包(Fiber Package),Linux下也有NGPT(在2.4核心的年代)來實現N:M模型,但是它們都沒有成為主流。

Java執行緒排程

執行緒排程是指系統為執行緒分配處理器使用權的過程,主要排程方式有兩種,分別是協同式執行緒排程(Cooperative Threads-Scheduling)和搶佔式執行緒排程(Preemptive Threads-Scheduling)。

如果使用協同式排程的多執行緒系統,執行緒的執行時間由執行緒本身來控制,執行緒把自己的工作執行完了之後,要主動通知系統切換到另外一個執行緒上。協同式多執行緒的最大好處是實現簡單,而且由於執行緒要把自己的事情幹完後才會進行執行緒切換,切換操作對執行緒自己是可知的,所以沒有什麼執行緒同步的問題。Lua語言中的“協同例程”就是這類實現。它的壞處也很明顯:執行緒執行時間不可控制,甚至如果一個執行緒編寫有問題,一直不告知系統進行執行緒切換,那麼程式就會一直阻塞在那裡。很久以前的Windows 3.x系統就是使用協同式來實現多程式多工的,相當不穩定,一個程式堅持不讓出CPU執行時間就可能會導致整個系統崩潰。

如果使用搶佔式排程的多執行緒系統,那麼每個執行緒將由系統來分配執行時間,執行緒的切換不由執行緒本身來決定(在Java中,Thread.yield()可以讓出執行時間,但是要獲取執行時間的話,執行緒本身是沒有什麼辦法的)。在這種實現執行緒排程的方式下,執行緒的執行時間是系統可控的,也不會有一個執行緒導致整個程式阻塞的問題,Java使用的執行緒排程方式就是搶佔式排程。與前面所說的Windows 3.x的例子相對,在Windows 9x/NT核心中就是使用搶佔式來實現多程式的,當一個程式出了問題,我們還可以使用工作管理員把這個程式“殺掉”,而不至於導致系統崩潰。

雖然Java執行緒排程是系統自動完成的,但是我們還是可以“建議”系統給某些執行緒多分配一點執行時間,另外的一些執行緒則可以少分配一點——這項操作可以通過設定執行緒優先順序來完成。Java語言一共設定了10個級別的執行緒優先順序(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY),在兩個執行緒同時處於Ready狀態時,優先順序越高的執行緒越容易被系統選擇執行。

不過,執行緒優先順序並不是太靠譜,原因是Java的執行緒是通過對映到系統的原生執行緒上來實現的,所以執行緒排程最終還是取決於作業系統,雖然現在很多作業系統都提供執行緒優先順序的概念,但是並不見得能與Java執行緒的優先順序一一對應,如Solaris中有2147483648(232)種優先順序,但Windows中就只有7種,比Java執行緒優先順序多的系統還好說,中間留下一點空位就可以了,但比Java執行緒優先順序少的系統,就不得不出現幾個優先順序相同的情況了,表12-1顯示了Java執行緒優先順序與Windows執行緒優先順序之間的對應關係,Windows平臺的JDK中使用了除THREAD_PRIORITY_IDLE之外的其餘6種執行緒優先順序。

上文說到“執行緒優先順序並不是太靠譜”,不僅僅是說在一些平臺上不同的優先順序實際會變得相同這一點,還有其他情況讓我們不能太依賴優先順序:優先順序可能會被系統自行改變。例如,在Windows系統中存在一個稱為“優先順序推進器”(Priority Boosting,當然它可以被關閉掉)的功能,它的大致作用就是當系統發現一個執行緒執行得特別“勤奮努力”的話,可能會越過執行緒優先順序去為它分配執行時間。因此,我們不能在程式中通過優先順序來完全準確地判斷一組狀態都為Ready的執行緒將會先執行哪一個。

狀態轉換

Java語言定義了5種執行緒狀態,在任意一個時間點,一個執行緒只能有且只有其中的一種狀態,這5種狀態分別如下。

  • 新建(New):建立後尚未啟動的執行緒處於這種狀態。
  • 執行(Runable):Runable包括了作業系統執行緒狀態中的Running和Ready,也就是處於此狀態的執行緒有可能正在執行,也有可能正在等待著CPU為它分配執行時間。
  • 無限期等待(Waiting):處於這種狀態的執行緒不會被分配CPU執行時間,它們要等待被其他執行緒顯式地喚醒。以下方法會讓執行緒陷入無限期的等待狀態:
    • 沒有設定Timeout引數的Object.wait()方法。
    • 沒有設定Timeout引數的Thread.join()方法。
    • LockSupport.park()方法。
  • 限期等待(Timed Waiting):處於這種狀態的執行緒也不會被分配CPU執行時間,不過無須等待被其他執行緒顯式地喚醒,在一定時間之後它們會由系統自動喚醒。以下方法會讓執行緒進入限期等待狀態:
    • Thread.sleep()方法。
    • 設定了Timeout引數的Object.wait()方法。
    • 設定了Timeout引數的Thread.join()方法。
    • LockSupport.parkNanos()方法。
    • LockSupport.parkUntil()方法。
  • 阻塞(Blocked):執行緒被阻塞了,“阻塞狀態”與“等待狀態”的區別是:“阻塞狀態”在等待著獲取到一個排他鎖,這個事件將在另外一個執行緒放棄這個鎖的時候發生;而“等待狀態”則是在等待一段時間,或者喚醒動作的發生。在程式等待進入同步區域的時候,執行緒將進入這種狀態。
  • 結束(Terminated):已終止執行緒的執行緒狀態,執行緒已經結束執行。

上述5種狀態在遇到特定事件發生的時候將會互相轉換,它們的轉換關係如圖12-6所示。

圖 12-6 執行緒狀態轉換關係

相關文章