假如您不小心,线程可能会在没有(堆栈)跟踪的情况下从服务器应用程序中消失。在本文中,线程问题专家 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 的结果很明显,并且对发生异常的位置有明确的堆栈跟踪,这提供了问题通知以及解决问题的有用信息。但是,在多线程应用程序中,由于未查出的异常,线程会无声无息地死亡 — 使得用户和开发人员对于发生的问题和为什么发生这些问题毫无头绪。