一、Java環境下的多線程技術
構建線程化的應用程序往往會對程序帶來重要的性能影響。例如,請考慮這樣一個程序,它從磁盤讀取大量數據並且在把它們寫到屏幕之前處理這些數據(例如一個DVD播放器)。在一個傳統的單線程程序(今天所使用的大多數客戶端程序)上,一次只有一個任務執行,每一個這些活動分別作為一個序列的不同階段發生。只有在一塊已定義大小的數據讀取完成時才能進行數據處理。因此,能處理數據的程序邏輯直到磁盤讀操作完成後才得到執行。這將導致非常差的性能問題。
在一個多線程程序中,可以分配一個線程來讀取數據,讓另一個線程來處理數據,而讓第三個線程把數據輸送到圖形卡上去。這三個線程可以並行運行;這樣以來,在磁盤讀取數據的同時仍然可以處理數據,從而提高了整體程序的性能。許多大量的示例程序都可以被設計來同時做兩件事情以進一步提高性能。Java虛擬機(JVM)本身就是基於此原因廣泛使用了多線程技術。
本文將討論創建多線程Java代碼以及一些進行並行程序設計的最好練習;另外還介紹了對開發者極為有用的一些工具和資源。篇幅所限,不可能全面論述這些問題,所以我想只是重點提一下極重要的地方並提供給你相應的參考信息。
二、線程化Java代碼
所有的程序都至少使用一個線程。在C/C++和Java中,這是指用對main()的調用而啟動的那個線程。另外線程的創建需要若干步驟:創建一個新線程,然後指定給它某種工作。一旦工作做完,該線程將自動被JVM所殺死。
Java提供兩個方法來創建線程並且指定給它們工作。第一種方法是子類化Java的Thread類(在java.lang包中),然後用該線程的工作函數重載run()方法。下面是這種方法的一個示例:
public class SimpleThread extends Thread {
public SimpleThread(String str) {
super(str);
}
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println(i + " " + getName());
try {
sleep((long)(Math.random() * 1000));
} catch (InterruptedException e) {}
}
System.out.println("DONE! " + getName());
}
}
這個類子類化Thread並且提供它自己的run()方法。上面代碼中的函數運行一個循環來打印傳送過來的字符串到屏幕上,然後等待一個隨機的時間數目。在循環十次後,該函數打印“DONE!”,然後退出-並由它殺死這個線程。下面是創建線程的主函數:
public class TwoThreadsDemo {
public static void main (String[] args) {
new SimpleThread("Do it!").start();
new SimpleThread("Definitely not!").start();
}
}
注意該代碼極為簡單:函數開始,給定一個名字(它是該線程將要打印輸出的字符串)並且調用start()。然後,start()將調用run()方法。程序的結果如下所示:
0 Do it!
0 Definitely not!
1 Definitely not!
2 Definitely not!
1 Do it!
2 Do it!
3 Do it!
3 Definitely not!
4 Do it!
4 Definitely not!
5 Do it!
5 Definitely not!
6 Do it!
7 Do it!
6 Definitely not!
8 Do it!
7 Definitely not!
8 Definitely not!
9 Do it!
DONE! Do it!
9 Definitely not!
DONE! Definitely not!
正如你所看到的,這兩個線程的輸出結果糾合到一起。在一個單線程程序中,所有的“Do it!”命令將一起打印,後面跟著輸出“Definitely not!”。
這個程序的不同運行將產生不同的結果。這種不確定性來源於兩個方面:在循環中有一個隨機的暫停;更為重要的是,因為線程執行時間沒法保證。這是一個關鍵的原則。JVM將根據它自己的時間表運行這些進程(虛擬機一般支持盡可能快地運行這些線程,但是沒法保證何時運行一個給定線程)。對於每個線程可以使一個優先級與之相關聯以確保關鍵線程被JVM處理在次要的線程之前。
啟動一個線程的第二種方法是使用一個實現Runnable接口的類-這個接口也定義在java.lang中。這個Runnable接口指定一個run()方法-然後該方法成為線程的主函數,類似於前面的代碼。
現在,Java程序的一般風格是支持繼承的接口。通過使用接口,一個類在後面仍然能夠繼承(子類化)-如果必要的話(例如,如果該類要在後面作為一個applet使用的話,就會發生這種情況)。
三、線程的含義
在采用多線程技術增強性能的同時,它也增加了程序內部運行的復雜性。這種復雜性主要是由線程之間的交互引起的。熟悉這些問題是很重要的,因為隨著越來越多的核心芯片加入到Intel處理器中,要使用的線程數目也將相應地增長。如果在創建多線程程序時不能很好地理解這些問題,那麼是調試時將很難發現錯誤。因此,讓我們先看一下這些問題及其解決辦法。
等待另一個線程完成:假定我們有一個整型數組要進行處理。我們可以遍歷這個數組,每次一個整數並執行相應的操作。或,更高效地,我們可以建立多個線程,這樣以來讓每個線程處理數組的一部分。假定我們在開始下一步之前必須等待所有的線程結束。為了暫時同步線程之間的活動,這些線程使用了join()方法-它使得一個線程等待另一個線程的完成。加入的線程(線程B)等待被加入的線程(線程A)的完成。在join()中的一個可選的超時值使得線程B可以繼續處理其它工作-如果線程A在給定的時間幀內還沒有終止的話。這個問題將觸及到線程的核心復雜性-等待線程的問題。下面我們將討論這個問題。
在鎖定對象上等待:假定我們編寫一個航空公司座位分配系統。在開發這種大型的程序時,為每個連接到該軟件的用戶分配一個線程是很經常的,如一個線程對應一個機票銷售員(在很大的系統中,情況並非總是如此)。如果有兩個用戶同時想分配同一個座位,就會出現問題。除非采取特殊的措施,否則一個線程將分配該座位而另一個線程將會在做相同的事情。兩個用戶都會認為他們在這趟航班上擁有一個分配的位子。
為了避免兩個線程同時修改一樣的數據項,我們讓一個線程在修改數據前鎖定數據項。用這種方法,當第二個線程開始作修改時,它將等待到第一個線程釋放鎖為止。當這種發生時,線程將會看到座位已被分配,而對於座位分配的請求就會失敗。兩個線程競爭分配座位的問題也就是著名的競爭條件問題,而當競爭發生時有可能導致系統的洩漏。為此,最好的辦法就是鎖定任何代碼-該代碼存取一個可由多個線程共同存取的變量。
在Java中存在好幾種鎖選擇。其中最為常用的是使用同步機制。當一個方法的簽名包含同步時,在任何給定時間只有一個線程能夠執行這個方法。然後,當該方法完成執行時,對該方法的鎖定即被解除。例如,
protected synchronized int reserveSeat ( Seat seat_number ){
if ( seat_number.getReserved() == false ){
seat_number.setReserved();
return ( 0 );
}
else return ( -1 );
}
就是一個方法-在這種方法中每次只運行一個線程。這種鎖機制就打破了上面所描述的競爭條件。
使用同步是處理線程間交互的幾種方法中的一種。J2SE 5.0中添加了若干方便的方法來鎖定對象。大多數這些方法可以在包java.util.concurrent.locks中找到-一旦你熟悉了Java線程,就應該對它進行詳細的研究。
在鎖機制解決了競爭條件的同時,它們也帶來了新的復雜性。在這種情況下,最困難的問題就是死鎖。假定線程A在等待線程B,並且線程B在等待線程A,那麼這兩個線程將永遠被鎖定-這正是術語死鎖的意義。死鎖問題可能很難判定,並且必須相當小心以確保在線程之間沒有這種依賴性。
四、使用線程池
如前所提及,在線程完成執行時,它們將被JVM殺死而分配給它們的內存將被垃圾回收機制所回收。不斷地創建和毀滅線程所帶來的麻煩是它浪費了時鐘周期,因為創建線程確實耗費額外的時間。一個通用的且最好的實現是在程序運行的早期就分配一組線程(稱為一個線程池),然後在這些線程可用時再使用它們。通過使用這種方案,在創建時分配給一個線程指定的功能就是呆在線程池中並且等待分配一項工作。然後,當分配的工作完成時,該線程被返回到線程池。
J2SE 5.0引入了java.util.concurrent包-它包括了一個預先構建的線程池框架-這大大便利了上述方法的實現。有關Java線程池的更多信息及一部教程,請參見http://java.sun.com/developer/JDCTechTips/2004/tt1116.html#2。
在設計線程程序和線程池時,自然出現關於應該創建多少線程的問題。答案看你怎樣計劃使用這些線程。如果你基於分離的任務來用線程劃分工作,那麼線程的數目等於任務的數目。例如,一個字處理器可能使用一個線程用於顯示(在幾乎所有系統中的主程序線程負責更新用戶接口),一個用於標記文檔,第三個用於拼寫檢查,而第四個用於其它後台操作。在這種情況中,創建四個線程是理想的並且它們提供了編寫該類軟件的一個很自然的方法。
然而,如果程序-象早些時候所討論的那個一樣-使用多個線程來做類似的工作,那麼線程的最佳數目將是系統資源的反映,特別是處理器上可執行管道的數目和處理器的數目的反映。在采用英特爾處理器超線程技術(HT技術)的系統上,當前在每個處理器核心上有兩個執行管道。最新的多核心處理器在每個芯片上有兩個處理器核心。英特爾指出將來的芯片有可能具有多個核心,大部分是因為額外的核心會帶來更高的性能而不會從根本上增加熱量或電量的消耗。因此,管道數將會越來越多。
照上面這些體系結構所作的算術建議,在一個雙核心Pentium 4處理器系統上,可以使用四條執行管道並因此可以使用四個線程將會提供理想的性能。在一個雙處理器英特爾Xeon?處理器的工作站上,理想的線程數目是4,因為目前Xeon芯片提供HT技術但是沒提供多核心模型。你可以參考下面文檔來了解這些新型處理器上的執行管道的數目(http://www.intel.com/cd/ids/developer/asmo-na/eng/196716.htm)。
五、小結
你當在平台上運行線程化的Java程序時,你將可能想要監控在處理器上的加載過程與線程的執行。最好的獲得這些數據與管理JVM怎樣處理並行處理的JVM之一是BEA的WebLogic JRockit。JRockit還有其它一些由來自於BEA和Intel公司的工程師專門為Intel平台設計和優化的優點。
不考慮你使用哪一種JVM,Intel的VTune Performance Analyzer將會給你一個關於JVM怎樣執行你的代碼的很深入的視圖-這包括每個線程的性能瓶頸等。另外,Intel還提供了關於如何在Java環境下使用VTune Performance Analyzer的白皮書[PDF 2MB]。
總之,本文提供了線程在Java平台工作機理的分析。由於Intel還將繼續生產HT技術的處理器並且發行更多的多核心芯片,所以想從這些多管道中得到性能效益的壓力也會增加。並且,由於核心芯片數目的增加,管道的數目也將相應地增加。唯一的利用它們的優點的辦法就是使用多線程技術,如在本文中所討論的。並且Java多線程程序的優勢也越來越明顯。