一、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多線程程式的優勢也越來越明 顯。