如果您不小心,线程可能会在没有(堆栈)跟踪的情况下从服务器应用程序中消失。在本文中,线程问题专家 Brian Goetz 提供了用于预防和检测线程“擅离职守”的技术。
当单线程应用程序中的主线程抛出一个未捕获的异常时,因为控制台中会打印堆栈跟踪(也因为程序停止),所以您很可能注意到。但在多线程应用程序中,尤其是在作为服务器运行并且不与控制台相连的应用程序中,线程死亡可能成为不太引人注目的事件,这会导致局部系统失败,从而产生混乱的应用程序行为。
在 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 时某些响应服务线程会终止,这意味着一个出错的插件会使整个系统崩溃。遗憾的是,这种脆弱性很常见。
尽管编译器“希望”我们主动地针对已查出的异常编制代码(编译器会强制我们这样做),但大多数 Java 开发人员在很大程度上忽略了未查出的异常。在单线程应用程序中,未经处理的 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 处理程序来在线程异常终止时进行检测。