1. 程式人生 > >JAVA程式設計思想:第14章 多執行緒

JAVA程式設計思想:第14章 多執行緒

第14章 多執行緒

利用物件,可將一個程式分割成相互獨立的區域。我們通常也需要將一個程式轉換成多個獨立執行的子任務。
象這樣的每個子任務都叫作一個“執行緒”(Thread)。編寫程式時,可將每個執行緒都想象成獨立執行,而且都有自己的專用CPU。一些基礎機制實際會為我們自動分割CPU的時間。我們通常不必關心這些細節問題,所以多執行緒的程式碼編寫是相當簡便的。
這時理解一些定義對以後的學習狠有幫助。“程序”是指一種“自包容”的執行程式,有自己的地址空間。“多工”作業系統能同時執行多個程序(程式)——但實際是由於CPU分時機制的作用,使每個程序都能迴圈獲得自己的CPU時間片。但由於輪換速度非常快,使得所有程式好象是在“同時”執行一樣。“執行緒”是程序內部單一的一個順序控制流。因此,一個程序可能容納了多個同時執行的執行緒。
多執行緒的應用範圍很廣。但在一般情況下,程式的一些部分同特定的事件或資源聯絡在一起,同時又不想為它而暫停程式其他部分的執行。這樣一來,就可考慮建立一個執行緒,令其與那個事件或資源關聯到一起,並讓它獨立於主程式執行。一個很好的例子便是“Quit”或“退出”按鈕——我們並不希望在程式的每一部分程式碼中都輪詢這個按鈕,同時又希望該按鈕能及時地作出響應(使程式看起來似乎經常都在輪詢它)。事實上,多執行緒最主要的一個用途就是構建一個“反應靈敏”的使用者介面。

14.1 反應靈敏的使用者介面
作為我們的起點,請思考一個需要執行某些CPU密集型計算的程式。由於CPU“全心全意”為那些計算服務,所以對使用者的輸入十分遲鈍,幾乎沒有什麼反應。在這裡,我們用一個合成的applet/application(程式片/應用程式)來簡單顯示出一個計數器的結果:

752-753頁程式

在這個程式中,AWT和程式片程式碼都應是大家熟悉的,第13章對此已有很詳細的交待。go()方法正是程式全心全意服務的對待:將當前的count(計數)值置入TextField(文字欄位)t,然後使count增值。
go()內的部分無限迴圈是呼叫sleep()。sleep()必須同一個Thread(執行緒)物件關聯到一起,而且似乎每個應用程式都有部分執行緒同它關聯(事實上,Java本身就是建立線上程基礎上的,肯定有一些執行緒會伴隨我們寫的應用一起執行)。所以無論我們是否明確使用了執行緒,都可利用Thread.currentThread()產生由程式使用的當前執行緒,然後為那個執行緒呼叫sleep()。注意,Thread.currentThread()是Thread類的一個靜態方法。
注意sleep()可能“擲”出一個InterruptException(中斷違例)——儘管產生這樣的違例被認為是中止執行緒的一種“惡意”手段,而且應該儘可能地杜絕這一做法。再次提醒大家,違例是為異常情況而產生的,而不是為了正常的控制流。在這裡包含了對一個“睡眠”執行緒的中斷,以支援未來的一種語言特性。
一旦按下start按鈕,就會呼叫go()。研究一下go(),你可能會很自然地(就象我一樣)認為它該支援多執行緒,因為它會進入“睡眠”狀態。也就是說,儘管方法本身“睡著”了,CPU仍然應該忙於監視其他按鈕“按下”事件。但有一個問題,那就是go()是永遠不會返回的,因為它被設計成一個無限迴圈。這意味著actionPerformed()根本不會返回。由於在第一個按鍵以後便陷入actionPerformed()中,所以程式不能再對其他任何事件進行控制(如果想出來,必須以某種方式“殺死”程序——最簡便的方式就是在控制檯視窗按Ctrl+C鍵)。
這裡最基本的問題是go()需要繼續執行自己的操作,而與此同時,它也需要返回,以便actionPerformed()能夠完成,而且使用者介面也能繼續響應使用者的操作。但物件go()這樣的傳統方法來說,它卻不能在繼續的同時將控制權返回給程式的其他部分。這聽起來似乎是一件不可能做到的事情,就象CPU必須同時位於兩個地方一樣,但執行緒可以解決一切。“執行緒模型”(以及Java中的程式設計支援)是一種程式編寫規範,可在單獨一個程式裡實現幾個操作的同時進行。根據這一機制,CPU可為每個執行緒都分配自己的一部分時間。每個執行緒都“感覺”自己好象擁有整個CPU,但CPU的計算時間實際卻是在所有執行緒間分攤的。
執行緒機制多少降低了一些計算效率,但無論程式的設計,資源的均衡,還是使用者操作的方便性,都從中獲得了巨大的利益。綜合考慮,這一機制是非常有價值的。當然,如果本來就安裝了多塊CPU,那麼作業系統能夠自行決定為不同的CPU分配哪些執行緒,程式的總體執行速度也會變得更快(所有這些都要求作業系統以及應用程式的支援)。多執行緒和多工是充分發揮多處理機系統能力的一種最有效的方式。

14.1.1 從執行緒繼承
為建立一個執行緒,最簡單的方法就是從Thread類繼承。這個類包含了建立和執行執行緒所需的一切東西。Thread最重要的方法是run()。但為了使用run(),必須對其進行過載或者覆蓋,使其能充分按自己的吩咐行事。因此,run()屬於那些會與程式中的其他執行緒“併發”或“同時”執行的程式碼。
下面這個例子可建立任意數量的執行緒,並通過為每個執行緒分配一個獨一無二的編號(由一個靜態變數產生),從而對不同的執行緒進行跟蹤。Thread的run()方法在這裡得到了覆蓋,每通過一次迴圈,計數就減1——計數為0時則完成迴圈(此時一旦返回run(),執行緒就中止執行)。

755頁程式

run()方法幾乎肯定含有某種形式的迴圈——它們會一直持續到執行緒不再需要為止。因此,我們必須規定特定的條件,以便中斷並退出這個迴圈(或者在上述的例子中,簡單地從run()返回即可)。run()通常採用一種無限迴圈的形式。也就是說,通過阻止外部發出對執行緒的stop()或者destroy()呼叫,它會永遠執行下去(直到程式完成)。
在main()中,可看到建立並運行了大量執行緒。Thread包含了一個特殊的方法,叫作start(),它的作用是對執行緒進行特殊的初始化,然後呼叫run()。所以整個步驟包括:呼叫構建器來構建物件,然後用start()配置執行緒,再呼叫run()。如果不呼叫start()——如果適當的話,可在構建器那樣做——執行緒便永遠不會啟動。
下面是該程式某一次執行的輸出(注意每次執行都會不同):

756頁程式

可注意到這個例子中到處都呼叫了sleep(),然而輸出結果指出每個執行緒都獲得了屬於自己的那一部分CPU執行時間。從中可以看出,儘管sleep()依賴一個執行緒的存在來執行,但卻與允許或禁止執行緒無關。它只不過是另一個不同的方法而已。
亦可看出執行緒並不是按它們建立時的順序執行的。事實上,CPU處理一個現有執行緒集的順序是不確定的——除非我們親自介入,並用Thread的setPriority()方法調整它們的優先順序。
main()建立Thread物件時,它並未捕獲任何一個物件的控制代碼。普通物件對於垃圾收集來說是一種“公平競賽”,但執行緒卻並非如此。每個執行緒都會“註冊”自己,所以某處實際存在著對它的一個引用。這樣一來,垃圾收集器便只好對它“瞠目以對”了。

14.1.2 針對使用者介面的多執行緒
現在,我們也許能用一個執行緒解決在Counter1.java中出現的問題。採用的一個技巧便是在一個執行緒的run()方法中放置“子任務”——亦即位於go()內的迴圈。一旦使用者按下Start按鈕,執行緒就會啟動,但馬上結束執行緒的建立。這樣一來,儘管執行緒仍在執行,但程式的主要工作卻能得以繼續(等候並響應使用者介面的事件)。下面是具體的程式碼:

757-759頁程式

現在,Counter2變成了一個相當直接的程式,它的唯一任務就是設定並管理使用者介面。但假若使用者現在按下Start按鈕,卻不會真正呼叫一個方法。此時不是建立類的一個執行緒,而是建立SeparateSubTask,然後繼續Counter2事件迴圈。注意此時會儲存SeparateSubTask的控制代碼,以便我們按下onOff按鈕的時候,能正常地切換位於SeparateSubTask內部的runFlag(執行標誌)。隨後那個執行緒便可啟動(當它看到標誌的時候),然後將自己中止(亦可將SeparateSubTask設為一個內部類來達到這一目的)。
SeparateSubTask類是對Thread的一個簡單擴充套件,它帶有一個構建器(其中儲存了Counter2控制代碼,然後通過呼叫start()來執行執行緒)以及一個run()——本質上包含了Counter1.java的go()內的程式碼。由於SeparateSubTask知道自己容納了指向一個Counter2的控制代碼,所以能夠在需要的時候介入,並訪問Counter2的TestField(文字欄位)。
按下onOff按鈕,幾乎立即能得到正確的響應。當然,這個響應其實並不是“立即”發生的,它畢竟和那種由“中斷”驅動的系統不同。只有執行緒擁有CPU的執行時間,並注意到標記已發生改變,計數器才會停止。

1. 用內部類改善程式碼
下面說說題外話,請大家注意一下SeparateSubTask和Counter2類之間發生的結合行為。SeparateSubTask同Counter2“親密”地結合到了一起——它必須持有指向自己“父”Counter2物件的一個控制代碼,以便自己能回撥和操縱它。但兩個類並不是真的合併為單獨一個類(儘管在下一節中,我們會講到Java確實提供了合併它們的方法),因為它們各自做的是不同的事情,而且是在不同的時間建立的。但不管怎樣,它們依然緊密地結合到一起(更準確地說,應該叫“聯合”),所以使程式程式碼多少顯得有些笨拙。在這種情況下,一個內部類可以顯著改善程式碼的“可讀性”和執行效率:

759-761頁程式

這個SeparateSubTask名字不會與前例中的SeparateSubTask衝突——即使它們都在相同的目錄裡——因為它已作為一個內部類隱藏起來。大家亦可看到內部類被設為private(私有)屬性,這意味著它的欄位和方法都可獲得預設的訪問許可權(run()除外,它必須設為public,因為它在基礎類中是公開的)。除Counter2i之外,其他任何方面都不可訪問private內部類。而且由於兩個類緊密結合在一起,所以很容易放寬它們之間的訪問限制。在SeparateSubTask中,我們可看到invertFlag()方法已被刪去,因為Counter2i現在可以直接訪問runFlag。
此外,注意SeparateSubTask的構建器已得到了簡化——它現在唯一的用外就是啟動執行緒。Counter2i物件的控制代碼仍象以前那樣得以捕獲,但不再是通過人工傳遞和引用外部物件來達到這一目的,此時的內部類機制可以自動照料它。在run()中,可看到對t的訪問是直接進行的,似乎它是SeparateSubTask的一個欄位。父類中的t欄位現在可以變成private,因為SeparateSubTask能在未獲任何特殊許可的前提下自由地訪問它——而且無論如何都該儘可能地把欄位變成“私有”屬性,以防來自類外的某種力量不慎地改變它們。
無論在什麼時候,只要注意到類相互之間結合得比較緊密,就可考慮利用內部類來改善程式碼的編寫與維護。

14.1.3 用主類合併執行緒
在上面的例子中,我們看到執行緒類(Thread)與程式的主類(Main)是分隔開的。這樣做非常合理,而且易於理解。然而,還有另一種方式也是經常要用到的。儘管它不十分明確,但一般都要更簡潔一些(這也解釋了它為什麼十分流行)。通過將主程式類變成一個執行緒,這種形式可將主程式類與執行緒類合併到一起。由於對一個GUI程式來說,主程式類必須從Frame或Applet繼承,所以必須用一個介面加入額外的功能。這個介面叫作Runnable,其中包含了與Thread一致的基本方法。事實上,Thread也實現了Runnable,它只指出有一個run()方法。
對合並後的程式/執行緒來說,它的用法不是十分明確。當我們啟動程式時,會建立一個Runnable(可執行的)物件,但不會自行啟動執行緒。執行緒的啟動必須明確進行。下面這個程式向我們演示了這一點,它再現了Counter2的功能:

762-763頁程式1

現在run()位於類內,但它在init()結束以後仍處在“睡眠”狀態。若按下啟動按鈕,執行緒便會用多少有些曖昧的表達方式建立(若執行緒尚不存在):
new Thread(Counter3.this);
若某樣東西有一個Runnable介面,實際只是意味著它有一個run()方法,但不存在與之相關的任何特殊東西——它不具有任何天生的執行緒處理能力,這與那些從Thread繼承的類是不同的。所以為了從一個Runnable物件產生執行緒,必須單獨建立一個執行緒,併為其傳遞Runnable物件;可為其使用一個特殊的構建器,並令其採用一個Runnable作為自己的引數使用。隨後便可為那個執行緒呼叫start(),如下所示:
selfThread.start();
它的作用是執行常規初始化操作,然後呼叫run()。
Runnable介面最大的一個優點是所有東西都從屬於相同的類。若需訪問什麼東西,只需簡單地訪問它即可,不需要涉及一個獨立的物件。但為這種便利也是要付出代價的——只可為那個特定的物件執行單獨一個執行緒(儘管可建立那種型別的多個物件,或者在不同的類裡建立其他物件)。
注意Runnable介面本身並不是造成這一限制的罪魁禍首。它是由於Runnable與我們的主類合併造成的,因為每個應用只能主類的一個物件。

14.1.4 製作多個執行緒
現在考慮一下建立多個不同的執行緒的問題。我們不可用前面的例子來做到這一點,所以必須倒退回去,利用從Thread繼承的多個獨立類來封裝run()。但這是一種更常規的方案,而且更易理解,所以儘管前例揭示了我們經常都能看到的編碼樣式,但並不推薦在大多數情況下都那樣做,因為它只是稍微複雜一些,而且靈活性稍低一些。
下面這個例子用計數器和切換按鈕再現了前面的編碼樣式。但這一次,一個特定計數器的所有資訊(按鈕和文字欄位)都位於它自己的、從Thread繼承的物件內。Ticker中的所有欄位都具有private(私有)屬性,這意味著Ticker的具體實現方案可根據實際情況任意修改,其中包括修改用於獲取和顯示資訊的資料元件的數量及型別。建立好一個Ticker物件以後,構建器便請求一個AWT容器(Container)的控制代碼——Ticker用自己的可視元件填充那個容器。採用這種方式,以後一旦改變了可視元件,使用Ticker的程式碼便不需要另行修改一道。

764-766頁程式

Ticker不僅包括了自己的執行緒處理機制,也提供了控制與顯示執行緒的工具。可按自己的意願建立任意數量的執行緒,毋需明確地建立視窗化元件。
在Counter4中,有一個名為s的Ticker物件的陣列。為獲得最大的靈活性,這個陣列的長度是用程式片引數接觸Web頁而初始化的。下面是網頁中長度引數大致的樣子,它們嵌於對程式片(applet)的描述內容中:
<applet code=Counter4 width=600 height=600>
<param name=size value="20">
</applet>
其中,param,name和value是所有Web頁都適用的關鍵字。name是指程式中對引數的一種引用稱謂,value可以是任何字串(並不僅僅是解析成一個數字的東西)。
我們注意到對陣列s長度的判斷是在init()內部完成的,它沒有作為s的內嵌定義的一部分提供。換言之,不可將下述程式碼作為類定義的一部分使用(應該位於任何方法的外部):
inst size = Integer.parseInt(getParameter("Size"));
Ticker[] s = new Ticker[size]
可把它編譯出來,但會在執行期得到一個空指標違例。但若將getParameter()初始化移入init(),則可正常工作。程式片框架會進行必要的啟動工作,以便在進入init()前收集好一些引數。
此外,上述程式碼被同時設定成一個程式片和一個應用(程式)。在它是應用程式的情況下,size引數可從命令列裡提取出來(否則就提供一個預設的值)。
陣列的長度建好以後,就可以建立新的Ticker物件;作為Ticker構建器的一部分,用於每個Ticker的按鈕和文字欄位就會加入程式片。
按下Start按鈕後,會在整個Ticker數組裡遍歷,併為每個Ticker呼叫start()。記住,start()會進行必要的執行緒初始化工作,然後為那個執行緒呼叫run()。
ToggleL監視器只是簡單地切換Ticker中的標記,一旦對應執行緒以後需要修改這個標記,它會作出相應的反應。
這個例子的一個好處是它使我們能夠方便地建立由單獨子任務構成的大型集合,並以監視它們的行為。在這種情況下,我們會發現隨著子任務數量的增多,機器顯示出來的數字可能會出現更大的分歧,這是由於為執行緒提供服務的方式造成的。
亦可試著體驗一下sleep(100)在Ticker.run()中的重要作用。若刪除sleep(),那麼在按下一個切換按鈕前,情況仍然會進展良好。按下按鈕以後,那個特定的執行緒就會出現一個失敗的runFlag,而且run()會深深地陷入一個無限迴圈——很難在多工處理期間中止退出。因此,程式對使用者操作的反應靈敏度會大幅度降低。

14.1.5 Daemon執行緒
“Daemon”執行緒的作用是在程式的執行期間於後臺提供一種“常規”服務,但它並不屬於程式的一個基本部分。因此,一旦所有非Daemon執行緒完成,程式也會中止執行。相反,假若有任何非Daemon執行緒仍在執行(比如還有一個正在執行main()的執行緒),則程式的執行不會中止。
通過呼叫isDaemon(),可調查一個執行緒是不是一個Daemon,而且能用setDaemon()開啟或者關閉一個執行緒的Daemon狀態。如果是一個Daemon執行緒,那麼它建立的任何執行緒也會自動具備Daemon屬性。
下面這個例子演示了Daemon執行緒的用法:

768-769頁程式

Daemon執行緒可將自己的Daemon標記設定成“真”,然後產生一系列其他執行緒,而且認為它們也具有Daemon屬性。隨後,它進入一個無限迴圈,在其中呼叫yield(),放棄對其他程序的控制。在這個程式早期的一個版本中,無限迴圈會使int計數器增值,但會使整個程式都好象陷入停頓狀態。換用yield()後,卻可使程式充滿“活力”,不會使人產生停滯或反應遲鈍的感覺。
一旦main()完成自己的工作,便沒有什麼能阻止程式中斷執行,因為這裡執行的只有Daemon執行緒。所以能看到啟動所有Daemon執行緒後顯示出來的結果,System.in也進行了相應的設定,使程式中斷前能等待一個回車。如果不進行這樣的設定,就只能看到建立Daemon執行緒的一部分結果(試試將readLine()程式碼換成不同長度的sleep()呼叫,看看會有什麼表現)。

14.2 共享有限的資源
可將單執行緒程式想象成一種孤立的實體,它能遍歷我們的問題空間,而且一次只能做一件事情。由於只有一個實體,所以永遠不必擔心會有兩個實體同時試圖使用相同的資源,就象兩個人同時都想停到一個車位,同時都想通過一扇門,甚至同時發話。
進入多執行緒環境後,它們則再也不是孤立的。可能會有兩個甚至更多的執行緒試圖同時同一個有限的資源。必須對這種潛在資源衝突進行預防,否則就可能發生兩個執行緒同時訪問一個銀行帳號,列印到同一臺計算機,以及對同一個值進行調整等等。

14.2.1 資源訪問的錯誤方法
現在考慮換成另一種方式來使用本章頻繁見到的計數器。在下面的例子中,每個執行緒都包含了兩個計數器,它們在run()裡增值以及顯示。除此以外,我們使用了Watcher類的另一個執行緒。它的作用是監視計數器,檢查它們是否保持相等。這表面是一項無意義的行動,因為如果檢視程式碼,就會發現計數器肯定是相同的。但實際情況卻不一定如此。下面是程式的第一個版本:

770-773頁程式

和往常一樣,每個計數器都包含了自己的顯示元件:兩個文字欄位以及一個標籤。根據它們的初始值,可知道計數是相同的。這些元件在TwoCounter構建器加入Container。由於這個執行緒是通過使用者的一個“按下按鈕”操作啟動的,所以start()可能被多次呼叫。但對一個執行緒來說,對Thread.start()的多次呼叫是非法的(會產生違例)。在started標記和過載的start()方法中,大家可看到針對這一情況採取的防範措施。
在run()中,count1和count2的增值與顯示方式表面上似乎能保持它們完全一致。隨後會呼叫sleep();若沒有這個呼叫,程式便會出錯,因為那會造成CPU難於交換任務。
synchTest()方法採取的似乎是沒有意義的行動,它檢查count1是否等於count2;如果不等,就把標籤設為“Unsynched”(不同步)。但是首先,它呼叫的是類Sharing1的一個靜態成員,以便增值和顯示一個訪問計數器,指出這種檢查已成功進行了多少次(這樣做的理由會在本例的其他版本中變得非常明顯)。
Watcher類是一個執行緒,它的作用是為處於活動狀態的所有TwoCounter物件都呼叫synchTest()。其間,它會對Sharing1物件中容納的陣列進行遍歷。可將Watcher想象成它掠過TwoCounter物件的肩膀不斷地“偷看”。
Sharing1包含了TwoCounter物件的一個數組,它通過init()進行初始化,並在我們按下“start”按鈕後作為執行緒啟動。以後若按下“Observe”(觀察)按鈕,就會建立一個或者多個觀察器,並對毫不設防的TwoCounter進行調查。
注意為了讓它作為一個程式片在瀏覽器中執行,Web頁需要包含下面這幾行:

774頁上程式

可自行改變寬度、高度以及引數,根據自己的意願進行試驗。若改變了size和observers,程式的行為也會發生變化。我們也注意到,通過從命令列接受引數(或者使用預設值),它被設計成作為一個獨立的應用程式執行。
下面才是最讓人“不可思議”的。在TwoCounter.run()中,無限迴圈只是不斷地重複相鄰的行:
t1.setText(Integer.toString(count1++));
t2.setText(Integer.toString(count2++));
(和“睡眠”一樣,不過在這裡並不重要)。但在程式執行的時候,你會發現count1和count2被“觀察”(用Watcher觀察)的次數是不相等的!這是由執行緒的本質造成的——它們可在任何時候掛起(暫停)。所以在上述兩行的執行時刻之間,有時會出現執行暫停現象。同時,Watcher執行緒也正好跟隨著進來,並正好在這個時候進行比較,造成計數器出現不相等的情況。
本例揭示了使用執行緒時一個非常基本的問題。我們跟無從知道一個執行緒什麼時候執行。想象自己坐在一張桌子前面,桌上放有一把叉子,準備叉起自己的最後一塊食物。當叉子要碰到食物時,食物卻突然消失了(因為這個執行緒已被掛起,同時另一個執行緒進來“偷”走了食物)。這便是我們要解決的問題。
有的時候,我們並不介意一個資源在嘗試使用它的時候是否正被訪問(食物在另一些盤子裡)。但為了讓多執行緒機制能夠正常運轉,需要採取一些措施來防止兩個執行緒訪問相同的資源——至少在關鍵的時期。
為防止出現這樣的衝突,只需線上程使用一個資源時為其加鎖即可。訪問資源的第一個執行緒會其加上鎖以後,其他執行緒便不能再使用那個資源,除非被解鎖。如果車子的前座是有限的資源,高喊“這是我的!”的孩子會主張把它鎖起來。

14.2.2 Java如何共享資源
對一種特殊的資源——物件中的記憶體——Java提供了內建的機制來防止它們的衝突。由於我們通常將資料元素設為從屬於private(私有)類,然後只通過方法訪問那些記憶體,所以只需將一個特定的方法設為synchronized(同步的),便可有效地防止衝突。在任何時刻,只可有一個執行緒呼叫特定物件的一個synchronized方法(儘管那個執行緒可以呼叫多個物件的同步方法)。下面列出簡單的synchronized方法:
synchronized void f() { /* ... */ }
synchronized void g() { /* ... */ }
每個物件都包含了一把鎖(也叫作“監視器”),它自動成為物件的一部分(不必為此寫任何特殊的程式碼)。呼叫任何synchronized方法時,物件就會被鎖定,不可再呼叫那個物件的其他任何synchronized方法,除非第一個方法完成了自己的工作,並解除鎖定。在上面的例子中,如果為一個物件呼叫f(),便不能再為同樣的物件呼叫g(),除非f()完成並解除鎖定。因此,一個特定物件的所有synchronized方法都共享著一把鎖,而且這把鎖能防止多個方法對通用記憶體同時進行寫操作(比如同時有多個執行緒)。
每個類也有自己的一把鎖(作為類的Class物件的一部分),所以synchronized static方法可在一個類的範圍內被相互間鎖定起來,防止與static資料的接觸。
注意如果想保護其他某些資源不被多個執行緒同時訪問,可以強制通過synchronized方訪問那些資源。

1. 計數器的同步
裝備了這個新關鍵字後,我們能夠採取的方案就更靈活了:可以只為TwoCounter中的方法簡單地使用synchronized關鍵字。下面這個例子是對前例的改版,其中加入了新的關鍵字:

775-778頁程式

我們注意到無論run()還是synchTest()都是“同步的”。如果只同步其中的一個方法,那麼另一個就可以自由忽視物件的鎖定,並可無礙地呼叫。所以必須記住一個重要的規則:對於訪問某個關鍵共享資源的所有方法,都必須把它們設為synchronized,否則就不能正常地工作。
現在又遇到了一個新問題。Watcher2永遠都不能看到正在進行的事情,因為整個run()方法已設為“同步”。而且由於肯定要為每個物件執行run(),所以鎖永遠不能開啟,而synchTest()永遠不會得到呼叫。之所以能看到這一結果,是因為accessCount根本沒有變化。
為解決這個問題,我們能採取的一個辦法是隻將run()中的一部分程式碼隔離出來。想用這個辦法隔離出來的那部分程式碼叫作“關鍵區域”,而且要用不同的方式來使用synchronized關鍵字,以設定一個關鍵區域。Java通過“同步塊”提供對關鍵區域的支援;這一次,我們用synchronized關鍵字指出物件的鎖用於對其中封閉的程式碼進行同步。如下所示:

779頁中程式

在能進入同步塊之前,必須在synchObject上取得鎖。如果已有其他執行緒取得了這把鎖,塊便不能進入,必須等候那把鎖被釋放。
可從整個run()中刪除synchronized關鍵字,換成用一個同步塊包圍兩個關鍵行,從而完成對Sharing2例子的修改。但什麼物件應作為鎖來使用呢?那個物件已由synchTest()標記出來了——也就是當前物件(this)!所以修改過的run()方法象下面這個樣子:

779頁下程式

這是必須對Sharing2.java作出的唯一修改,我們會看到儘管兩個計數器永遠不會脫離同步(取決於允許Watcher什麼時候檢查它們),但在run()執行期間,仍然向Watcher提供了足夠的訪問許可權。
當然,所有同步都取決於程式設計師是否勤奮:要訪問共享資源的每一部分程式碼都必須封裝到一個適當的同步塊裡。

2. 同步的效率
由於要為同樣的資料編寫兩個方法,所以無論如何都不會給人留下效率很高的印象。看來似乎更好的一種做法是將所有方法都設為自動同步,並完全消除synchronized關鍵字(當然,含有synchronized run()的例子顯示出這樣做是很不通的)。但它也揭示出獲取一把鎖並非一種“廉價”方案——為一次方法呼叫付出的代價(進入和退出方法,不執行方法主體)至少要累加到四倍,而且根據我們的具體現方案,這一代價還有可能變得更高。所以假如已知一個方法不會造成衝突,最明智的做法便是撤消其中的synchronized關鍵字。

14.2.3 回顧Java Beans
我們現在已理解了同步,接著可換從另一個角度來考察Java Beans。無論什麼時候建立了一個Bean,就必須假定它要在一個多執行緒的環境中執行。這意味著:
(1) 只要可行,Bean的所有公共方法都應同步。當然,這也帶來了“同步”在執行期間的開銷。若特別在意這個問題,在關鍵區域中不會造成問題的方法就可保留為“不同步”,但注意這通常都不是十分容易判斷。有資格的方法傾向於規模很小(如下例的getCircleSize())以及/或者“微小”。也就是說,這個方法呼叫在如此少的程式碼片裡執行,以至於在執行期間物件不能改變。如果將這種方法設為“不同步”,可能對程式的執行速度不會有明顯的影響。可能也將一個Bean的所有public方法都設為synchronized,並只有在保證特別必要、而且會造成一個差異的情況下,才將synchronized關鍵字刪去。
(2) 如果將一個多造型事件送給一系列對那個事件感興趣的“聽眾”,必須假在列表中移動的時候可以新增或者刪除。

第一點很容易處理,但第二點需要考慮更多的東西。讓我們以前一章提供的BangBean.java為例。在那個例子中,我們忽略了synchronized關鍵字(那時還沒有引入呢),並將造型設為單造型,從而回避了多執行緒的問題。在下面這個修改過的版本中,我們使其能在多執行緒環境中工作,併為事件採用了多造型技術:

781-784頁程式

很容易就可以為方法新增synchronized。但注意在addActionListener()和removeActionListener()中,現在添加了ActionListener,並從一個Vector中移去,所以能夠根據自己願望使用任意多個。
我們注意到,notifyListeners()方法並未設為“同步”。可從多個執行緒中發出對這個方法的呼叫。另外,在對notifyListeners()呼叫的中途,也可能發出對addActionListener()和removeActionListener()的呼叫。這顯然會造成問題,因為它否定了Vector actionListeners。為緩解這個問題,我們在一個synchronized從句中“克隆”了Vector,並對克隆進行了否定。這樣便可在不影響notifyListeners()的前提下,對Vector進行操縱。
paint()方法也沒有設為“同步”。與單純地新增自己的方法相比,決定是否對過載的方法進行同步要困難得多。在這個例子中,無論paint()是否“同步”,它似乎都能正常地工作。但必須考慮的問題包括:
(1) 方法會在物件內部修改“關鍵”變數的狀態嗎?為判斷一個變數是否“關鍵”,必須知道它是否會被程式中的其他執行緒讀取或設定(就目前的情況看,讀取或設定幾乎肯定是通過“同步”方法進行的,所以可以只對它們進行檢查)。對paint()的情況來說,不會發生任何修改。
(2) 方法要以這些“關鍵”變數的狀態為基礎嗎?如果一個“同步”方法修改了一個變數,而我們的方法要用到這個變數,那麼一般都願意把自己的方法也設為“同步”。基於這一前提,大家可觀察到cSize由“同步”方法進行了修改,所以paint()應當是“同步”的。但在這裡,我們可以問:“假如cSize在paint()執行期間發生了變化,會發生的最糟糕的事情是什麼呢?”如果發現情況不算太壞,而且僅僅是暫時的效果,那麼最好保持paint()的“不同步”狀態,以避免同步方法呼叫帶來的額外開銷。
(3) 要留意的第三條線索是paint()基礎類版本是否“同步”,在這裡它不是同步的。這並不是一個非常嚴格的引數,僅僅是一條“線索”。比如在目前的情況下,通過同步方法(好cSize)改變的一個欄位已合成到paint()公式裡,而且可能已改變了情況。但請注意,synchronized不能繼承——也就是說,假如一個方法在基礎類中是“同步”的,那麼在衍生類過載版本中,它不會自動進入“同步”狀態。
TestBangBean2中的測試程式碼已在前一章的基礎上進行了修改,已在其中加入了額外的“聽眾”,從而演示了BangBean2的多造型能力。

14.3 堵塞
一個執行緒可以有四種狀態:
(1) 新(New):執行緒物件已經建立,但尚未啟動,所以不可執行。
(2) 可執行(Runnable):意味著一旦時間分片機制有空閒的CPU週期提供給一個執行緒,那個執行緒便可立即開始執行。因此,執行緒可能在、也可能不在運行當中,但一旦條件許可,沒有什麼能阻止它的執行——它既沒有“死”掉,也未被“堵塞”。
(3) 死(Dead):從自己的run()方法中返回後,一個執行緒便已“死”掉。亦可呼叫stop()令其死掉,但會產生一個違例——屬於Error的一個子類(也就是說,我們通常不捕獲它)。記住一個違例的“擲”出應當是一個特殊事件,而不是正常程式執行的一部分。所以不建議你使用stop()(在Java 1.2則是堅決反對)。另外還有一個destroy()方法(它永遠不會實現),應該儘可能地避免呼叫它,因為它非常武斷,根本不會解除物件的鎖定。
(4) 堵塞(Blocked):執行緒可以執行,但有某種東西阻礙了它。若執行緒處於堵塞狀態,排程機制可以簡單地跳過它,不給它分配任何CPU時間。除非執行緒再次進入“可執行”狀態,否則不會採取任何操作。

14.3.1 為何會堵塞
堵塞狀態是前述四種狀態中最有趣的,值得我們作進一步的探討。執行緒被堵塞可能是由下述五方面的原因造成的:
(1) 呼叫sleep(毫秒數),使執行緒進入“睡眠”狀態。在規定的時間內,這個執行緒是不會執行的。
(2) 用suspend()暫停了執行緒的執行。除非執行緒收到resume()訊息,否則不會返回“可執行”狀態。
(3) 用wait()暫停了執行緒的執行。除非執行緒收到nofify()或者notifyAll()訊息,否則不會變成“可執行”(是的,這看起來同原因2非常相象,但有一個明顯的區別是我們馬上要揭示的)。
(4) 執行緒正在等候一些IO(輸入輸出)操作完成。
(5) 執行緒試圖呼叫另一個物件的“同步”方法,但那個物件處於鎖定狀態,暫時無法使用。

亦可呼叫yield()(Thread類的一個方法)自動放棄CPU,以便其他執行緒能夠執行。然而,假如排程機制覺得我們的執行緒已擁有足夠的時間,並跳轉到另一個執行緒,就會發生同樣的事情。也就是說,沒有什麼能防止排程機制重新啟動我們的執行緒。執行緒被堵塞後,便有一些原因造成它不能繼續執行。
下面這個例子展示了進入堵塞狀態的全部五種途徑。它們全都存在於名為Blocking.java的一個檔案中,但在這兒採用散落的片斷進行解釋(大家可注意到片斷前後的“Continued”以及“Continuing”標誌。利用第17章介紹的工具,可將這些片斷連結到一起)。首先讓我們看看基本的框架:

786-787頁程式

Blockable類打算成為本例所有類的一個基礎類。一個Blockable物件包含了一個名為state的TextField(文字欄位),用於顯示出物件有關的資訊。用於顯示這些資訊的方法叫作update()。我們發現它用getClass.getName()來產生類名,而不是僅僅把它打印出來;這是由於update(0不知道自己為其呼叫的那個類的準確名字,因為那個類是從Blockable衍生出來的。
在Blockable中,變動指示符是一個int i;衍生類的run()方法會為其增值。
針對每個Bloackable物件,都會啟動Peeker類的一個執行緒。Peeker的任務是呼叫read()方法,檢查與自己關聯的Blockable物件,看看i是否發生了變化,最後用它的status文字欄位報告檢查結果。注意read()和update()都是同步的,要求物件的鎖定能自由解除,這一點非常重要。

1. 睡眠
這個程式的第一項測試是用sleep()作出的:

788-789頁程式

在Sleeper1中,整個run()方法都是同步的。我們可看到與這個物件關聯在一起的Peeker可以正常執行,直到我們啟動執行緒為止,隨後Peeker便會完全停止。這正是“堵塞”的一種形式:因為Sleeper1.run()是同步的,而且一旦執行緒啟動,它就肯定在run()內部,方法永遠不會放棄物件鎖定,造成Peeker執行緒的堵塞。
Sleeper2通過設定不同步的執行,提供了一種解決方案。只有change()方法才是同步的,所以儘管run()位於sleep()內部,Peeker仍然能訪問自己需要的同步方法——read()。在這裡,我們可看到在啟動了Sleeper2執行緒以後,Peeker會持續執行下去。

2. 暫停和恢復
這個例子接下來的一部分引入了“掛起”或者“暫停”(Suspend)的概述。Thread類提供了一個名為suspend()的方法,可臨時中止執行緒;以及一個名為resume()的方法,用於從暫停處開始恢復執行緒的執行。顯然,我們可以推斷出resume()是由暫停執行緒外部的某個執行緒呼叫的。在這種情況下,需要用到一個名為Resumer(恢復器)的獨立類。演示暫停/恢復過程的每個類都有一個相關的恢復器。如下所示:

789-790頁程式

SuspendResume1也提供了一個同步的run()方法。同樣地,當我們啟動這個執行緒以後,就會發現與它關聯的Peeker進入“堵塞”狀態,等候物件鎖被釋放,但那永遠不會發生。和往常一樣,這個問題在SuspendResume2裡得到了解決,它並不同步整個run()方法,而是採用了一個單獨的同步change()方法。
對於Java 1.2,大家應注意suspend()和resume()已獲得強烈反對,因為suspend()包含了物件鎖,所以極易出現“死鎖”現象。換言之,很容易就會看到許多被鎖住的物件在傻乎乎地等待對方。這會造成整個應用程式的“凝固”。儘管在一些老程式中還能看到它們的蹤跡,但在你寫自己的程式時,無論如何都應避免。本章稍後就會講述正確的方案是什麼。

3. 等待和通知
通過前兩個例子的實踐,我們知道無論sleep()還是suspend()都不會在自己被呼叫的時候解除鎖定。需要用到物件鎖時,請務必注意這個問題。在另一方面,wait()方法在被呼叫時卻會解除鎖定,這意味著可在執行wait()期間呼叫執行緒物件中的其他同步方法。但在接著的兩個類中,我們看到run()方法都是“同步”的。在wait()期間,Peeker仍然擁有對同步方法的完全訪問許可權。這是由於wait()在掛起內部呼叫的方法時,會解除物件的鎖定。
我們也可以看到wait()的兩種形式。第一種形式採用一個以毫秒為單位的引數,它具有與sleep()中相同的含義:暫停這一段規定時間。區別在於在wait()中,物件鎖已被解除,而且能夠自由地退出wait(),因為一個notify()可強行使時間流逝。
第二種形式不採用任何引數,這意味著wait()會持續執行,直到notify()介入為止。而且在一段時間以後,不會自行中止。
wait()和notify()比較特別的一個地方是這兩個方法都屬於基礎類Object的一部分,不象sleep(),suspend()以及resume()那樣屬於Thread的一部分。儘管這表面看有點兒奇怪——居然讓專門進行執行緒處理的東西成為通用基礎類的一部分——但仔細想想又會釋然,因為它們操縱的物件鎖也屬於每個物件的一部分。因此,我們可將一個wait()置入任何同步方法內部,無論在那個類裡是否準備進行涉及執行緒的處理。事實上,我們能呼叫wait()的唯一地方是在一個同步的方法或程式碼塊內部。若在一個不同步的方法內呼叫wait()或者notify(),儘管程式仍然會編譯,但在執行它的時候,就會得到一個IllegalMonitorStateException(非法監視器狀態違例),而且會出現多少有點莫名其妙的一條訊息:“current thread not owner”(當前執行緒不是所有人”。注意sleep(),suspend()以及resume()都能在不同步的方法內呼叫,因為它們不需要對鎖定進行操作。
只能為自己的鎖定呼叫wait()和notify()。同樣地,仍然可以編譯那些試圖使用錯誤鎖定的程式碼,但和往常一樣會產生同樣的IllegalMonitorStateException違例。我們沒辦法用其他人的物件鎖來愚弄系統,但可要求另一個物件執行相應的操作,對它自己的鎖進行操作。所以一種做法是建立一個同步方法,令其為自己的物件呼叫notify()。但在Notifier中,我們會看到一個同步方法內部的notify():

792頁上程式

其中,wn2是型別為WaitNotify2的物件。儘管並不屬於WaitNotify2的一部分,這個方法仍然獲得了wn2物件的鎖定。在這個時候,它為wn2呼叫notify()是合法的,不會得到IllegalMonitorStateException違例。

792-793頁程式

若必須等候其他某些條件(從執行緒外部加以控制)發生變化,同時又不想線上程內一直傻乎乎地等下去,一般就需要用到wait()。wait()允許我們將執行緒置入“睡眠”狀態,同時又“積極”地等待條件發生改變。而且只有在一個notify()或notifyAll()發生變化的時候,執行緒才會被喚醒,並檢查條件是否有變。因此,我們認為它提供了線上程間進行同步的一種手段。

4. IO堵塞
若一個數據流必須等候一些IO活動,便會自動進入“堵塞”狀態。在本例下面列出的部分中,有兩個類協同通用的Reader以及Writer物件工作(使用Java 1.1的流)。但在測試模型中,會設定一個管道化的資料流,使兩個執行緒相互間能安全地傳遞資料(這正是使用管道流的目的)。
Sender將資料置入Writer,並“睡眠”隨機長短的時間。然而,Receiver本身並沒有包括sleep(),suspend()或者wait()方法。但在執行read()的時候,如果沒有資料存在,它會自動進入“堵塞”狀態。如下所示:

793-794頁程式

這兩個類也將資訊送入自己的state欄位,並修改i值,使Peeker知道執行緒仍在執行。

5. 測試
令人驚訝的是,主要的程式片(Applet)類非常簡單,這是大多數工作都已置入Blockable框架的緣故。大概地說,我們建立了一個由Blockable物件構成的陣列。而且由於每個物件都是一個執行緒,所以在按下“start”按鈕後,它們會採取自己的行動。還有另一個按鈕和actionPerformed()從句,用於中止所有Peeker物件。由於Java 1.2“反對”使用Thread的stop()方法,所以可考慮採用這種折衷形式的中止方式。
為了在Sender和Receiver之間建立一個連線,我們建立了一個PipedWriter和一個PipedReader。注意PipedReader in必須通過一個構建器引數同PipedWriterout連線起來。在那以後,我們在out內放進去的所有東西都可從in中提取出來——似乎那些東西是通過一個“管道”傳輸過去的。隨後將in和out物件分別傳遞給Receiver和Sender構建器;後者將它們當作任意型別的Reader和Writer看待(也就是說,它們被“上溯”造型了)。
Blockable控制代碼b的陣列在定義之初並未得到初始化,因為管道化的資料流是不可在定義前設定好的(對try塊的需要將成為障礙):

795-796頁程式

在init()中,注意迴圈會遍歷整個陣列,併為頁新增state和peeker.status文字欄位。
首次建立好Blockable執行緒以後,每個這樣的執行緒都會自動建立並啟動自己的Peeker。所以我們會看到各個Peeker都在Blockable執行緒啟動之前執行起來。這一點非常重要,因為在Blockable執行緒啟動的時候,部分Peeker會被堵塞,並停止執行。弄懂這一點,將有助於我們加深對“堵塞”這一概念的認識。

14.3.2 死鎖
由於執行緒可能進入堵塞狀態,而且由於物件可能擁有“同步”方法——除非同步鎖定被解除,否則執行緒不能訪問那個物件——所以一個執行緒完全可能等候另一個物件,而另一個物件又在等候下一個物件,以此類推。這個“等候”鏈最可怕的情形就是進入封閉狀態——最後那個物件等候的是第一個物件!此時,所有執行緒都會陷入無休止的相互等待狀態,大家都動彈不得。我們將這種情況稱為“死鎖”。儘管這種情況並非經常出現,但一旦碰到,程式的除錯將變得異常艱難。
就語言本身來說,尚未直接提供防止死鎖的幫助措施,需要我們通過謹慎的設計來避免。如果有誰需要除錯一個死鎖的程式,他是沒有任何竅門可用的。

1. Java 1.2對stop(),suspend(),resume()以及destroy()的反對
為減少出現死鎖的可能,Java 1.2作出的一項貢獻是“反對”使用Thread的stop(),suspend(),resume()以及destroy()方法。
之所以反對使用stop(),是因為它不安全。它會解除由執行緒獲取的所有鎖定,而且如果物件處於一種不連貫狀態(“被破壞”),那麼其他執行緒能在那種狀態下檢查和修改它們。結果便造成了一種微妙的局面,我們很難檢查出真正的問題所在。所以應儘量避免使用stop(),應該採用Blocking.java那樣的方法,用一個標誌告訴執行緒什麼時候通過退出自己的run()方法來中止自己的執行。
如果一個執行緒被堵塞,比如在它等候輸入的時候,那麼一般都不能象在Blocking.java中那樣輪詢一個標誌。但在這些情況下,我們仍然不該使用stop(),而應換用由Thread提供的interrupt()方法,以便中止並退出堵塞的程式碼。

797-798頁程式

Blocked.run()內部的wait()會產生堵塞的執行緒。當我們按下按鈕以後,blocked(堵塞)的控制代碼就會設為null,使垃圾收集器能夠將其清除,然後呼叫物件的interrupt()方法。如果是首次按下按鈕,我們會看到執行緒正常退出。但在沒有可供“殺死”的執行緒以後,看到的便只是按鈕被按下而已。
suspend()和resume()方法天生容易發生死鎖。呼叫suspend()的時候,目標執行緒會停下來,但卻仍然持有在這之前獲得的鎖定。此時,其他任何執行緒都不能訪問鎖定的資源,除非被“掛起”的執行緒恢復執行。對任何執行緒來說,如果它們想恢復目標執行緒,同時又試圖使用任何一個鎖定的資源,就會造成令人難堪的死鎖。所以我們不應該使用suspend()和resume(),而應在自己的Thread類中置入一個標誌,指出執行緒應該活動還是掛起。若標誌指出執行緒應該掛起,便用wait()命其進入等待狀態。若標誌指出執行緒應當恢復,則用一個notify()重新啟動執行緒。我們可以修改前面的Counter2.java來實際體驗一番。儘管兩個版本的效果是差不多的,但大家會注意到程式碼的組織結構發生了很大的變化——為所有“聽眾”都使用了匿名的內部類,而且Thread是一個內部類。這使得程式的編寫稍微方便一些,因為它取消了Counter2.java中一些額外的記錄工作。

799-801頁程式

Suspendable中的suspended(已掛起)標誌用於開關“掛起”或者“暫停”狀態。為掛起一個執行緒,只需呼叫fauxSuspend()將標誌設為true(真)即可。對標誌狀態的偵測是在run()內進行的。就象本章早些時候提到的那樣,wait()必須設為“同步”(synchronized),使其能夠使用物件鎖。在fauxResume()中,suspended標誌被設為false(假),並呼叫notify()——由於這會在一個“同步”從句中喚醒wait(),所以fauxResume()方法也必須同步,使其能在呼叫notify()之前取得物件鎖(這樣一來,物件鎖可由要喚醍的那個wait()使用)。如果遵照本程式展示的樣式,可以避免使用wait()和notify()。
Thread的destroy()方法根本沒有實現;它類似一個根本不能恢復的suspend(),所以會發生與suspend()一樣的死鎖問題。然而,這一方法沒有得到明確的“反對”,也許會在Java以後的版本(1.2版以後)實現,用於一些可以承受死鎖危險的特殊場合。
大家可能會奇怪當初為什麼要實現這些現在又被“反對”的方法。之所以會出現這種情況,大概是由於Sun公司主要讓技術人員來決定對語言的改動,而不是那些市場銷售人員。通常,技術人員比搞銷售的更能理解語言的實質。當初犯下了錯誤以後,也能較為理智地正視它們。這意味著Java能夠繼續進步,即便這使Java程式設計師多少感到有些不便。就我自己來說,寧願面對這些不便之處,也不願看到語言停滯不前。

14.4 優先順序
執行緒的優先順序(Priority)告訴除錯程式該執行緒的重要程度有多大。如果有大量執行緒都被堵塞,都在等候執行,除錯程式會首先執行具有最高優先順序的那個執行緒。然而,這並不表示優先順序較低的執行緒不會執行(換言之,不會因為存在優先順序而導致死鎖)。若執行緒的優先順序較低,只不過表示它被准許執行的機會小一些而已。
可用getPriority()方法讀取一個執行緒的優先順序,並用setPriority()改變它。在下面這個程式片中,大家會發現計數器的計數速度慢了下來,因為它們關聯的執行緒分配了較低的優先順序:

802-805頁程式

Ticker採用本章前面構造好的形式,但有一個額外的TextField(文字欄位),用於顯示執行緒的優先順序;以及兩個額外的按鈕,用於人為提高及降低優先順序。
也要注意yield()的用法,它將控制權自動返回給除錯程式(機制)。若不進行這樣的處理,多執行緒機制仍會工作,但我們會發現它的執行速度慢了下來(試試刪去對yield()的呼叫)。亦可呼叫sleep(),但假若那樣做,計數頻率就會改由sleep()的持續時間控制,而不是優先順序。
Counter5中的init()建立了由10個Ticker2構成的一個數組;它們的按鈕以及輸入欄位(文字欄位)由Ticker2構建器置入窗體。Counter5增加了新的按鈕,用於啟動一切,以及用於提高和降低執行緒組的最大優先順序。除此以外,還有一些標籤用於顯示一個執行緒可以採用的最大及最小優先順序;以及一個特殊的文字欄位,用於顯示執行緒組的最大優先順序(在下一節裡,我們將全面討論執行緒組的問題)。最後,父執行緒組的優先順序也作為標籤顯示出來。
按下“up”(上)或“down”(下)按鈕的時候,會先取得Ticker2當前的優先順序,然後相應地提高或者降低。
執行該程式時,我們可注意到幾件事情。首先,執行緒組的預設優先順序是5。即使在啟動執行緒之前(或者在建立執行緒之前,這要求對程式碼進行適當的修改)將最大優先順序降到5以下,每個執行緒都會有一個5的預設優先順序。
最簡單的測試是獲取一個計數器,將它的優先順序降低至1,此時應觀察到它的計數頻率顯著放慢。現在試著再次提高優先順序,可以升高回執行緒組的優先順序,但不能再高了。現在將執行緒組的優先順序降低兩次。執行緒的優先順序不會改變,但假若試圖提高或者降低它,就會發現這個優先順序自動變成執行緒組的優先順序。此外,新執行緒仍然具有一個預設優先順序,即使它比組的優先順序還要高(換句話說,不要指望利用組優先順序來防止新執行緒擁有比現有的更高的優先順序)。
最後,試著提高組的最大優先順序。可以發現,這樣做是沒有效果的。我們只能減少執行緒組的最大優先順序,而不能增大它。

14.4.1 執行緒組
所有執行緒都隸屬於一個執行緒組。那可以是一個預設執行緒組,亦可是一個建立執行緒時明確指定的組。在建立之初,執行緒被限制到一個組裡,而且不能改變到一個不同的組。每個應用都至少有一個執行緒從屬於系統執行緒組。若建立多個執行緒而不指定一個組,它們就會自動歸屬於系統執行緒組。
執行緒組也必須從屬於其他執行緒組。必須在構建器裡指定新執行緒組從屬於哪個執行緒組。若在建立一個執行緒組的時候沒有指定它的歸屬,則同樣會自動成為系統執行緒組的一名屬下。因此,一個應用程式中的所有執行緒組最終都會將系統執行緒組作為自己的“父”。
之所以要提出“執行緒組”的概念,很難從字面上找到原因。這多少為我們討論的主題帶來了一些混亂。一般地說,我們認為是由於“安全”或者“保密”方面的理由才使用執行緒組的。根據Arnold和Gosling的說法:“執行緒組中的執行緒可以修改組內的其他執行緒,包括那些位於分層結構最深處的。一個執行緒不能修改位於自己所在組或者下屬組之外的任何執行緒”(註釋①)。然而,我們很難判斷“修改”在這兒的具體含義是什麼。下面這個例子展示了位於一個“葉子組”內的執行緒能修改它所線上程組樹的所有執行緒的優先順序,同時還能為這個“樹”內的所有執行緒都呼叫一個方法。

①:《The Java Programming Language》第179頁。該書由Arnold和Jams Gosling編著,Addison-Wesley於1996年出版

807-808頁程式

在main()中,我們建立了幾個ThreadGroup(執行緒組),每個都位於不同的“葉”上:x沒有引數,只有它的名字(一個String),所以會自動進入“system”(系統)執行緒組;y位於x下方,而z位於y下方。注意初始化是按照文字順序進行的,所以程式碼合法。
有兩個執行緒建立之後進入了不同的執行緒組。其中,TestThread1沒有一個run()方法,但有一個f(),用於通知執行緒以及打印出一些東西,以便我們知道它已被呼叫。而TestThread2屬於TestThread1的一個子類,它的run()非常詳盡,要做許多事情。首先,它獲得當前執行緒所在的執行緒組,然後利用getParent()在繼承樹中向上移動兩級(這樣做是有道理的,因為我想把TestThread2在分級結構中向下移動兩級)。隨後,我們呼叫方法activeCount(),查詢這個執行緒組以及所有子執行緒組內有多少個執行緒,從而建立由指向Thread的控制代碼構成的一個數組。enumerate()方法將指向所有這些執行緒的控制代碼置入陣列gAll裡。然後在整個數組裡遍歷,為每個執行緒都呼叫f()方法,同時修改優先順序。這樣一來,位於一個“葉子”執行緒組裡的執行緒就修改了位於父執行緒組的執行緒。
除錯方法list()打印出與一個執行緒組有關的所有資訊,把它們作為標準輸出。在我們對執行緒組的行為進行調查的時候,這樣做是相當有好處的。下面是程式的輸出:

808頁下程式

list()不僅打印出ThreadGroup或者Thread的類名,也打印出了執行緒組的名字以及它的最高優先順序。對於執行緒,則打印出它們的名字,並接上執行緒優先順序以及所屬的執行緒組。注意list()會對執行緒和執行緒組進行縮排處理,指出它們是未縮排的執行緒組的“子”。
大家可看到f()是由TestThread2的run()方法呼叫的,所以很明顯,組內的所有執行緒都是相當脆弱的。然而,我們只能訪問那些從自己的system執行緒組樹分支出來的執行緒,而且或許這就是所謂“安全”的意思。我們不能訪問其他任何人的系統執行緒樹。

1. 執行緒組的控制
拋開安全問題不談,執行緒組最有用的一個地方就是控制:只需用單個命令即可完成對整個執行緒組的操作。下面這個例子演示了這一點,並對執行緒組內優先順序的限制進行了說明。括號內的註釋數字便於大家比較輸出結果:

809-810頁程式

下面的輸出結果已進行了適當的編輯,以便用一頁能夠裝下(java.lang.已被刪去),而且添加了適當的數字,與前面程式列表中括號裡的數字對應:

811頁程式

所有程式都至少有一個執行緒在執行,而且main()採取的第一項行動便是呼叫Thread的一個static(靜態)方法,名為currentThread()。從這個執行緒開始,執行緒組將被建立,而且會為結果呼叫list()。輸出如下:

812頁上程式

我們可以看到,主執行緒組的名字是system,而主執行緒的名字是main,而且它從屬於system執行緒組。
第二個練習顯示出system組的最高優先順序可以減少,而且main執行緒可以增大自己的優先順序:

812頁中上程式

第三個練習建立一個新的執行緒組,名為g1;它自動從屬於system執行緒組,因為並沒有明確指定它的歸屬關係。我們在g1內部放置了一個新執行緒,名為A。隨後,我們試著將這個組的最大優先順序設到最高的級別,並將A的優先順序也設到最高一級。結果如下:

812頁中程式

可以看出,不可能將執行緒組的最大優先順序設為高於它的父執行緒組。
第四個練習將g1的最大優先順序降低兩級,然後試著把它升至Thread.MAX_PRIORITY。結果如下:

812頁中下程式

同樣可以看出,提高最大優先順序的企圖是失敗的。我們只能降低一個執行緒組的最大優先順序,而不能提高它。此外,注意執行緒A的優先順序並未改變,而且它現在高於執行緒組的最大優先順序。也就是說,執行緒組最大優先順序的變化並不能對現有執行緒造成影響。
第五個練習試著將一個新執行緒設為最大優先順序。如下所示:

812頁下程式

因此,新執行緒不能變到比最大執行緒組優先順序還要高的一級。
這個程式的預設執行緒優先順序是6;若新建一個執行緒,那就是它的預設優先順序,而且不會發生變化,除非對優先順序進行了特別的處理。練習六將把執行緒組的最大優先順序降至預設執行緒優先順序以下,看看在這種情況下新建一個執行緒會發生什麼事情:

813頁上程式

儘管執行緒組現在的最大優先順序是3,但仍然用預設優先順序6來建立新執行緒。所以,執行緒組的最大優先順序不會影響預設優先順序(事實上,似乎沒有辦法可以設定新執行緒的預設優先順序)。
改變了優先順序後,接下來試試將其降低一級,結果如下:

813頁中程式

因此,只有在試圖改變優先順序的時候,才會強迫遵守執行緒組最大優先順序的限制。
我們在(8)和(9)中進行了類似的試驗。在這裡,我們建立了一個新的執行緒組,名為g2,將其作為g1的一個子組,並改變了它的最大優先順序。大家可以看到,g2的優先順序無論如何都不可能高於g1:

813頁中下程式

也要注意在g2建立的時候,它會被自動設為g1的執行緒組最大優先順序。
經過所有這些實驗以後,整個執行緒組和執行緒系統都會被打印出來,如下所示:

813-814頁程式

所以由執行緒組的規則所限,一個子組的最大優先順序在任何時候都只能低於或等於它的父組的最大優先順序。
本程式的最後一個部分演示了用於整組執行緒的方法。程式首先遍歷整個執行緒樹,並啟動每一個尚未啟動的執行緒。例如,system組隨後會被掛起(暫停),最後被中止(儘管用suspend()和stop()對整個執行緒組進行操作看起來似乎很有趣,但應注意這些方法在Java 1.2裡都是被“反對”的)。但在掛起system組的同時,也掛起了main執行緒,而且整個程式都會關閉。所以永遠不會達到讓執行緒中止的那一步。實際上,假如真的中止了main執行緒,它會“擲”出一個ThreadDeath違例,所以我們通常不這樣做。由於ThreadGroup是從Object繼承的,其中包含了wait()方法,所以也能呼叫wait(秒數×1000),令程式暫停執行任意秒數的時間。當然,事前必須在一個同步塊裡取得物件鎖。
ThreadGroup類也提供了suspend()和resume()方法,所以能中止和啟動整個執行緒組和它的所有執行緒,也能中止和啟動它的子組,所有這些只需一個命令即可(再次提醒,suspend()和resume()都是Java 1.2所“反對”的)。
從表面看,執行緒組似乎有些讓人摸不著頭腦,但請注意我們很少需要直接使用它們。

14.5 回顧runnable
在本章早些時候,我曾建議大家在將一個程式片或主Frame當作Runnable的實現形式之前,一定要好好地想一想。若採用那種方式,就只能在自己的程式中使用其中的一個執行緒。這便限制了靈活性,一旦需要用到屬於那種型別的多個執行緒,就會遇到不必要的麻煩。
當然,如果必須從一個類繼承,而且想使類具有執行緒處理能力,則Runnable是一種正確的方案。本章最後一個例子對這一點進行了剖析,製作了一個RunnableCanvas類,用於為自己描繪不同的顏色(Canvas是“畫布”的意思)。這個應用被設計成從命令列獲得引數值,以決定顏色網格有多大,以及顏色發生變化之間的sleep()有多長。通過運用這些值,大家能體驗到執行緒一些有趣而且可能令人費解的特性:

815-816頁程式

ColorBoxes是一個典型的應用(程式),有一個構建器用於設定GUI。這個構建器採用int grid的一個引數,用它設定GridLayout(網格佈局),使每一