13: 并發(fā)編程

面向?qū)ο笫刮覀兡軐⒊绦騽澐殖上嗷オ?dú)立的模塊。但是你時(shí)常還會(huì)碰到,不但要把程序分解開(kāi)來(lái),而且還要讓它的各個(gè)部分都能獨(dú)立運(yùn)行的問(wèn)題。

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

進(jìn)程(process)是一種有專屬地址空間的"自含式(self-contained)"程序。通過(guò)在不同的任務(wù)之間定時(shí)切換CPU,多任務(wù)(multitasking)操作系統(tǒng)營(yíng)造出一種同一個(gè)時(shí)點(diǎn)可以有多個(gè)進(jìn)程(程序)在同時(shí)運(yùn)行的效果。線程是進(jìn)程內(nèi)部的獨(dú)立的,有序的指令流。由此,一個(gè)進(jìn)程能包含多個(gè)并發(fā)執(zhí)行的線程。

多線程的用途很廣,但歸納起來(lái)不外乎,程序的某一部分正在等一個(gè)事件或資源,而你又不想讓它把整個(gè)程序都給阻塞了。因此你可以創(chuàng)建一個(gè)與該事件或資源相關(guān)的線程,讓它與主程序分開(kāi)來(lái)運(yùn)行。

學(xué)習(xí)并發(fā)編程就像是去探訪一個(gè)新的世界,同時(shí)學(xué)習(xí)一種新的編程語(yǔ)言,最起碼也得接受一套新的理念。隨著絕大多數(shù)的微電腦操作系統(tǒng)提供了多線程支持,編程語(yǔ)言和類庫(kù)也做了相應(yīng)的擴(kuò)展??偠灾?,多線程編程:

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

理解并發(fā)編程的難度不亞于理解多態(tài)性。多線程看著容易其實(shí)很難。

動(dòng)機(jī)

并發(fā)編程的一個(gè)最主要的用途就是創(chuàng)建反應(yīng)靈敏的用戶界面。試想有這么一個(gè)程序,由于要進(jìn)行大量的CPU密集的運(yùn)算,它完全忽略了用戶輸入,以致于變得非常遲鈍了。要解決這種問(wèn)題,關(guān)鍵在于,程序在進(jìn)行運(yùn)算的同時(shí),還要時(shí)不時(shí)地將控制權(quán)交還給用戶界面,這樣才能對(duì)用戶的操作做出及時(shí)的響應(yīng)。假設(shè)有一個(gè)"quit"按鈕,你總不會(huì)希望每寫一段代碼就做一次輪詢的吧,你要的是"quit"能及時(shí)響應(yīng)用戶的操作,就像你在定時(shí)檢查一樣。

常規(guī)的方法是不可能在運(yùn)行指令的同時(shí)還把控制權(quán)交給其他程序的。這聽(tīng)上去簡(jiǎn)直就是在天方夜譚,就好像CPU能同時(shí)出現(xiàn)在兩個(gè)地方,但是多線程所營(yíng)造的正是這個(gè)效果。

并發(fā)編程還能用來(lái)優(yōu)化吞吐率。

如果是多處理器的系統(tǒng),線程還會(huì)被分到多個(gè)處理器上。

有一點(diǎn)要記住,那就是多線程程序也必須能運(yùn)行在單CPU系統(tǒng)上。

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

多線程能令你設(shè)計(jì)出更為松散耦合(more loosely-coupled)的應(yīng)用程序。

基本線程

要想創(chuàng)建線程,最簡(jiǎn)單的辦法就是繼承java.lang.Thread。這個(gè)類已經(jīng)為線程的創(chuàng)建和運(yùn)行做了必要的配置。run( )Thread最重要的方法,要想讓線程替你辦事,你就必須覆寫這個(gè)方法。由此可知,run( )所包含的就是要和程序里其它線程"同時(shí)"執(zhí)行的代碼。

main( )創(chuàng)建了Thread,但是卻沒(méi)去拿它的reference。如果是普通對(duì)象,這一點(diǎn)就足以讓它成為垃圾,但Thread不會(huì)。Thread都會(huì)為它自己"注冊(cè)",所以實(shí)際上reference還保留在某個(gè)地方。除非run( )退出,線程中止,否則垃圾回收器不能動(dòng)它。

Yielding

如果你知道run( )已經(jīng)告一段落了,你就可以給線程調(diào)度機(jī)制作一個(gè)暗示,告訴它你干完了,可以讓別的線程來(lái)使用CPU了。這個(gè)暗示(注意,只是暗示——無(wú)法保證你用的這個(gè)JVM會(huì)不會(huì)對(duì)此作出反映)是用yield( )形式給出的。

Java的線程調(diào)度機(jī)制是搶占式的(preemptive),也就是說(shuō),只要它認(rèn)為有必要,它會(huì)隨時(shí)中斷當(dāng)前線程,并且切換到其它線程。因此,如果I/O(通過(guò)main( )線程執(zhí)行)占用的時(shí)間太長(zhǎng)了,線程調(diào)度機(jī)制就會(huì)在run( )運(yùn)行到yield( )之前把它給停下來(lái)??傊?span lang="EN-US">yield( )只會(huì)在很少的情況下起作用,而且不能用來(lái)進(jìn)行很嚴(yán)肅的調(diào)校。

Sleeping

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

sleep( )一定要放在try域里,這是因?yàn)橛锌赡軙?huì)出現(xiàn)時(shí)間沒(méi)到sleep( )就被中斷的情況。如果有人拿到了線程的reference,并且調(diào)用了它的interrupt( ),這種事就發(fā)生了。(interrupt( )也會(huì)影響處于wait( )join( )狀態(tài)的線程,所以這兩個(gè)方法也要放在try域里。)如果你準(zhǔn)備用interrupt( )喚醒線程,那最好是用wait( )而不是sleep( ),因?yàn)檫@兩者的catch語(yǔ)句是不一樣的。這里我們所遵循的原則是:"除非知道該怎樣去處理異常,否則別去捕捉"。所以,我們把它當(dāng)作RuntimeException往外面拋。

sleep( int x)不是控制線程執(zhí)行的辦法。它只是暫停線程。唯一能保證的事情是,它會(huì)休眠至少x毫秒,但是它恢復(fù)運(yùn)行所花的時(shí)間可能更長(zhǎng),因?yàn)樵谛菝呓Y(jié)束之后,線程調(diào)度機(jī)制還要花時(shí)間來(lái)接管。

如果你一定要控制線程的執(zhí)行順序,那最徹底的辦法還是不用線程。你可以自己寫一個(gè)協(xié)作程序,讓它按一定順序交換程序的運(yùn)行權(quán)。

優(yōu)先級(jí)

線程的優(yōu)先級(jí)(priority)的作用是,告訴線程調(diào)度機(jī)制這個(gè)線程的重要程度的高低。雖然CPU伺候線程的順序是非決定性的,但是如果有很多線程堵在那里等著啟動(dòng),線程調(diào)度機(jī)制會(huì)傾向于首先啟動(dòng)優(yōu)先級(jí)最高的線程。但這并不意味著低優(yōu)先級(jí)的線程就沒(méi)機(jī)會(huì)運(yùn)行了(也就是說(shuō)優(yōu)先級(jí)不會(huì)造成死鎖)。優(yōu)先級(jí)低只表示運(yùn)行的機(jī)會(huì)少而已。

可以用getPriority( )來(lái)讀取線程的優(yōu)先級(jí),用setPriority( )隨時(shí)修改線程的優(yōu)先級(jí)。

雖然JDK提供了10級(jí)優(yōu)先級(jí),但是卻不能很好地映射到很多操作系統(tǒng)上。比方說(shuō),Windows 2000平臺(tái)上有7個(gè)等級(jí)還沒(méi)固定下來(lái),因此映射是不確定的(雖然SunSolaris231個(gè)等級(jí))。要想保持可移植性,唯一的辦法就是,在調(diào)整優(yōu)先級(jí)的時(shí)候,盯住MIN_PRIORITY, NORM_PRIORITY, MIN_PRORITY

守護(hù)線程

所謂"守護(hù)線程(daemon thread)"是指,只要程序還在運(yùn)行,它就應(yīng)該在后臺(tái)提供某種公共服務(wù)的線程,但是守護(hù)線程不屬于程序的核心部分。因此,當(dāng)所有非守護(hù)線程都運(yùn)行結(jié)束的時(shí)候,程序也結(jié)束了。相反,只要還有非守護(hù)線程在運(yùn)行,程序就不能結(jié)束。比如,運(yùn)行main( )的線程就屬于非守護(hù)線程。

要想創(chuàng)建守護(hù)線程,必須在它啟動(dòng)之前就setDaemon( )

可以用isDaemon( )來(lái)判斷一個(gè)線程是不是守護(hù)線程。守護(hù)線程所創(chuàng)建的線程也自動(dòng)是守護(hù)線程。請(qǐng)看下面這個(gè)例子:

連接線程

線程還能調(diào)用另一個(gè)線程的join( ),等那個(gè)線程結(jié)束之后再繼續(xù)運(yùn)行。如果線程調(diào)用了調(diào)用了另一個(gè)線程tt.join( ),那么在線程t結(jié)束之前(判斷標(biāo)準(zhǔn)是,t.isAlive( )等于false),主叫線程會(huì)被掛起。

調(diào)用join( )的時(shí)候可以給一個(gè)timeout參數(shù),(可以是以毫秒,也可以是以納秒作單位),這樣如果目標(biāo)線程在時(shí)限到期之后還沒(méi)有結(jié)束,join( )就會(huì)強(qiáng)制返回了。

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

另外一種方式

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

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

如果對(duì)象是Runnable的,那只說(shuō)明它有run( )方法。這并沒(méi)有什么特別的,也就是說(shuō),不會(huì)因?yàn)樗?span lang="EN-US">Runnable的,就使它具備了線程的先天功能,這一點(diǎn)同Thread的派生類不同的。所以你必須像例程那樣,用Runnable對(duì)象去創(chuàng)建線程。把Runnable對(duì)象傳給Thread的構(gòu)造函數(shù),創(chuàng)建一個(gè)獨(dú)立的Thread對(duì)象。接著再調(diào)用那個(gè)線程的start( ),由它來(lái)進(jìn)行初始化,然后線程的調(diào)度機(jī)制就能調(diào)用run( )了。

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

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

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

創(chuàng)建反應(yīng)敏捷的用戶界面

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

要想讓程序反應(yīng)靈敏,可以把運(yùn)算放進(jìn)run( )里面,然后讓搶占式的調(diào)度程序來(lái)管理它,。

共享有限的資源

你可以認(rèn)為單線程程序是一個(gè)在問(wèn)題空間里游走的,一次只作一件事的孤獨(dú)的個(gè)體。由于只有它一個(gè),因此你無(wú)需考慮兩個(gè)實(shí)體同時(shí)申請(qǐng)同一項(xiàng)資源的問(wèn)題。這個(gè)問(wèn)題有點(diǎn)像兩個(gè)人同時(shí)把車停在一個(gè)車位上,同時(shí)穿一扇門,甚至是同時(shí)發(fā)言。

但是在多線程環(huán)境下,事情就不那么簡(jiǎn)單了,你必須考慮兩個(gè)或兩個(gè)以上線程同時(shí)申請(qǐng)同一資源的問(wèn)題。必須杜絕資源訪問(wèn)方面的沖突。

用不正確的方法訪問(wèn)資源

試看下面這段例程。AlwaysEven會(huì)"保證",每次調(diào)用getValue( )的時(shí)候都會(huì)返回一個(gè)偶數(shù)。此外還有一個(gè) "Watcher"線程,它會(huì)不時(shí)地調(diào)用getValue( ),然后檢查這個(gè)數(shù)是不是真的是偶數(shù)。這么做看上去有些多余,因?yàn)閺拇a上看,很明顯這個(gè)值肯定是偶數(shù)。但是意外來(lái)了。下面是源代碼:

有些時(shí)候,你不用關(guān)心別人是不是正在用那個(gè)資源。但是對(duì)多線程環(huán)境,你必須要有辦法能防止兩個(gè)線程同時(shí)訪問(wèn)同一個(gè)資源,至少別在關(guān)鍵的時(shí)候。

要防止這種沖突很簡(jiǎn)單,只要在線程運(yùn)行的時(shí)候給資源上鎖就行了。第一個(gè)訪問(wèn)這個(gè)資源的線程給它上鎖,在它解鎖之前,其它線程都不能訪問(wèn)這個(gè)資源,接著另一個(gè)線程給這個(gè)資源上鎖然后再使用,如此循環(huán)。

測(cè)試框架

資源訪問(wèn)的沖突

Semaphore是一種用于線程間通信的標(biāo)志對(duì)象。如果semaphore的值是零,則線程可以獲得它所監(jiān)視的資源,如果不是零,那么線程就無(wú)法獲取這個(gè)資源,于是線程必須等。如果申請(qǐng)到了資源,線程會(huì)先對(duì)semaphore作遞增,再使用這個(gè)資源。遞增和遞減是原子操作(atomic operation,也就是說(shuō)不會(huì)被打斷的操作),由此semaphore就防止兩個(gè)線程同時(shí)使用同一項(xiàng)資源。

如果semaphore能妥善的看護(hù)它所監(jiān)視的資源,那么對(duì)象就永遠(yuǎn)也不會(huì)陷入不穩(wěn)定狀態(tài)。

解決共享資源的沖突

實(shí)際上所有的多線程架構(gòu)都采用串行訪問(wèn)的方式來(lái)解決共享資源的沖突問(wèn)題。也就是說(shuō),同一時(shí)刻只有一個(gè)線程可以訪問(wèn)這個(gè)共享資源。通常是這樣實(shí)現(xiàn)的,在代碼的前后設(shè)一條加鎖和解鎖的語(yǔ)句,這樣同一時(shí)刻只有一個(gè)線程能夠執(zhí)行這段代碼。由于鎖定語(yǔ)句會(huì)產(chǎn)生"互斥(mutual exclusion)"的效果,因此這一機(jī)制通常也被稱為mutex

實(shí)際上等在外面的線程并沒(méi)有排成一列,相反由于線程的調(diào)度機(jī)制是非決定性的,因此誰(shuí)都不知道誰(shuí)會(huì)是下一個(gè)。我們可以用yield( )setPriority( )來(lái)給線程調(diào)度機(jī)制提一些建議,但究竟能起多大作用,還要看平臺(tái)和JVM

Java提供了內(nèi)置的防止資源沖突的解決方案,這就是synchronized關(guān)鍵詞。它的工作原理很像Semaphore:當(dāng)線程想執(zhí)行由synchronized看護(hù)的代碼時(shí),它會(huì)先檢查其semaphore是否可得,如果是,它會(huì)先獲取semaphore,再執(zhí)行代碼,用完之后再釋放semaphore。但是和我們寫的Semaphore不同,synchronized是語(yǔ)言內(nèi)置的,因此不會(huì)有什么問(wèn)題。

通常共享資源就是一段內(nèi)存,其表現(xiàn)形式就是對(duì)象,不過(guò)也可以是文件,I/O端口或打印機(jī)之類的。要想控制對(duì)共享資源的訪問(wèn),先把它放進(jìn)對(duì)象里面。然后把所有要訪問(wèn)這個(gè)資源的方法都作成synchronized的。只要有一個(gè)線程還在調(diào)用synchronized方法,其它線程就不允許訪問(wèn)所有的synchronized方法。

通常你會(huì)把類的成員設(shè)成private的,然后用方法進(jìn)行訪問(wèn),因此你可以把方法做成synchronized。下面就是synchronized方法的聲明:

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

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

每個(gè)對(duì)象都有一個(gè)鎖(也稱監(jiān)控器monitor),它是對(duì)象生來(lái)就有的東西(因此你不必為此寫任何代碼)。當(dāng)你調(diào)用synchronized方法時(shí),這個(gè)對(duì)象就被鎖住了。在方法返回并且解鎖之前,誰(shuí)也不能調(diào)用同一個(gè)對(duì)象的其它synchronized方法。就說(shuō)上面那兩個(gè)方法,如果你調(diào)用了f( ),那么在f( )返回并且解鎖之前,你是不能調(diào)用同一個(gè)對(duì)象的g( )的。因此對(duì)任何一個(gè)特定的對(duì)象,所有的synchronized方法都會(huì)共享一個(gè)鎖,而這個(gè)鎖能防止兩個(gè)或兩個(gè)以上線程同時(shí)讀寫一塊共用內(nèi)存。

一個(gè)線程能多次獲得對(duì)象的鎖。也就是說(shuō),一個(gè)synchronized方法調(diào)用了另一個(gè)synchronized方法,而后者又調(diào)用了另一synchronized方法,諸如此類。JVM會(huì)跟蹤對(duì)象被上鎖的次數(shù)。如果對(duì)象沒(méi)有被鎖住,那么它的計(jì)數(shù)器應(yīng)該為零。當(dāng)線程第一次獲得對(duì)象的鎖時(shí),計(jì)數(shù)器為一。線程每獲一次對(duì)象的鎖,計(jì)數(shù)器就加一。當(dāng)然,只有第一次獲得對(duì)象鎖的線程才能多次獲得鎖。線程每退出一個(gè)synchronized方法,計(jì)數(shù)器就減一。等減到零了,對(duì)象也就解鎖了,這時(shí)其它線程就可以使用這個(gè)對(duì)象了。

此外每個(gè)類還有一個(gè)鎖(它屬于類的Class對(duì)象),這樣當(dāng)類的synchronized static方法讀取static數(shù)據(jù)的時(shí)候,就不會(huì)相互干擾了。

Synchronized改寫EvenGenerator

一定要記住:所有訪問(wèn)共享資源的方法都必須是synchronized的,否則程序肯定會(huì)出錯(cuò)。

原子操作

"原子操作(atomic operation)是不需要synchronized",這是Java多線程編程的老生常談了。所謂原子操作是指不會(huì)被線程調(diào)度機(jī)制打斷的操作;這種操作一旦開(kāi)始,就一直運(yùn)行倒結(jié)束,中間不會(huì)有任何context switch(切換到另一個(gè)線程)。

通常所說(shuō)的原子操作包括對(duì)非longdouble型的primitive進(jìn)行賦值,以及返回這兩者之外的primitive。之所以要把它們排除在外是因?yàn)樗鼈兌急容^大,而JVM的設(shè)計(jì)規(guī)范又沒(méi)有要求讀操作和賦值操作必須是原子操作(JVM可以試著去這么作,但并不保證)。不過(guò)如果你在longdouble前面加了volatile,那么它就肯定是原子操作了。

如果你是從C++轉(zhuǎn)過(guò)來(lái)的,或者有其它低級(jí)語(yǔ)言的經(jīng)驗(yàn),你會(huì)認(rèn)為遞增肯定是一個(gè)原子操作,因?yàn)樗ǔ6际怯?span lang="EN-US">CPU的指令來(lái)實(shí)現(xiàn)的。但是在JVM里,遞增不是原子操作,它涉及到了讀和寫。所以即便是這么簡(jiǎn)單的一個(gè)操作,多線程也有機(jī)可乘。

如果你把變量定義為volatile的,那么編譯器就不會(huì)做任何優(yōu)化了。而優(yōu)化的意思就是減少數(shù)據(jù)同步的讀寫。

最安全的原子操作只有讀取和對(duì)primitive賦值這兩種。但是原子操作也能訪問(wèn)正處于無(wú)效狀態(tài)的對(duì)象,所以絕對(duì)不能想當(dāng)然。我們一開(kāi)頭就講了,longdouble型的操作不一定時(shí)原子操作(雖然有些JVM能保證longdouble也是原子操作,但是如果你真的用了這個(gè)特性的話,代碼就沒(méi)有可移植性了。)

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

  1. 如果你要synchronize類的一個(gè)方法,索性把所有的方法全都synchronize了。要判斷,哪個(gè)方法該synchronize,哪個(gè)方法可以不synchronize,通常是很難的,而且也沒(méi)什么把握。
  2. 刪除synchronized的時(shí)候要絕對(duì)小心。通常這么做是為了性能,但是synchronized的開(kāi)銷在JDK1.31.4里已經(jīng)大為降低了。此外,只有在用profiler分析過(guò),確認(rèn)synchronized確實(shí)是瓶頸的前提下才能這么作。

千萬(wàn)要牢記并發(fā)編程的最高法則:絕對(duì)不能想當(dāng)然。

對(duì)象鎖和synchronized關(guān)鍵詞是Java內(nèi)置的semaphore,因此沒(méi)必要再去搞一套了。

關(guān)鍵段

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

synchronized(syncObject) {

  // This code can be accessed

  // by only one thread at a time

}

 

關(guān)鍵段又被稱為"同步塊(synchronized block)";線程在執(zhí)段代碼之前,必須先獲得syncObject的鎖。如果其它線程已經(jīng)獲得這個(gè)鎖了,那么在它解鎖之前,線程不能運(yùn)行關(guān)鍵段中的代碼。

同步分兩種,代碼的同步和方法的同步。相比同步整個(gè)方法,同步一段代碼能顯著增加其它線程獲得這個(gè)對(duì)象的機(jī)會(huì)。

當(dāng)然,最后還是要靠程序員:所有訪問(wèn)共享資源的代碼都必須被包進(jìn)同步段里。

線程的狀態(tài)

線程的狀態(tài)可歸納為以下四種:

  1. New: 線程對(duì)象已經(jīng)創(chuàng)建完畢,但尚未啟動(dòng)(start),因此還不能運(yùn)行。
  2. Runnable: 處在這種狀態(tài)下的線程,只要分時(shí)機(jī)制分配給它CPU周期,它就能運(yùn)行。也就是說(shuō),具體到某個(gè)時(shí)點(diǎn),它可能正在運(yùn)行,也可能沒(méi)有運(yùn)行,但是輪到它運(yùn)行的時(shí)候,誰(shuí)都不能阻止它;它沒(méi)有dead,也沒(méi)有被阻塞。
  3. Dead: 要想中止線程,正常的做法是退出run( )。在Java 2以前,你也可以調(diào)用stop( ),不過(guò)現(xiàn)在不建議用這個(gè)辦法了,因?yàn)樗芸赡軙?huì)造成程序運(yùn)行狀態(tài)的不穩(wěn)定。此外還有一個(gè)destroy( )(不過(guò)它還沒(méi)有實(shí)現(xiàn),或許將來(lái)也不會(huì)了,也就是說(shuō)已經(jīng)被放棄了)。
  4. Blocked: 就線程本身而言,它是可以運(yùn)行的,但是有什么別的原因在阻止它運(yùn)行。線程調(diào)度機(jī)制會(huì)直接跳過(guò)blocked的線程,根本不給它分配CPU的時(shí)間。除非它重新進(jìn)入runnable狀態(tài),否則什么都干不了。

進(jìn)入阻塞狀態(tài)

如果線程被阻塞了,那肯定是出了什么問(wèn)題。問(wèn)題可能有以下幾種:

  1. 你用sleep(milliseconds)方法叫線程休眠。在此期間,線程是不能運(yùn)行的。
  2. 你用wait( )方法把線程掛了起來(lái)。除非收到notify( )notifyAll( )消息,否則線程無(wú)法重新進(jìn)入runnable狀態(tài)。這部分內(nèi)容會(huì)在后面講。
  3. 線程在等I/O結(jié)束。
  4. 線程要調(diào)用另一個(gè)對(duì)象的synchronized方法,但是還沒(méi)有得到對(duì)象的鎖。

或許你還在舊代碼里看到過(guò)suspend( )resume( ),不過(guò)Java 2已經(jīng)放棄了這兩個(gè)方法(因?yàn)楹苋菀自斐伤梨i),所以這里就不作介紹了。

線程間的協(xié)作

理解了線程會(huì)相互沖突以及該如何防止這種沖突之后,下一步就該學(xué)習(xí)怎樣讓線程協(xié)同工作了。要做到這一點(diǎn),關(guān)鍵是要讓線程能相互"協(xié)商(handshaking)"。而這個(gè)任務(wù)要由Objectwait( )notify( )來(lái)完成。

waitnotify

首先要強(qiáng)調(diào),線程sleep( )的時(shí)候并不釋放對(duì)象的鎖,但是wait( )的時(shí)候卻會(huì)釋放對(duì)象的鎖。也就是說(shuō)在線程wait( )期間,別的線程可以調(diào)用它的synchronized方法。當(dāng)線程調(diào)用了某個(gè)對(duì)象wait( )方法之后,它就中止運(yùn)行并釋放那個(gè)對(duì)象鎖了。

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

  1. wait( )會(huì)釋放對(duì)象的鎖。
  2. 除了時(shí)間到了,wait( )還可以用notify( )notifyAll( )來(lái)中止

第二種wait( )不需要任何參數(shù);它的用途更廣。線程調(diào)用了這種wait( )之后,會(huì)一直等下去,直到(有別的線程調(diào)用了這個(gè)對(duì)象的)notify( )notifyAll( )

sleep( )屬于Thread不同,wait( ), notify( ), 和notifyAll( )是根Object的方法。雖然這樣做法(把專為多線程服務(wù)的方法放到通用的根類里面)看上去有些奇怪,但卻是必要的。因?yàn)樗鼈兯倏氐氖敲總€(gè)對(duì)象都會(huì)有的鎖。所以結(jié)論就是,你可以在類的synchronized方法里調(diào)用wait( ),至于它繼不繼承Thread,實(shí)沒(méi)實(shí)現(xiàn)Runnable已經(jīng)無(wú)所謂了。實(shí)際上你也只能在synchronized方法里或synchronized段里調(diào)用wait( ),notify( )notifyAll( )(sleep( )則沒(méi)有這個(gè)限制,因?yàn)樗粚?duì)鎖進(jìn)行操作)。如果你在非synchronized方法里調(diào)用了這些方法,程序還是可以編譯的,但是一運(yùn)行就會(huì)出一個(gè)IllegalMonitorStateException。這個(gè)異常帶著一個(gè)挺讓人費(fèi)解的"current thread not owner"消息。這個(gè)消息的意思是,如果線程想調(diào)用對(duì)象的wait( ), notify( ),或notifyAll( )方法,必須先"擁有"(得到)這個(gè)對(duì)象的鎖。

通常情況下,如果條件是由方法之外的其他力量所控制的(最常見(jiàn)的就是要由其他線程修改),那么你就應(yīng)該用wait( )。wait( )能讓你在等待世道改變的同時(shí)讓線程休眠,當(dāng)(其他線程調(diào)用了對(duì)象的)notify( )notifyAll( )的時(shí)候,線程自會(huì)醒來(lái),然后檢查條件是不是改變了。所以說(shuō)wait( )提供了一種同步線程間的活動(dòng)的方法。

用管道進(jìn)行線程間的I/O操作

在很多情況下,線程也可以利用I/O來(lái)進(jìn)行通信。多線程類庫(kù)會(huì)提供一種"管道(pipes)"來(lái)實(shí)現(xiàn)線程間的I/O。對(duì)Java I/O類庫(kù)而言,這個(gè)類就是PipedWriter(可以讓線程往管道里寫數(shù)據(jù))PipedReader(讓另一個(gè)線程從這個(gè)管道里讀數(shù)據(jù))。你可以把它理解成"producer-consumer"問(wèn)題的一個(gè)變型,而管道則提供了一個(gè)現(xiàn)成的解決方案。

注意,如果你沒(méi)創(chuàng)建完對(duì)象就啟動(dòng)線程,那么管道在不同的平臺(tái)上的行為就有可能會(huì)不一致。

更復(fù)雜的協(xié)同

這里只講了最基本的協(xié)同方式(即通常籍由wait( )notify( )/notifyAll( )來(lái)實(shí)現(xiàn)的producer-consumer模式)。它已經(jīng)能解決絕大多數(shù)的線程協(xié)同問(wèn)題了,但是在高級(jí)的教科書里還有很多更復(fù)雜協(xié)同方式

死鎖

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

如果程序一運(yùn)行就死鎖,那倒也簡(jiǎn)單了。你可以馬上著手解決這個(gè)問(wèn)題。但真正的麻煩在于,程序看上去能正常運(yùn)行,但是卻潛伏著會(huì)引起死鎖的隱患。或許你認(rèn)為這里根本就不可能會(huì)有死鎖,而bug也就這樣潛伏下來(lái)了。直到有一天,讓某個(gè)用戶給撞上了(而且這種bug還很可能是不可重復(fù)的)。所以對(duì)并發(fā)編程來(lái)說(shuō),防止死鎖是設(shè)計(jì)階段的一個(gè)重要任務(wù)。

下面我們來(lái)看看由Dijkstra發(fā)現(xiàn)的經(jīng)典的死鎖場(chǎng)景:哲學(xué)家吃飯問(wèn)題。原版的故事里有五個(gè)哲學(xué)家(不過(guò)我們的例程里允許有任意數(shù)量)。 這些哲學(xué)家們只做兩件事,思考和吃飯。他們思考的時(shí)候,不需要任何共享資源,但是吃飯的時(shí)候,就必須坐到餐桌旁。餐桌上的餐具是有限的。原版的故事里,餐 具是叉子,吃飯的時(shí)候要用兩把叉子把面條從碗里撈出來(lái)。但是很明顯,把叉子換成筷子會(huì)更合理,所以:一個(gè)哲學(xué)家需要兩根筷子才能吃飯。

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

這個(gè)問(wèn)題之所以有趣就在于,它演示了這么一個(gè)程序,它看上去似乎能正常運(yùn)行,但是卻容易引起死鎖。

在告訴你如何修補(bǔ)這個(gè)問(wèn)題之前,先了解一下只有在下述四個(gè)條件同時(shí)滿足的情況下,死鎖才會(huì)發(fā)生:

  1. 互斥:也許線程會(huì)用到很多資源,但其中至少要有一項(xiàng)是不能共享的。
  2. 至少要有一個(gè)進(jìn)程會(huì)在占用一項(xiàng)資源的同時(shí)還在等另一項(xiàng)正被其它進(jìn)程所占用的資源。
  3. (調(diào)度系統(tǒng)或其他進(jìn)程)不能從進(jìn)程里搶資源。所有進(jìn)程都必須正常的釋放資源。
  4. 必需要有等待的環(huán)。一個(gè)進(jìn)程在一個(gè)已經(jīng)被另一進(jìn)程搶占了的資源,而那個(gè)進(jìn)程又在等另一個(gè)被第三個(gè)進(jìn)程搶占了的資源,以此類推,直到有個(gè)進(jìn)程正在等被第一個(gè)進(jìn)程搶占了的資源,這樣就形成了癱瘓性的阻塞了。

由于死鎖要同時(shí)滿足這四個(gè)條件,所用只要去掉其中一個(gè)就能防止死鎖。

Java語(yǔ)言沒(méi)有提供任何能預(yù)防死鎖的機(jī)制,所以只能靠你來(lái)設(shè)計(jì)了。

停止線程的正確方法

為了降低死鎖的發(fā)生幾率,Java 2放棄了Threadstop( ),suspend( )resume( )方法。

之所以要放棄stop( )是因?yàn)椋粫?huì)釋放對(duì)象的鎖,因此如果對(duì)象正處于無(wú)效狀態(tài)(也就是被破壞了),其它線程就可能會(huì)看到并且修改它了。這個(gè)問(wèn)題的后果可能非常微秒,因此難以察覺(jué)。所以別再用stop( )了,相反你應(yīng)該設(shè)置一個(gè)旗標(biāo)(flag)來(lái)告訴線程什么時(shí)候該停止。

打斷受阻的線程

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

線程組

線程組是一個(gè)裝線程的容器(collection)。用Joshua Bloch的話來(lái)講,它的意義可以概括為:

"最好把線程組看成是一次不成功的實(shí)驗(yàn),或者就當(dāng)它根本不存在。"

線程組還剩一個(gè)小用途。如果組里的線程拋出一個(gè)沒(méi)有被(異常處理程序)捕捉到的異常,就會(huì)啟動(dòng)ThreadGroup.uncaughtException( )。而它會(huì)在標(biāo)準(zhǔn)錯(cuò)誤流上打印出棧的軌跡。要想修改這個(gè)行為,你必須覆寫這個(gè)方法。

總結(jié)

要懂得什么時(shí)候用什么時(shí)候用并發(fā),什么時(shí)候不用并發(fā),這點(diǎn)非常重要。使用并發(fā)的主要理由包括:要管理大量的任務(wù),讓它們同時(shí)運(yùn)行以提高系統(tǒng)的利用率(包括在多CPU上透明的分配負(fù)載);更合理的組織代碼;以及方便用戶。平衡負(fù)載的一個(gè)經(jīng)典案例是在等待I/O的同時(shí)做計(jì)算。方便用戶的經(jīng)典案例是在用戶下載大文件的時(shí)候監(jiān)控"stop"按鈕。

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

多線程的主要缺點(diǎn)包括:

  1. 等待共享資源的時(shí)候,運(yùn)行速度會(huì)慢下來(lái)。
  2. 線程管理需要額外的CPU開(kāi)銷。
  3. 如果設(shè)計(jì)得不不合理,程序會(huì)變得異常負(fù)責(zé)。
  4. 會(huì)引發(fā)一些不正常的狀態(tài),像饑餓(starving),競(jìng)爭(zhēng)(racing),死鎖(deadlock),活鎖(livelock)。
  5. 不同平臺(tái)上會(huì)有一些不一致。比如我在開(kāi)發(fā)本書例程時(shí)發(fā)現(xiàn),在有些平臺(tái)下競(jìng)爭(zhēng)很快就出現(xiàn),但是換了臺(tái)機(jī)器,它根本就不出現(xiàn)。如果你在后者搞開(kāi)發(fā),然后發(fā)布到前者,那可就慘了。

線程的難點(diǎn)在于多個(gè)線程會(huì)共享同一項(xiàng)資源——比如對(duì)象的內(nèi)存——而你又必須確保同一時(shí)刻不會(huì)有兩個(gè)或兩個(gè)以上的線程去訪問(wèn)那項(xiàng)資源。這就需要合理地使用synchronized關(guān)鍵詞了,但是用之前必須完全理解,否則它會(huì)悄悄地地把死鎖了帶進(jìn)來(lái)。

此外線程的運(yùn)用方面還有某種藝術(shù)。Java的設(shè)計(jì)思想是,讓你能根據(jù)需要?jiǎng)?chuàng)建任意多的對(duì)象來(lái)解決問(wèn)題,至少理論上如此。(對(duì)Java來(lái)說(shuō)創(chuàng)建數(shù)以百萬(wàn)計(jì)的對(duì)象,比如工程方面的有限元分析,還不太現(xiàn)實(shí)。)但是你能創(chuàng)建的線程數(shù)量應(yīng)該還是有一個(gè)上限的,因?yàn)榈搅诉@個(gè)數(shù)量,線程就僵掉了。這個(gè)臨界點(diǎn)很難找,通常由OSJVM決定;或許是一百以內(nèi),也可能是幾千。不過(guò)通常你只需創(chuàng)建幾個(gè)線程就能解決問(wèn)題了,所以這還不算是什么限制;但是對(duì)于更為通用的設(shè)計(jì),這就是一個(gè)限制了。

線程方面一個(gè)重要,但卻不那么直觀的結(jié)論。那就是,通常你可以在run( )的主循環(huán)里插上yield( ), 然后讓線程調(diào)度機(jī)制幫你加快程序的運(yùn)行。這絕對(duì)是一種藝術(shù),特別是當(dāng)?shù)却娱L(zhǎng)之后,性能卻上升了。之所以會(huì)這樣是因?yàn)椋^短的延遲會(huì)使正在運(yùn)行的線程還沒(méi) 準(zhǔn)備好休眠就收到休眠結(jié)束的信號(hào),這樣為了能讓線程干完工作之后再休眠,調(diào)度機(jī)制不得不先把它停下來(lái)再喚醒它。額外的運(yùn)行環(huán)境的切換會(huì)導(dǎo)致運(yùn)行速度的下 降,而yield( )sleep( )則可以防止這種多余的切換。要理解這個(gè)問(wèn)題有多麻煩還真得好好想想。




                                          
2005年04月05日 12:07 PM