當單線程應用程序中的主線程拋出一個未捕獲的異常時,因為控制台中會打 印堆棧跟蹤(也因為程序停止),所以您很可能注意到。但在多線程應用程序中 ,尤其是在作為服務器運行並且不與控制台相連的應用程序中,線程死亡可能成 為不太引人注目的事件,這會導致局部系統失敗,從而產生混亂的應用程序行為 。
在 Java theory and practice十月份的專欄文章 中,我們研究了線程池, 並研究了編寫得不正確的線程池會如何“洩漏”線程,直到最終丟失所有線程。 大多數線程池實現通過捕獲拋出的異常或重新啟動死亡的線程來防止這一點,但 線程洩漏的問題並不僅限於線程池 ― 使用線程來為工作隊列提供服務的服務器 應用程序也可能具有這種問題。當服務器應用程序丟失了一個工作線程(worker thread)時,在較長時間內應用程序仍可能顯得一切正常,這使得該問題的真實 原因難以確定。
許多應用程序用線程來提供後台服務 ― 處理來自事件隊列的任務、從套接 字讀取命令或執行 UI 線程以外的長期任務。當由於拋出未捕獲的 RuntimeException 或 Error ,或者只是停下來,等待阻塞的 I/O 操作(原本 未預計到阻塞),從而引起這些線程之一死亡時,會發生什麼呢?
有時,譬如當線程執行由用戶啟動的長期任務(如拼寫檢查)時,用戶會注 意到任務沒有進展,他們可能會異常終止操作或程序。但其它時間,後台線程執 行“清理維護”任務 ,它們可能消失很長時間而不被察覺。
示例服務器應用程序
考慮這樣一個假設的中間件服務器應用程序,它聚合來自各種輸入源的消息 ,然後將它們提交到外部服務器應用程序,從外部應用程序接收響應並將響應路 由回適當的輸入源。對於每個輸入源,都有一個以其自己的方式接受其輸入消息 的插件(通過掃描文件目錄、等待套接字連接、輪詢數據庫表等)。插件可以由 第三方編寫,即使它們是在服務器 JVM 上運行的。這個應用程序擁有(至少) 兩個內部工作隊列 ― 從插件處接收的正在等待被發送到服務器的消息(“出站 消息”隊列),以及從服務器接收的正在等待被傳遞到適當插件的響應(“入站 響應”隊列)。通過調用插件對象上的服務例程 incomingResponse() ,消息被 路由到最初發出請求的插件。
從插件接收消息後,就被排列到出站消息隊列中。由一個或多個從隊列讀取 消息的線程處理出站消息隊列中的消息、記錄其來源並將它提交給遠程服務器應 用程序(假定通過 Web 服務接口)。遠程應用程序最終通過 Web 服務接口返回 響應,然後我們的服務器將接收的響應排列到入站響應隊列中。一個或多個響應 線程從入站響應隊列讀取消息並將其路由到適當的插件,從而完成往返“旅程” 。
在這個應用程序中,有兩個消息隊列,分別用於出站請求和入站響應,不同 的插件內可能也有另外的隊列。我們還有幾種服務線程,一個從出站消息隊列讀 取請求並將其提交給外部服務器,一個從入站響應隊列讀取響應並將其路由到插 件,在用於向套接字或其它外部請求源提供服務的插件中可能也有一些線程。
線程失敗時並不總是顯而易見的
如果這些線程中的一個(如響應分派線程)消失了,將會發生什麼?因為插 件仍能夠提交新消息,所以它們可能不會立即注意到某些方面出錯了。消息仍將 通過各種輸入源到達,並通過我們的應用程序提交到外部服務。因為插件並不期 待立即獲得其響應,因此它仍沒有意識到出了問題。最後,接收的響應將排滿隊 列。如果它們存儲在內存中,那麼最終將耗盡內存。即使不耗盡內存,也會有人 在某個時刻發現響應得不到傳遞 ― 但這可能需要一些時間,因為系統的其它方 面仍能正常發揮作用。
當主要的任務處理方面由線程池而不是單個線程來處理時,對於偶然的線程 洩漏的後果有一定程度的保護,因為一個執行得很好的八線程的線程池,用七個 線程完成其工作的效率可能仍可以接受。起初,可能沒有任何顯著的差異。但是 ,系統性能最終將下降,雖然這種下降的方式不易被察覺。
服務器應用程序中的線程洩漏問題在於不是總是容易從外部檢測它。因為大 多數線程只處理服務器的部分工作負載,或可能僅處理特定類型的後台任務,所 以當程序實際上遭遇嚴重故障時,在用戶看來它仍在正常工作。這一點,再加上 引起線程洩漏的因素並不總是留下明顯痕跡,就會引起令人驚訝甚或使人迷惑的 應用程序行為。
RuntimeException 是導致線程死亡的首要原因
當線程拋出未捕獲的異常或錯誤時它們可能消失;而當線程等待的 I/O 操作 永遠不會完成,或沒人為它們等待的監視器調用 notify() 時,它們只是停止工 作。意外線程死亡的最常見根源是 RuntimeException (如 NullPointerException 、 ArrayIndexOutOfBoundsException 等)。在我們的 示例應用程序中,在通過調用插件對象上的 incomingResponse() 將響應傳遞回 插件時,可能拋出 RuntimeException 。插件代碼可能是由第三方編寫的,或者 可能是在編寫完應用程序之後編寫的,因此應用程序編寫者不可能審核其正確性 。如果一些插件拋出 RuntimeException 時某些響應服務線程會終止,這意味著 一個出錯的插件會使整個系統崩潰。遺憾的是,這種脆弱性很常見。
當線程拋出未捕獲的異常或錯誤時它們可能消失;而當線程等待的 I/O 操作 永遠不會完成,或沒人為它們等待的監視器調用 notify() 時,它們只是停止工 作。意外線程死亡的最常見根源是 RuntimeException 的結果很明顯,並且對發 生異常的位置有明確的堆棧跟蹤,這提供了問題通知以及解決問題的有用信息。 但是,在多線程應用程序中,由於未查出的異常,線程會無聲無息地死亡 — 使 得用戶和開發人員對於發生的問題和為什麼發生這些問題毫無頭緒。
處理任務的線程(類似於示例應用程序中的請求和響應處理程序),基本上 花費其整個生命周期穿過某個類似於 Runnable 的抽象障礙物來調用服務方法。 因為我們不知道在這個抽象障礙物的另一邊是什麼,所以,對於服務方法,我們 應該懷疑,它是不是真好到可以假設它從不拋出未查出異常的程度。如果服務例 程拋出 RuntimeException ,則調用線程應該捕獲這個異常,並將它記錄到日志 ,然後轉到隊列中的下一項或關閉線程然後再重新啟動它。(後一個選項源自這 樣的假定:任何拋出 RuntimeException 或 Error 的代碼也可能已經破壞了線 程的狀態。)
清單 1 中的代碼是典型的從工作隊列處理 Runnable 任務的線程,類似於我 們的示例中的入站響應線程。它並不防備拋出任何未查出異常的插件。
清單 1. 不防備 RuntimeException 的工作線程
private class TrustingPoolWorker extends Thread {
public void run() {
IncomingResponse ir;
while (true) {
ir = (IncomingResponse) queue.getNext();
PlugIn plugIn = findPlugIn(ir.getResponseId());
if (plugIn != null)
plugIn.handleMessage(ir.getResponse());
else
log("Unknown plug-in for response " + ir.getResponseId());
}
}
}
我們不必添加許多代碼來使這個工作線程能夠更健壯地處理插件代碼中的故 障。只要通過捕獲 RuntimeException ,然後進行糾正操作,就可以確保我們自 己有能力防止一個編寫得較差的插件破壞整個服務器。適當的糾正操作應該將錯 誤記錄到日志,然後,簡單地轉到下一條消息,終止當前線程並重新啟動它(這 是類似於 TimerTask 的類的做法),或者卸載引起問題的插件,如清單 2 中所 示:
清單 2. 防備 RuntimeException 的工作線程
private class SaferPoolWorker extends Thread {
public void run() {
IncomingResponse ir;
while (true) {
ir = (IncomingResponse) queue.getNext();
PlugIn plugIn = findPlugIn(ir.getResponseId());
if (plugIn != null) {
try {
plugIn.handleMessage(ir.getResponse());
}
catch (RuntimeException e) {
// Take some sort of action;
// - log the exception and move on
// - log the exception and restart the worker thread
// - log the exception and unload the offending plug-in
}
}
else
log("Unknown plug-in for response " + ir.getResponseId());
}
}
}
使用由 ThreadGroup 提供的未捕獲的異常處理程序
除了將外來代碼視作較可能拋出 RuntimeException 的方法之外,使用 ThreadGroup 類的 uncaughtException 函數也是明智的。 ThreadGroup 用處不 很大,但是目前(直到 JDK 1.5 中的 Thread 添加了未捕獲的異常處理為止) , uncaughtException 特性暫時使它不可或缺。清單 3 展示了一個示例,使用 ThreadGroup 來檢測由於未捕獲的異常引起的線程死亡。
清單 3. 使用 uncaughtException 來檢測線程死亡
public class ThreadGroupExample {
public static class MyThreadGroup extends ThreadGroup {
public MyThreadGroup(String s) {
super(s);
}
public void uncaughtException(Thread thread, Throwable throwable) {
System.out.println("Thread " + thread.getName()
+ " died, exception was: ");
throwable.printStackTrace();
}
}
public static ThreadGroup workerThreads =
new MyThreadGroup("Worker Threads");
public static class WorkerThread extends Thread {
public WorkerThread(String s) {
super(workerThreads, s);
}
public void run() {
throw new RuntimeException();
}
}
public static void main(String[] args) {
Thread t = new WorkerThread("Worker Thread");
t.start();
}
}
如果線程組中的一個線程因拋出一個未捕獲的異常而死亡,則調用該線程組 的 uncaughtException() 方法,該方法可以向日志寫入一條記錄、重新啟動線 程,然後重新啟動系統,或采取它認為必要的任何糾正或診斷操作。至少,如果 在線程死亡時所有線程都寫一條日志消息,您將有一個何時、何處出錯的記錄, 而不是只能奇怪您的請求處理線程到哪裡去了。
結束語
當線程從應用程序中消失時會引起混亂,並且在很多情況下,線程消失時沒 有(堆棧)跟蹤。象對付許多風險一樣,防止線程洩漏的最佳方法是預防和檢測 相結合;注意有可能拋出 RuntimeException 的地方(如調用外來代碼時),並 使用 ThreadGroup 提供的 uncaughtException 處理程序來在線程異常終止時進 行檢測。