當項目中需要 XML 解析器、文本索引程序和搜索引擎、正則表達式編譯器、 XSL 處理器或 PDF 生成器時,我們中大多數人從不會考慮自己去編寫這些實用 程序。每當需要這些設施時,我們會使用商業實現或開放源碼實現來執行這些任 務原因很簡單 ― 現有實現工作得很好,而且易於使用,自己編寫這些實用程序 會事倍功半,或者甚至得不到結果。作為軟件工程師,我們更願意遵循艾薩克 ・牛頓的信念 ― 站在巨人的肩膀之上,有時這是可取的,但並不總是這 樣。(在 Richard Hamming 的 Turing Award 講座中,他認為計算機科學家的 “自立”要更可取。)
探究重復發明“車輪”之 原因
對於一些幾乎每個服務器應用程序都需要的低級應用程序框架服務 (如日志記錄、數據庫連接合用、高速緩存和任務調度等),我們看到這些基本 的基礎結構服務被一遍又一遍地重寫。為什麼會發生這種情況?因為現有的選擇 不夠充分,或者因為定制版本要更好些或更適合手邊的應用程序,但我認為這是 不必要的。事實上,專為某個應用程序開發的定制版本常常並不比廣泛可用的、 通用的實現更適合於該應用程序,也許會更差。例如,盡管您不喜歡 log4j,但 它可以完成任務。盡管自己開發的日志記錄系統也許有一些 log4j 所缺乏的特 定特性,但對於大多數應用程序,您很難證明,一個完善的定制日志記錄包值得 付出從頭編寫的代價,而不使用現有的、通用的實現。可是,許多項目團隊最終 還是自己一遍又一遍地編寫日志記錄、連接合用或線程調度包。
表面上 看起來簡單
我們不考慮自己去編寫 XSL 處理器的原因之一是,這將花費 大量的工作。但這些低級的框架服務表面上看起來簡單,所以自己編寫它們似乎 並不困難。然而,它們很難正常工作,並不象開始看起來那樣。這些特殊的 “輪子”一直處在重復發明之中的主要原因是,在給定的應用程序中 ,往往一開始對這些工具的需求非常小,但當您遇到了無數其它項目中也存在的 同樣問題時,這種需求會逐漸變大。理由通常象這樣:“我們不需要完善 的日志記錄/調度/高速緩存包,只需要一些簡單的包,所以只編寫一些能達到 我們目的的包,我們將針對自己特定的需求來調整它”。但情況往往是, 您很快擴展了所編寫的這個簡單工具,並試圖添加再添加更多的特性,直到編寫 出一個完善的基礎結構服務。至此,您通常會執著於自己所編寫的程序,無論它 是好是壞。您已經為構建自己的程序付出了全部的代價,所以除了轉至通用的實 現所實際投入的遷移成本之外,還必須克服這種“已支付成本”的障 礙。
並發構件的價值所在
編寫調度和並發基礎結構類的確要比看上去難。Java 語言提供了一組有用的 低級同步原語: wait() 、 notify() 和 synchronized ,但具體使用這些原語 需要一些技巧,需要考慮性能、死鎖、公平性、資源管理以及如何避免線程安全 性方面帶來的危害等諸多因素。並發代碼難以編寫,更難以測試 ― 即使專家有 時在第一次時也會出現錯誤。 Concurrent Programming in Java(請參閱 參考 資料)的作者 Doug Lea 編寫了一個極其優秀的、免費的並發實用程序包,它包 括並發應用程序的鎖、互斥、隊列、線程池、輕量級任務、有效的並發集合、原 子的算術操作和其它基本構件。人們一般稱這個包為 util.concurrent (因為 它實際的包名很長),該包將形成 Java Community Process JSR 166 正在標准 化的 JDK 1.5 中 java.util.concurrent 包的基礎。同時, util.concurrent 經過了良好的測試,許多服務器應用程序(包括 JBoss J2EE 應用程序服務器) 都使用這個包。
填補空白
核心 Java 類庫中略去了一組有用的高級同步工具(譬如互斥、信號和阻塞 、線程安全集合類)。Java 語言的並發原語 ― synchronization 、 wait() 和 notify() ― 對於大多數服務器應用程序的需求而言過於低級。如果要試圖 獲取鎖,但如果在給定的時間段內超時了還沒有獲得它,會發生什麼情況?如果 線程中斷了,則放棄獲取鎖的嘗試?創建一個至多可有 N 個線程持有的鎖?支 持多種方式的鎖定(譬如帶互斥寫的並發讀)?或者以一種方式來獲取鎖,但以 另一種方式釋放它?內置的鎖定機制不直接支持上述這些情形,但可以在 Java 語言所提供的基本並發原語上構建它們。但是這樣做需要一些技巧,而且容易出 錯。
服務器應用程序開發人員需要簡單的設施來執行互斥、同步事件響應、跨活 動的數據通信以及異步地調度任務。對於這些任務,Java 語言所提供的低級原 語很難用,而且容易出錯。 util.concurrent 包的目的在於通過提供一組用於 鎖定、阻塞隊列和任務調度的類來填補這項空白,從而能夠處理一些常見的錯誤 情況或者限制任務隊列和運行中的任務所消耗的資源。
調度異步任務
util.concurrent 中使用最廣泛的類是那些處理異步事件調度的類。在本專 欄七月份的文章中,我們研究了 thread pools and work queues,以及許多 Java 應用程序是如何使用“ Runnable 隊列”模式調度小工作單元。
可以通過簡單地為某個任務創建一個新線程來派生執行該任務的後端線程, 這種做法很吸引人:
new Thread(new Runnable() { ... } ).start();
雖然這種做法很好,而且很簡潔,但有兩個重大缺陷。首先,創建新的線程 需要耗費一定資源,因此產生出許許多多線程,每個將執行一個簡短的任務,然 後退出,這意味著 JVM 也許要做更多的工作,創建和銷毀線程而消耗的資源比 實際做有用工作所消耗的資源要多。即使創建和銷毀線程的開銷為零,這種執行 模式仍然有第二個更難以解決的缺陷 ― 在執行某類任務時,如何限制所使用的 資源?如果突然到來大量的請求,如何防止同時會產生大量的線程?現實世界中 的服務器應用程序需要比這更小心地管理資源。您需要限制同時執行異步任務的 數目。
線程池解決了以上兩個問題 — 線程池具有可以同時提高調度效率和限制資 源使用的好處。雖然人們可以方便地編寫工作隊列和用池線程執行 Runnable 的 線程池(七月份那篇專欄文章中的示例代碼正是用於此目的),但編寫有效的任 務調度程序需要做比簡單地同步對共享隊列的訪問更多的工作。現實世界中的任 務調度程序應該可以處理死線程,殺死超量的池線程,使它們不消耗不必要的資 源,根據負載動態地管理池的大小,以及限制排隊任務的數目。為了防止服務器 應用程序在過載時由於內存不足錯誤而造成崩潰,最後一項(即限制排隊的任務 數目)是很重要的。
限制任務隊列需要做決策 ― 如果工作隊列溢出,則如何處理這種溢出?拋 棄最新的任務?拋棄最老的任務?阻塞正在提交的線程直到隊列有可用的空間? 在正在提交的線程內執行新的任務?存在著各種切實可行的溢出管理策略,每種 策略都會在某些情形下適合,而在另一些情形下不適合。
Executor
Util.concurrent 定義一個 Executor 接口,以異步地執行 Runnable ,另 外還定義了 Executor 的幾個實現,它們具有不同的調度特征。將一個任務排入 executor 的隊列非常簡單:
Executor executor = new QueuedExecutor();
...
Runnable runnable = ... ;
executor.execute(runnable);
最簡單的實現 ThreadedExecutor 為每個 Runnable 創建了一個新線程,這 裡沒有提供資源管理 ― 很象 new Thread(new Runnable() {}).start() 這個 常用的方法。但 ThreadedExecutor 有一個重要的好處:通過只改變 executor 結構,就可以轉移到其它執行模型,而不必緩慢地在整個應用程序源碼內查找所 有創建新線程的地方。 QueuedExecutor 使用一個後端線程來處理所有任務,這 非常類似於 AWT 和 Swing 中的事件線程。 QueuedExecutor 具有一個很好的特 性:任務按照排隊的順序來執行,因為是在一個線程內來執行所有的任務,任務 無需同步對共享數據的所有訪問。
PooledExecutor 是一個復雜的線程池實現,它不但提供工作線程(worker thread)池中任務的調度,而且還可靈活地調整池的大小,同時還提供了線程生 命周期管理,這個實現可以限制工作隊列中任務的數目,以防止隊列中的任務耗 盡所有可用內存,另外還提供了多種可用的關閉和飽和度策略(阻塞、廢棄、拋 出、廢棄最老的、在調用者中運行等)。所有的 Executor 實現為您管理線程的 創建和銷毀,包括當關閉 executor 時,關閉所有線程,另外還為線程創建過程 提供了 hook,以便應用程序可以管理它希望管理的線程實例化。例如,這使您 可以將所有工作線程放在特定的 ThreadGroup 中,或者賦予它們描述性名稱。
FutureResult
有時您希望異步地啟動一個進程,同時希望在以後需要這個進程時,可以使 用該進程的結果。 FutureResult 實用程序類使這變得很容易。 FutureResult 表示可能要花一段時間執行的任務,並且可以在另一個線程中執行此任務, FutureResult 對象可用作執行進程的句柄。通過它,您可以查明該任務是否已 經完成,可以等待任務完成,並檢索其結果。可以將 FutureResult 與 Executor 組合起來;可以創建一個 FutureResult 並將其排入 executor 的隊 列,同時保留對 FutureResult 的引用。清單 1 顯示了一個一同使用 FutureResult 和 Executor 的簡單示例,它異步地啟動圖像著色,並繼續進行 其它處理:
清單 1. 運作中的 FutureResult 和 Executor
Executor executor = ...
ImageRenderer renderer = ...
FutureResult futureImage = new FutureResult();
Runnable command = futureImage.setter(new Callable() {
public Object call() { return renderer.render(rawImage); }
});
// start the rendering process
executor.execute(command);
// do other things while executing
drawBorders();
drawCaption();
// retrieve the future result, blocking if necessary
drawImage((Image)(futureImage.get())); // use future
FutureResult 和高速緩存
還可以使用 FutureResult 來提高按需裝入高速緩存的並發性。通過將 FutureResult 放置在高速緩存內,而不是放置計算本身的結果,可以減少持有 高速緩存上寫鎖的時間。雖然這種做法不能加快第一個線程把某一項放入高速緩 存,但它 將減少第一個線程阻塞其它線程訪問高速緩存的時間。它還使其它線 程更早地使用結果,因為它們可以從高速緩存中檢索 FutureTask 。清單 2 顯 示了使用用於高速緩存的 FutureResult 示例:
清單 2. 使用 FutureResult 來改善高速緩存
public class FileCache {
private Map cache = new HashMap();
private Executor executor = new PooledExecutor();
public void get(final String name) {
FutureResult result;
synchronized(cache) {
result = cache.get(name);
if (result == null) {
result = new FutureResult();
executor.execute(result.setter(new Callable() {
public Object call() { return loadFile(name); }
}));
cache.put(result);
}
}
return result.get();
}
}
這種方法使第一個線程快速地進入和退出同步塊,使其它線程與第一個線程 一樣快地得到第一個線程計算的結果,不可能出現兩個線程都試圖計算同一個對 象。
結束語
util.concurrent 包包含許多有用的類,您可能認為其中一些類與您已編寫 的類一樣好,也許甚至比從前還要好。它們是許多多線程應用程序的基本構件的 高性能實現,並經歷了大量測試。 util.concurrent 是 JSR 166 的切入點,它 將帶來一組並發性的實用程序,這些實用程序將成為 JDK 1.5 中的 java.util.concurrent 包,但您不必等到那時侯才能使用它。在以後的文章中 ,我將討論 util.concurrent 中一些定制的同步類,並研究 util.concurrent 和 java.util.concurrent API 中的不同之處。