寫多線程Java應(yīng)用程序常見問題
在本文中,我們將探討使用多線程時遇到的問題,并提出那些常見陷阱的解決方案。
線程是什么?
一個程序或進(jìn)程能夠包含多個線程,這些線程可以根據(jù)程序的代碼執(zhí)行相應(yīng)的指令。多線程看上去似乎在并行執(zhí)行它們各自的工作,像在一臺計(jì)算機(jī)上運(yùn)行著多個處理機(jī)一樣。在多處理機(jī)計(jì)算機(jī)上實(shí)現(xiàn)多線程時,它們確實(shí)可以并行工作。和進(jìn)程不同的是,線程共享地址空間。也是說,多個線程能夠讀寫相同的變量或數(shù)據(jù)結(jié)構(gòu)。
編寫多線程程序時,你必須注意每個線程是否干擾了其他線程的工作?梢詫⒊绦蚩醋饕粋辦公室,如果不需要共享辦公室資源或與其他人交流,所有職員會獨(dú)立并行地工作。某個職員若要和其他人交談,當(dāng)且僅當(dāng)該職員在“聽”且他們兩說同樣的語言。此外,只有在復(fù)印機(jī)空閑且處于可用狀態(tài)(沒有僅完成一半的復(fù)印工作,沒有紙張阻塞等問題)時,職員才能夠使用它。在這篇文章中你將看到,在 Java 程序中互相協(xié)作的線程好像是在一個組織良好的機(jī)構(gòu)中工作的職員。
在多線程程序中,線程可以從準(zhǔn)備緒隊(duì)列中得到,并在可獲得的系統(tǒng) CPU 上運(yùn)行。操作系統(tǒng)可以將線程從處理器移到準(zhǔn)備緒隊(duì)列或阻塞隊(duì)列中,這種情況可以認(rèn)為是處理器“掛起”了該線程。同樣,Java 虛擬機(jī) (JVM) 也可以控制線程的移動 在協(xié)作或搶先模型中 從準(zhǔn)備緒隊(duì)列中將進(jìn)程移到處理器中,于是該線程可以開始執(zhí)行它的程序代碼。
協(xié)作式線程模型允許線程自己決定什么時候放棄處理器來等待其他的線程。程序開發(fā)員可以地決定某個線程何時會被其他線程掛起,允許它們與對方有效地合作。缺點(diǎn)在于某些惡意或是寫得不好的線程會消耗所有可獲得的 CPU 時間,導(dǎo)致其他線程“饑餓”。
在搶占式線程模型中,操作系統(tǒng)可以在任何時候打斷線程。通常會在它運(yùn)行了一段時間(是所謂的一個時間片)后才打斷它。這樣的結(jié)果自然是沒有線程能夠不公平地長時間霸占處理器。然而,隨時可能打斷線程會給程序開發(fā)員帶來其他麻煩。同樣使用辦公室的例子,假設(shè)某個職員搶在另一人前使用復(fù)印機(jī),但打印工作在未完成的時候離開了,另一人接著使用復(fù)印機(jī)時,該復(fù)印機(jī)上可能還有先前那名職員留下來的資料。搶占式線程模型要求線程正確共享資源,協(xié)作式模型卻要求線程共享執(zhí)行時間。由于 JVM 規(guī)范并沒有特別規(guī)定線程模型,Java 開發(fā)員必須編寫可在兩種模型上正確運(yùn)行的程序。在了解線程以及線程間通訊的一些方面之后,我們可以看到如何為這兩種模型設(shè)計(jì)程序。
線程和 Java 語言
為了使用 Java 語言創(chuàng)建線程,你可以生成一個 Thread 類(或其子類)的對象,并給這個對象發(fā)送 start() 消息。(程序可以向任何一個派生自 Runnable 接口的類對象發(fā)送 start() 消息。)每個線程動作的定義包含在該線程對象的 run() 方法中。run 方法相當(dāng)于傳統(tǒng)程序中的 main() 方法;線程會持續(xù)運(yùn)行,直到 run() 返回為止,此時該線程便死了。
上鎖
大多數(shù)應(yīng)用程序要求線程互相通信來同步它們的動作。在 Java 程序中最簡單實(shí)現(xiàn)同步的方法是上鎖。為了防止同時訪問共享資源,線程在使用資源的前后可以給該資源上鎖和開鎖。假想給復(fù)印機(jī)上鎖,任一時刻只有一個職員擁有鑰匙。若沒有鑰匙不能使用復(fù)印機(jī)。給共享變量上鎖使得 Java 線程能夠快速方便地通信和同步。某個線程若給一個對象上了鎖,可以知道沒有其他線程能夠訪問該對象。即使在搶占式模型中,其他線程也不能夠訪問此對象,直到上鎖的線程被喚醒、完成工作并開鎖。那些試圖訪問一個上鎖對象的線程通常會進(jìn)入睡眠狀態(tài),直到上鎖的線程開鎖。一旦鎖被打開,這些睡眠進(jìn)程會被喚醒并移到準(zhǔn)備緒隊(duì)列中。
在 Java 編程中,所有的對象都有鎖。線程可以使用 synchronized 關(guān)鍵字來獲得鎖。在任一時刻對于給定的類的實(shí)例,方法或同步的代碼塊只能被一個線程執(zhí)行。這是因?yàn)榇a在執(zhí)行之前要求獲得對象的鎖。繼續(xù)我們關(guān)于復(fù)印機(jī)的比喻,為了避免復(fù)印沖突,我們可以簡單地對復(fù)印資源實(shí)行同步。如同下列的代碼例子,任一時刻只允許一位職員使用復(fù)印資源。通過使用方法(在 Copier 對象中)來修改復(fù)印機(jī)狀態(tài)。這個方法是同步方法。只有一個線程能夠執(zhí)行一個 Copier 對象中同步代碼,因此那些需要使用 Copier 對象的職員必須排隊(duì)等候。
class CopyMachine {
public synchronized void makeCopies(Document d, int nCopies) {
// only one thread executes this at a time
}
public void loadPaper() {
// multiple threads could access this at once!
synchronized(this) {
// only one thread accesses this at a time
// feel free to use shared resources, overwrite members, etc.
}
}
}正如前面所提到的,在搶占式模型中線程可以在代碼的任何一個部分的中間被打斷,除非那是一個原子操作代碼塊。原子操作代碼塊中的代碼段一旦開始執(zhí)行,要在該線程被換出處理器之前執(zhí)行完畢。在 Java 編程中,分配一個小于 32 位的變量空間是一種原子操作,而此外象 double 和 long 這兩個 64 位數(shù)據(jù)類型的分配不是原子的。使用鎖來正確同步共享資源的訪問,足以保證一個多線程程序在搶占式模型下正確工作。
而在協(xié)作式模型中,是否能保證線程正常放棄處理器,不掠奪其他線程的執(zhí)行時間,則完全取決于程序員。調(diào)用 yield() 方法能夠?qū)?dāng)前的線程從處理器中移出到準(zhǔn)備緒隊(duì)列中。另一個方法則是調(diào)用 sleep() 方法,使線程放棄處理器,并且在 sleep 方法中指定的時間間隔內(nèi)睡眠。
正如你所想的那樣,將這些方法隨意放在代碼的某個地方,并不能夠保證正常工作。如果線程正擁有一個鎖(因?yàn)樗谝粋同步方法或代碼塊中),則當(dāng)它調(diào)用 yield() 時不能夠釋放這個鎖。這意味著即使這個線程已經(jīng)被掛起,等待這個鎖釋放的其他線程依然不能繼續(xù)運(yùn)行。為了緩解這個問題,不在同步方法中調(diào)用 yield 方法。將那些需要同步的代碼包在一個同步塊中,里面不含有非同步的方法,并且在這些同步代碼塊之外才調(diào)用 yield。
另外一個解決方法則是調(diào)用 wait() 方法,使處理器放棄它當(dāng)前擁有的對象的鎖。如果對象在方法級別上使同步的,這種方法能夠很好的工作。因?yàn)樗鼉H僅使用了一個鎖。如果它使用 fine-grained 鎖,則 wait() 將無法放棄這些鎖。此外,一個因?yàn)檎{(diào)用 wait() 方法而阻塞的線程,只有當(dāng)其他線程調(diào)用 notifyAll() 時才會被喚醒。
線程和AWT/Swing
在那些使用 Swing 和/或 AWT 包創(chuàng)建 GUI (用戶圖形界面)的 Java 程序中,AWT 事件句柄在它自己的線程中運(yùn)行。開發(fā)員必須注意避免將這些 GUI 線程與較耗時間的計(jì)算工作綁在一起,因?yàn)檫@些線程必須負(fù)責(zé)處理用戶時間并重繪用戶圖形界面。換句話來說,一旦 GUI 線程處于繁忙,整個程序看起來象無響應(yīng)狀態(tài)。Swing 線程通過調(diào)用合適方法,通知那些 Swing callback (例如 Mouse Listener 和 Action Listener )。 這種方法意味著 listener 無論要做多少事情,都應(yīng)當(dāng)利用 listener callback 方法產(chǎn)生其他線程來完成此項(xiàng)工作。目的便在于讓 listener callback 更快速返回,從而允許 Swing 線程響應(yīng)其他事件。
如果一個 Swing 線程不能夠同步運(yùn)行、響應(yīng)事件并重繪輸出,那怎么能夠讓其他的線程地修改 Swing 的狀態(tài)?正如上面提到的,Swing callback 在 Swing 線程中運(yùn)行。因此他們能修改 Swing 數(shù)據(jù)并繪到屏幕上。
但是如果不是 Swing callback 產(chǎn)生的變化該怎么辦呢?使用一個非 Swing 線程來修改 Swing 數(shù)據(jù)是不的。Swing 提供了兩個方法來解決這個問題:invokeLater() 和 invokeAndWait()。為了修改 Swing 狀態(tài),只要簡單地調(diào)用其中一個方法,讓 Runnable 的對象來做這些工作。因?yàn)?Runnable 對象通常是它們自身的線程,你可能會認(rèn)為這些對象會作為線程來執(zhí)行。但那樣做其實(shí)也是不的。事實(shí)上,Swing 會將這些對象放到隊(duì)列中,并在將來某個時刻執(zhí)行它的 run 方法。這樣才能夠修改 Swing 狀態(tài)。
總結(jié)
Java 語言的設(shè)計(jì),使得多線程對幾乎所有的 Applet 都是必要的。特別是,IO 和 GUI 編程都需要多線程來為用戶提供完美的體驗(yàn)。如果依照本文所提到的若干基本規(guī)則,并在開始編程前仔細(xì)設(shè)計(jì)系統(tǒng) 包括它對共享資源的訪問等,你可以避免許多常見和難以發(fā)覺的線程陷阱