程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> 關於JAVA >> TIJ閱讀筆記(第十三章)

TIJ閱讀筆記(第十三章)

編輯:關於JAVA

13: 並發編程

面向對象使我們能將程序劃分成相互獨立的模塊。但是你時常還會碰到,不但要把程序分解開來,而且還要讓它的各個部分都能獨立運行的問題。

這種能獨立運行的子任務就是線程(thread)。編程的時候,你可以認為線程都是能獨立運行的,有自己CPU的子任務。實際上,是一些底層機制在為你分割CPU的時間,只是你不知道罷了。這種做法能簡化多線程的編程。

進程(process)是一種有專屬地址空間的"自含式(self-contained)"程序。通過在不同的任務之間定時切換CPU,多任務(multitasking)操作系統營造出一種同一個時點可以有多個進程(程序)在同時運行的效果。線程是進程內部的獨立的,有序的指令流。由此,一個進程能包含多個並發執行的線程。

多線程的用途很廣,但歸納起來不外乎,程序的某一部分正在等一個事件或資源,而你又不想讓它把整個程序都給阻塞了。因此你可以創建一個與該事件或資源相關的線程,讓它與主程序分開來運行。

學習並發編程就像是去探訪一個新的世界,同時學習一種新的編程語言,最起碼也得接受一套新的理念。隨著絕大多數的微電腦操作系統提供了多線程支持,編程語言和類庫也做了相應的擴展。總而言之,多線程編程:

看上去不但神秘,而且還要求你改變編程的觀念各種語言對多線程的支持大同小異,所以理解線程就等於掌握了一種通用語言

理解並發編程的難度不亞於理解多態性。多線程看著容易其實很難。

動機

並發編程的一個最主要的用途就是創建反應靈敏的用戶界面。試想有這麼一個程序,由於要進行大量的CPU密集的運算,它完全忽略了用戶輸入,以致於變得非常遲鈍了。要解決這種問題,關鍵在於,程序在進行運算的同時,還要時不時地將控制權交還給用戶界面,這樣才能對用戶的操作做出及時的響應。假設有一個"quit"按鈕,你總不會希望每寫一段代碼就做一次輪詢的吧,你要的是"quit"能及時響應用戶的操作,就像你在定時檢查一樣。

常規的方法是不可能在運行指令的同時還把控制權交給其他程序的。這聽上去簡直就是在天方夜譚,就好像CPU能同時出現在兩個地方,但是多線程所營造的正是這個效果。

並發編程還能用來優化吞吐率。

如果是多處理器的系統,線程還會被分到多個處理器上。

有一點要記住,那就是多線程程序也必須能運行在單CPU系統上。

多線程最值得稱道的還是它的底層抽象,即代碼無需知道它是運行在單CPU還是多CPU的系統上。多任務與多線程是充分利用多處理器系統的好辦法。

多線程能令你設計出更為松散耦合(more loosely-coupled)的應用程序。

基本線程

要想創建線程,最簡單的辦法就是繼承Java.lang.Thread。這個類已經為線程的創建和運行做了必要的配置。run()是Thread最重要的方法,要想讓線程替你辦事,你就必須覆寫這個方法。由此可知,run( )所包含的就是要和程序裡其它線程"同時"執行的代碼。

main( )創建了Thread,但是卻沒去拿它的reference。如果是普通對象,這一點就足以讓它成為垃圾,但Thread不會。Thread都會為它自己"注冊",所以實際上reference還保留在某個地方。除非run( )退出,線程中止,否則垃圾回收器不能動它。

YIElding

如果你知道run( )已經告一段落了,你就可以給線程調度機制作一個暗示,告訴它你干完了,可以讓別的線程來使用CPU了。這個暗示(注意,只是暗示——無法保證你用的這個JVM會不會對此作出反映)是用yIEld()形式給出的。

Java的線程調度機制是搶占式的(preemptive),也就是說,只要它認為有必要,它會隨時中斷當前線程,並且切換到其它線程。因此,如果I/O(通過main( )線程執行)占用的時間太長了,線程調度機制就會在run( )運行到yield( )之前把它給停下來。總之yIEld( )只會在很少的情況下起作用,而且不能用來進行很嚴肅的調校。

Sleeping

還有一種控制線程的辦法,就是用sleep( )讓它停一段以毫秒計的時間。

sleep( )一定要放在try域裡,這是因為有可能會出現時間沒到sleep( )就被中斷的情況。如果有人拿到了線程的reference,並且調用了它的interrupt( ),這種事就發生了。(interrupt( )也會影響處於wait( )或join( )狀態的線程,所以這兩個方法也要放在try域裡。)如果你准備用interrupt()喚醒線程,那最好是用wait( )而不是sleep( ),因為這兩者的catch語句是不一樣的。這裡我們所遵循的原則是:"除非知道該怎樣去處理異常,否則別去捕捉"。所以,我們把它當作RuntimeException往外面拋。

sleep( int x)不是控制線程執行的辦法。它只是暫停線程。唯一能保證的事情是,它會休眠至少x毫秒,但是它恢復運行所花的時間可能更長,因為在休眠結束之後,線程調度機制還要花時間來接管。

如果你一定要控制線程的執行順序,那最徹底的辦法還是不用線程。你可以自己寫一個協作程序,讓它按一定順序交換程序的運行權。

優先級

線程的優先級(priority)的作用是,告訴線程調度機制這個線程的重要程度的高低。雖然CPU伺候線程的順序是非決定性的,但是如果有很多線程堵在那裡等著啟動,線程調度機制會傾向於首先啟動優先級最高的線程。但這並不意味著低優先級的線程就沒機會運行了(也就是說優先級不會造成死鎖)。優先級低只表示運行的機會少而已。

可以用getPriority( )來讀取線程的優先級,用setPriority( )隨時修改線程的優先級。

雖然JDK提供了10級優先級,但是卻不能很好地映射到很多操作系統上。比方說,Windows 2000平台上有7個等級還沒固定下來,因此映射是不確定的(雖然Sun的Solaris有231個等級)。要想保持可移植性,唯一的辦法就是,在調整優先級的時候,盯住MIN_PRIORITY, NORM_PRIORITY, 和MIN_PRORITY

守護線程

所謂"守護線程(daemon thread)"是指,只要程序還在運行,它就應該在後台提供某種公共服務的線程,但是守護線程不屬於程序的核心部分。因此,當所有非守護線程都運行結束的時候,程序也結束了。相反,只要還有非守護線程在運行,程序就不能結束。比如,運行main( )的線程就屬於非守護線程。

要想創建守護線程,必須在它啟動之前就setDaemon( )。

可以用isDaemon( )來判斷一個線程是不是守護線程。守護線程所創建的線程也自動是守護線程。請看下面這個例子:

連接線程

線程還能調用另一個線程的join( ),等那個線程結束之後再繼續運行。如果線程調用了調用了另一個線程t的t.join( ),那麼在線程t結束之前(判斷標准是,t.isAlive( )等於false),主叫線程會被掛起。

調用join( )的時候可以給一個timeout參數,(可以是以毫秒,也可以是以納秒作單位),這樣如果目標線程在時限到期之後還沒有結束,join( )就會強制返回了。

join( )調用可以被主叫線程的interrupt( )打斷,所以join( )也要用try-catch括起來。

另外一種方式

迄今為止,你所看到的都是些很簡單的例子。這些線程都繼承了Thread,這種做法很很明智,對象只是作為線程,不做別的事情。但是類可能已經繼承了別的類,這樣它就不能再繼承Thread了(Java不支持多重繼承)。這時,你就要用Runnable接口了。Runnable的意思是,這個類實現了run( )方法,而Thread就是Runnable的。

Runnable接口只有一個方法,那就是run( ),但是如果你想對它做一些Thread對象才能做的事情(比方說toString()裡面的getName( )),你就必須用Thread.currentThread()去獲取其reference。Thread類有一個構造函數,可以拿Runnable和線程的名字作參數。

如果對象是Runnable的,那只說明它有run( )方法。這並沒有什麼特別的,也就是說,不會因為它是Runnable的,就使它具備了線程的先天功能,這一點同Thread的派生類不同的。所以你必須像例程那樣,用Runnable對象去創建線程。把Runnable對象傳給Thread的構造函數,創建一個獨立的Thread對象。接著再調用那個線程的start( ),由它來進行初始化,然後線程的調度機制就能調用run( )了。

Runnable interface的好處在於,所有東西都屬於同一個類;也就是說Runnable能讓你創建基類和其它接口的mixin(混合類)。如果你要訪問其它東西,直接用就是了,不用再一個一個地打交道。但是內部類也有這個功能,它也可以直接訪問宿主類的成員。所以這個理由不足以說服我們放棄Thread的內部類而去使用Runnable的mixin。

Runnable的意思是,你要用代碼——也就是run( )方法——來描述一個處理過程,而不是創建一個表示這個處理過程的對象。在如何理解線程方面,一直存在著爭議。這取決於,你是將線程看作是對象還是處理過程。如果你認為它是一個處理過程,那麼你就擺脫了"萬物皆對象"的OO教條。但與此同時,如果你只想讓這個處理過程掌管程序的某一部分,那你就沒理由讓整個類都成為Runnable的。有鑒於此,用內部類的形式將線程代碼隱藏起來,通常是個更明智的選擇。

除非迫不得已只能用Runnable,否則選Thread。

創建反應敏捷的用戶界面

創建反映敏捷的用戶界面是多線程的主要用途之一。

要想讓程序反應靈敏,可以把運算放進run( )裡面,然後讓搶占式的調度程序來管理它,。

共享有限的資源

你可以認為單線程程序是一個在問題空間裡游走的,一次只作一件事的孤獨的個體。由於只有它一個,因此你無需考慮兩個實體同時申請同一項資源的問題。這個問題有點像兩個人同時把車停在一個車位上,同時穿一扇門,甚至是同時發言。

但是在多線程環境下,事情就不那麼簡單了,你必須考慮兩個或兩個以上線程同時申請同一資源的問題。必須杜絕資源訪問方面的沖突。

用不正確的方法訪問資源

試看下面這段例程。AlwaysEven會"保證",每次調用getValue( )的時候都會返回一個偶數。此外還有一個"Watcher"線程,它會不時地調用getValue( ),然後檢查這個數是不是真的是偶數。這麼做看上去有些多余,因為從代碼上看,很明顯這個值肯定是偶數。但是意外來了。下面是源代碼:

有些時候,你不用關心別人是不是正在用那個資源。但是對多線程環境,你必須要有辦法能防止兩個線程同時訪問同一個資源,至少別在關鍵的時候。

要防止這種沖突很簡單,只要在線程運行的時候給資源上鎖就行了。第一個訪問這個資源的線程給它上鎖,在它解鎖之前,其它線程都不能訪問這個資源,接著另一個線程給這個資源上鎖然後再使用,如此循環。

測試框架

資源訪問的沖突

Semaphore是一種用於線程間通信的標志對象。如果semaphore的值是零,則線程可以獲得它所監視的資源,如果不是零,那麼線程就無法獲取這個資源,於是線程必須等。如果申請到了資源,線程會先對semaphore作遞增,再使用這個資源。遞增和遞減是原子操作(atomicOperation,也就是說不會被打斷的操作),由此semaphore就防止兩個線程同時使用同一項資源。

如果semaphore能妥善的看護它所監視的資源,那麼對象就永遠也不會陷入不穩定狀態。

解決共享資源的沖突

實際上所有的多線程架構都采用串行訪問的方式來解決共享資源的沖突問題。也就是說,同一時刻只有一個線程可以訪問這個共享資源。通常是這樣實現的,在代碼的前後設一條加鎖和解鎖的語句,這樣同一時刻只有一個線程能夠執行這段代碼。由於鎖定語句會產生"互斥(mutual exclusion)"的效果,因此這一機制通常也被稱為mutex。

實際上等在外面的線程並沒有排成一列,相反由於線程的調度機制是非決定性的,因此誰都不知道誰會是下一個。我們可以用yIEld( )和setPriority( )來給線程調度機制提一些建議,但究竟能起多大作用,還要看平台和JVM。

Java提供了內置的防止資源沖突的解決方案,這就是synchronized關鍵詞。它的工作原理很像Semaphore類:當線程想執行由synchronized看護的代碼時,它會先檢查其semaphore是否可得,如果是,它會先獲取semaphore,再執行代碼,用完之後再釋放semaphore。但是和我們寫的Semaphore不同,synchronized是語言內置的,因此不會有什麼問題。

通常共享資源就是一段內存,其表現形式就是對象,不過也可以是文件,I/O端口或打印機之類的。要想控制對共享資源的訪問,先把它放進對象裡面。然後把所有要訪問這個資源的方法都作成synchronized的。只要有一個線程還在調用synchronized方法,其它線程就不允許訪問所有的synchronized方法。

通常你會把類的成員設成private的,然後用方法進行訪問,因此你可以把方法做成synchronized。下面就是synchronized方法的聲明:

synchronized void f() { /*... */ }

synchronized void g(){ /*... */ }

每個對象都有一個鎖(也稱監控器monitor),它是對象生來就有的東西(因此你不必為此寫任何代碼)。當你調用synchronized方法時,這個對象就被鎖住了。在方法返回並且解鎖之前,誰也不能調用同一個對象的其它synchronized方法。就說上面那兩個方法,如果你調用了f( ),那麼在f( )返回並且解鎖之前,你是不能調用同一個對象的g( )的。因此對任何一個特定的對象,所有的synchronized方法都會共享一個鎖,而這個鎖能防止兩個或兩個以上線程同時讀寫一塊共用內存。

一個線程能多次獲得對象的鎖。也就是說,一個synchronized方法調用了另一個synchronized方法,而後者又調用了另一synchronized方法,諸如此類。JVM會跟蹤對象被上鎖的次數。如果對象沒有被鎖住,那麼它的計數器應該為零。當線程第一次獲得對象的鎖時,計數器為一。線程每獲一次對象的鎖,計數器就加一。當然,只有第一次獲得對象鎖的線程才能多次獲得鎖。線程每退出一個synchronized方法,計數器就減一。等減到零了,對象也就解鎖了,這時其它線程就可以使用這個對象了。

此外每個類還有一個鎖(它屬於類的Class對象),這樣當類的synchronized static方法讀取static數據的時候,就不會相互干擾了。

用Synchronized改寫EvenGenerator

一定要記住:所有訪問共享資源的方法都必須是synchronized的,否則程序肯定會出錯。

原子操作

"原子操作(atomic Operation)是不需要synchronized",這是Java多線程編程的老生常談了。所謂原子操作是指不會被線程調度機制打斷的操作;這種操作一旦開始,就一直運行倒結束,中間不會有任何context switch(切換到另一個線程)。

通常所說的原子操作包括對非long和double型的primitive進行賦值,以及返回這兩者之外的primitive。之所以要把它們排除在外是因為它們都比較大,而JVM的設計規范又沒有要求讀操作和賦值操作必須是原子操作(JVM可以試著去這麼作,但並不保證)。不過如果你在long或double前面加了volatile,那麼它就肯定是原子操作了。

如果你是從C++轉過來的,或者有其它低級語言的經驗,你會認為遞增肯定是一個原子操作,因為它通常都是用CPU的指令來實現的。但是在JVM裡,遞增不是原子操作,它涉及到了讀和寫。所以即便是這麼簡單的一個操作,多線程也有機可乘。

如果你把變量定義為volatile的,那麼編譯器就不會做任何優化了。而優化的意思就是減少數據同步的讀寫。

最安全的原子操作只有讀取和對primitive賦值這兩種。但是原子操作也能訪問正處於無效狀態的對象,所以絕對不能想當然。我們一開頭就講了,long和double型的操作不一定時原子操作(雖然有些JVM能保證long和double也是原子操作,但是如果你真的用了這個特性的話,代碼就沒有可移植性了。)

最安全的做法還是遵循如下的方針:

如果你要synchronize類的一個方法,索性把所有的方法全都synchronize了。要判斷,哪個方法該synchronize,哪個方法可以不synchronize,通常是很難的,而且也沒什麼把握。 刪除synchronized的時候要絕對小心。通常這麼做是為了性能,但是synchronized的開銷在JDK1.3和1.4裡已經大為降低了。此外,只有在用profiler分析過,確認synchronized確實是瓶頸的前提下才能這麼作。

千萬要牢記並發編程的最高法則:絕對不能想當然。

對象鎖和synchronized關鍵詞是Java內置的semaphore,因此沒必要再去搞一套了。

關鍵段

有時你只需要防止多個線程同時訪問方法中的某一部分,而不是整個方法。這種需要隔離的代碼就被稱為關鍵段(critical section)。創建關鍵段需要用到synchronized關鍵詞。這裡,synchronized的作用是,指明執行下列代碼需獲得哪個對象的鎖。

synchronized(syncObject) {

// This code can be Accessed

// by only one thread at a time

}

關鍵段又被稱為"同步塊(synchronized block)";線程在執段代碼之前,必須先獲得syncObject的鎖。如果其它線程已經獲得這個鎖了,那麼在它解鎖之前,線程不能運行關鍵段中的代碼。

同步分兩種,代碼的同步和方法的同步。相比同步整個方法,同步一段代碼能顯著增加其它線程獲得這個對象的機會。

當然,最後還是要靠程序員:所有訪問共享資源的代碼都必須被包進同步段裡。

線程的狀態

線程的狀態可歸納為以下四種:

New: 線程對象已經創建完畢,但尚未啟動(start),因此還不能運行。Runnable: 處在這種狀態下的線程,只要分時機制分配給它CPU周期,它就能運行。也就是說,具體到某個時點,它可能正在運行,也可能沒有運行,但是輪到它運行的時候,誰都不能阻止它;它沒有dead,也沒有被阻塞。Dead: 要想中止線程,正常的做法是退出run( )。在Java 2以前,你也可以調用stop( ),不過現在不建議用這個辦法了,因為它很可能會造成程序運行狀態的不穩定。此外還有一個destroy( )(不過它還沒有實現,或許將來也不會了,也就是說已經被放棄了)。Blocked: 就線程本身而言,它是可以運行的,但是有什麼別的原因在阻止它運行。線程調度機制會直接跳過blocked的線程,根本不給它分配CPU的時間。除非它重新進入runnable狀態,否則什麼都干不了。

進入阻塞狀態

如果線程被阻塞了,那肯定是出了什麼問題。問題可能有以下幾種:

你用sleep(milliseconds)方法叫線程休眠。在此期間,線程是不能運行的。你用wait( )方法把線程掛了起來。除非收到notify( )或notifyAll( )消息,否則線程無法重新進入runnable狀態。這部分內容會在後面講。線程在等I/O結束。線程要調用另一個對象的synchronized方法,但是還沒有得到對象的鎖。

或許你還在舊代碼裡看到過suspend( )和resume( ),不過Java 2已經放棄了這兩個方法(因為很容易造成死鎖),所以這裡就不作介紹了。

線程間的協作

理解了線程會相互沖突以及該如何防止這種沖突之後,下一步就該學習怎樣讓線程協同工作了。要做到這一點,關鍵是要讓線程能相互"協商(handshaking)"。而這個任務要由Object的wait( )和notify()來完成。

wait與notify

首先要強調,線程sleep( )的時候並不釋放對象的鎖,但是wait( )的時候卻會釋放對象的鎖。也就是說在線程wait( )期間,別的線程可以調用它的synchronized方法。當線程調用了某個對象wait( )方法之後,它就中止運行並釋放那個對象鎖了。

Java有兩種wait( )。第一種需要一個以毫秒記的時間作參數,它的意思和sleep( )一樣,都是:"暫停一段時間。"區別在於:

wait( )會釋放對象的鎖。除了時間到了,wait( )還可以用notify( )或notifyAll( )來中止

第二種wait( )不需要任何參數;它的用途更廣。線程調用了這種wait( )之後,會一直等下去,直到(有別的線程調用了這個對象的)notify( )或notifyAll( )。

和sleep()屬於Thread不同,wait( ), notify( ), 和notifyAll( )是根Object的方法。雖然這樣做法(把專為多線程服務的方法放到通用的根類裡面)看上去有些奇怪,但卻是必要的。因為它們所操控的是每個對象都會有的鎖。所以結論就是,你可以在類的synchronized方法裡調用wait( ),至於它繼不繼承Thread,實沒實現Runnable已經無所謂了。實際上你也只能在synchronized方法裡或synchronized段裡調用wait( ),notify( )或notifyAll()(sleep( )則沒有這個限制,因為它不對鎖進行操作)。如果你在非synchronized方法裡調用了這些方法,程序還是可以編譯的,但是一運行就會出一個IllegalMonitorStateException。這個異常帶著一個挺讓人費解的"current thread not owner"消息。這個消息的意思是,如果線程想調用對象的wait( ), notify( ),或notifyAll()方法,必須先"擁有"(得到)這個對象的鎖。

通常情況下,如果條件是由方法之外的其他力量所控制的(最常見的就是要由其他線程修改),那麼你就應該用wait( )。wait( )能讓你在等待世道改變的同時讓線程休眠,當(其他線程調用了對象的)notify( )或notifyAll( )的時候,線程自會醒來,然後檢查條件是不是改變了。所以說wait()提供了一種同步線程間的活動的方法。

用管道進行線程間的I/O操作

在很多情況下,線程也可以利用I/O來進行通信。多線程類庫會提供一種"管道(pipes)"來實現線程間的I/O。對Java I/O類庫而言,這個類就是PipedWriter(可以讓線程往管道裡寫數據)和PipedReader(讓另一個線程從這個管道裡讀數據)。你可以把它理解成"producer-consumer"問題的一個變型,而管道則提供了一個現成的解決方案。

注意,如果你沒創建完對象就啟動線程,那麼管道在不同的平台上的行為就有可能會不一致。

更復雜的協同

這裡只講了最基本的協同方式(即通常籍由wait( ),notify()/notifyAll( )來實現的producer-consumer模式)。它已經能解決絕大多數的線程協同問題了,但是在高級的教科書裡還有很多更復雜協同方式

死鎖

由於線程能被阻塞,更由於synchronized方法能阻止其它線程訪問本對象,因此有可能會出現如下這種情況:線程一在等線程二(釋放某個對象),線程二又在等線程三,這樣依次排下去直到有個線程在等線程一。這樣就形成了一個環,每個線程都在等對方釋放資源,而它們誰都不能運行。這就是所謂的死鎖(deadlock)。

如果程序一運行就死鎖,那倒也簡單了。你可以馬上著手解決這個問題。但真正的麻煩在於,程序看上去能正常運行,但是卻潛伏著會引起死鎖的隱患。或許你認為這裡根本就不可能會有死鎖,而bug也就這樣潛伏下來了。直到有一天,讓某個用戶給撞上了(而且這種bug還很可能是不可重復的)。所以對並發編程來說,防止死鎖是設計階段的一個重要任務。

下面我們來看看由Dijkstra發現的經典的死鎖場景:哲學家吃飯問題。原版的故事裡有五個哲學家(不過我們的例程裡允許有任意數量)。這些哲學家們只做兩件事,思考和吃飯。他們思考的時候,不需要任何共享資源,但是吃飯的時候,就必須坐到餐桌旁。餐桌上的餐具是有限的。原版的故事裡,餐具是叉子,吃飯的時候要用兩把叉子把面條從碗裡撈出來。但是很明顯,把叉子換成筷子會更合理,所以:一個哲學家需要兩根筷子才能吃飯。

現在引入問題的關鍵:這些哲學家很窮,只買得起五根筷子。他們坐成一圈,兩個人的中間放一根筷子。哲學家吃飯的時候必須同時得到左手邊和右手邊的筷子。如果他身邊的任何一位正在使用筷子,那他只有等著。

這個問題之所以有趣就在於,它演示了這麼一個程序,它看上去似乎能正常運行,但是卻容易引起死鎖。

在告訴你如何修補這個問題之前,先了解一下只有在下述四個條件同時滿足的情況下,死鎖才會發生:

互斥:也許線程會用到很多資源,但其中至少要有一項是不能共享的。至少要有一個進程會在占用一項資源的同時還在等另一項正被其它進程所占用的資源。(調度系統或其他進程)不能從進程裡搶資源。所有進程都必須正常的釋放資源。必需要有等待的環。一個進程在一個已經被另一進程搶占了的資源,而那個進程又在等另一個被第三個進程搶占了的資源,以此類推,直到有個進程正在等被第一個進程搶占了的資源,這樣就形成了癱瘓性的阻塞了。

由於死鎖要同時滿足這四個條件,所用只要去掉其中一個就能防止死鎖。

Java語言沒有提供任何能預防死鎖的機制,所以只能靠你來設計了。

停止線程的正確方法

為了降低死鎖的發生幾率,Java 2放棄了Thread類stop(),suspend( )和resume( )方法。

之所以要放棄stop( )是因為,它不會釋放對象的鎖,因此如果對象正處於無效狀態(也就是被破壞了),其它線程就可能會看到並且修改它了。這個問題的後果可能非常微秒,因此難以察覺。所以別再用stop( )了,相反你應該設置一個旗標(flag)來告訴線程什麼時候該停止。

打斷受阻的線程

有時線程受阻之後就不能再做輪詢了,比如在等輸入,這時你就不能像前面那樣去查詢旗標了。碰到這種情況,你可以用Thread.interrupt( )方法打斷受阻的線程。

線程組

線程組是一個裝線程的容器(collection)。用JoshuaBloch的話來講,它的意義可以概括為:

"最好把線程組看成是一次不成功的實驗,或者就當它根本不存在。"

線程組還剩一個小用途。如果組裡的線程拋出一個沒有被(異常處理程序)捕捉到的異常,就會啟動ThreadGroup.uncaughtException()。而它會在標准錯誤流上打印出棧的軌跡。要想修改這個行為,你必須覆寫這個方法。

總結

要懂得什麼時候用什麼時候用並發,什麼時候不用並發,這點非常重要。使用並發的主要理由包括:要管理大量的任務,讓它們同時運行以提高系統的利用率(包括在多CPU上透明的分配負載);更合理的組織代碼;以及方便用戶。平衡負載的一個經典案例是在等待I/O的同時做計算。方便用戶的經典案例是在用戶下載大文件的時候監控"stop"按鈕。

線程還有一個額外的好處,那就是它提供了"輕型"(100個指令級的)運行環境(execution context)的切換,而進程環境(process context)的切換則是"重型"的(數千個指令)。由於所有線程會共享進程的內存空間,所以輕型的環境切換只會改變程序執行順序和本地變量。而重型的進程環境切換則必須交換全部的內存空間。

多線程的主要缺點包括:

等待共享資源的時候,運行速度會慢下來。線程管理需要額外的CPU開銷。如果設計得不不合理,程序會變得異常負責。會引發一些不正常的狀態,像饑餓(starving),競爭(racing),死鎖(deadlock),活鎖(livelock)。不同平台上會有一些不一致。比如我在開發本書例程時發現,在有些平台下競爭很快就出現,但是換了台機器,它根本就不出現。如果你在後者搞開發,然後發布到前者,那可就慘了。

線程的難點在於多個線程會共享同一項資源——比如對象的內存——而你又必須確保同一時刻不會有兩個或兩個以上的線程去訪問那項資源。這就需要合理地使用synchronized關鍵詞了,但是用之前必須完全理解,否則它會悄悄地地把死鎖了帶進來。

此外線程的運用方面還有某種藝術。Java的設計思想是,讓你能根據需要創建任意多的對象來解決問題,至少理論上如此。(對Java來說創建數以百萬計的對象,比如工程方面的有限元分析,還不太現實。)但是你能創建的線程數量應該還是有一個上限的,因為到了這個數量,線程就僵掉了。這個臨界點很難找,通常由OS和JVM決定;或許是一百以內,也可能是幾千。不過通常你只需創建幾個線程就能解決問題了,所以這還不算是什麼限制;但是對於更為通用的設計,這就是一個限制了。

線程方面一個重要,但卻不那麼直觀的結論。那就是,通常你可以在run( )的主循環裡插上yield( ),然後讓線程調度機制幫你加快程序的運行。這絕對是一種藝術,特別是當等待延長之後,性能卻上升了。之所以會這樣是因為,較短的延遲會使正在運行的線程還沒准備好休眠就收到休眠結束的信號,這樣為了能讓線程干完工作之後再休眠,調度機制不得不先把它停下來再喚醒它。額外的運行環境的切換會導致運行速度的下降,而yIEld( )和sleep( )則可以防止這種多余的切換。要理解這個問題有多麻煩還真得好好想想。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved