某些类型的 bug 经常落到性能调优师手中来进行修复,虽然严格地讲,它们算不上是性能问题。通常由对象泄漏造成的内存不足就是这类 bug 中的一个。(在本专栏前面的一期中介绍过如何在“垃圾对话(Trash talk)”中处理这些问题,请参阅 参考资料。)另外一类经常落到性能调优师手上修补的 bug 就是线程死锁和其他线程方面的问题,例如竟态条件,因为这些问题一般只在对程序进行负载测试时才会表现出来。
将这些 bug 交到性能调优师手中通常有很好的理由:识别和清除性能及内存瓶颈所需要的工具,与识别对象泄漏和竟态条件的工具相同。死锁相对来说比较容易识别;只要注意到应用程序冻结,就会有堆栈跟踪显示是哪套线程锁定了其他线程的监视器。但是不幸的是,对于竟态条件,有可能更加无从下手。
有一类叫做 等待泄漏 的竟态条件最近受到我们的关注。基本的问题是,在使用 wait/notify 的概念时,通常会有一个或多个线程阻塞在 wait()
调用中,等待着另外一个线程通知它某个条件已经为真,这样它才能退出 wait()
调用,并继续进行处理。通知线程调用 notify()
或 notifyAll()
方法来通知等待线程现在就可以苏醒并继续进行处理。
这种方法很显然会形成竟态条件,但是一直到最近,我们在实践中都没有看到过这种情况。如果进入等待状态,等待特定资源变得可用,但是另外一个线程调用 notify()
正好是 在 您进入等待状态 之前 进行的,那么会发生什么呢?结果是:即使资源可用,线程也会陷入等待状态。
当然,有许多解决方案来避免这种场景 —— 毕竟,这是一个与其他 bug 类似的 bug。显然您应当更加仔细,在进入等待状态之前,判断资源是否可用。更具体来说,您应当检查资源在同步块内部是否可用,而不应当在资源可用的时候进入等待状态(这是推荐的方案,但这可能是一种可伸缩性较差的解决方案),或者也可以用 JDK 5.0 中可以使用的一些更复杂的同步类和相关的技术(参阅 参考资料)。
等待泄漏显然是个 bug,但是在这里要关心的不是对这个问题的解决方案,而是发现问题的方法。在拥有成百上千个线程的复杂应用程序中,除非事先发现故障现象,否则很难找出等待泄漏。与死锁不同,这里没有明显的能够说明问题的证据(例如两个线程相互等待对方锁定的监视器)。相反,会有大量线程滞留在 Object.wait()
调用中,对于许多应用程序来说,这是非常正常的情况。
学习如何发现等待泄漏的最好方法是实际查看一个泄漏,并理解导致它的原因。清单 1 演示了一个非常简单的等待泄漏。 WaitLeak
类实现了 Runnable
,每个线程要停下来等待,直到得到通知,然后终止。在这个模拟中,启动了 4 个 WaitLeak
线程,每秒钟启动一个。另一个类 WaitLeakNotifier
通知所有在 WaitLeak
wait()
调用中等待的线程,然后终止。主方法接受一个参数,该参数表示 WaitLeakNotifier
通知所有等待线程之前等待的毫秒数。
public class WaitLeak implements Runnable { private static Object LOCK = new Object(); public static void main(String[] args) throws Exception { int WAITTIME = Integer.parseInt(args[0]); int NUMTHREADS = 4; (new Thread(new WaitLeakNotifier(WAITTIME))).start(); for (int i = 0; i < NUMTHREADS; i++) { Thread.sleep(1000); (new Thread(new WaitLeak())).start(); } } public void run() { System.out.println("Starting thread " + Thread.currentThread()); synchronized(LOCK) { try{ LOCK.wait(); } catch(InterruptedException e) {} } System.out.println("Terminating thread " + Thread.currentThread()); } } class WaitLeakNotifier implements Runnable { long waittime; public WaitLeakNotifier(long time) { waittime = time; } public void run() { long now = System.currentTimeMillis(); long diff = 0; while( (diff = System.currentTimeMillis() - now) < waittime) { try { Thread.sleep(waittime - diff); } catch(InterruptedException e){} } synchronized(WaitLeak.LOCK) { WaitLeak.LOCK.notifyAll(); } } } |
图 1 显示了三种可能的场景,在通知发送之前,它们所用的延迟不同。
顶部的面板显示了延迟相对较大(例如 10 秒)的程序,如下所示:
java WaitLeak 10000 |
这种方式造成所有 4 个 WaitLeak
线程均启动、等待、在 10 秒钟后得到通知,然后终止。
图 1 的第 2 个面板显示的程序,它的延迟为 WaitLeak
启动一半的时候,如 2 或 3 秒:
java WaitLeak 2000 |
在这个场景中,比通知线程启动得早的 WaitLeak
线程得到通知并终止,但是在通知发送之后启动的 WaitLeak
线程会一直等下去。
第 3 个场景的延迟时间非常短(例如 1 毫秒),如下所示,效果如图 1 的第 3 个面板所示。
java WaitLeak 1 |
在这个例子中, WaitLeakNotifier
在其他线程启动之前发送通知。所以没有线程会从 WaitLeakNotifier
得到通知,从而造成所有线程都一直阻塞在等待状态。
清单 2 显示了启动几分钟后的堆栈跟踪,截取自第 2 个场景。(可以在 Windows 上按 Ctrl+Break 得到堆栈跟踪,然后在 Unix 上用 Ctrl+/,或者向进程发送 kill -3
。)
清单 2. java WaitLeak 2000 的线程堆栈转储
"Thread-4" prio=5 tid=0x00a0eee8 nid=0xf04 in Object.wait() [2d1f000..2d1fd8c] at java.lang.Object.wait(Native Method) - waiting on <0x1002c780> (a java.lang.Object) at java.lang.Object.wait(Unknown Source) at WaitLeak.run(WaitLeak.java:25) - locked <0x1002c780> (a java.lang.Object) at java.lang.Thread.run(Unknown Source) "Thread-3" prio=5 tid=0x00a0c418 nid=0xc5c in Object.wait() [2cdf000..2cdfd8c] at java.lang.Object.wait(Native Method) - waiting on <0x1002c780> (a java.lang.Object) at java.lang.Object.wait(Unknown Source) at WaitLeak.run(WaitLeak.java:25) - locked <0x1002c780> (a java.lang.Object) at java.lang.Thread.run(Unknown Source) "Thread-2" prio=5 tid=0x00a0d7a0 nid=0x118c in Object.wait() [2c9f000..2c9fd8c] at java.lang.Object.wait(Native Method) - waiting on <0x1002c780> (a java.lang.Object) at java.lang.Object.wait(Unknown Source) at WaitLeak.run(WaitLeak.java:25) - locked <0x1002c780> (a java.lang.Object) at java.lang.Thread.run(Unknown Source) |
线程转储显示了等待泄漏的故障现象,但是重要的东西却从线程转储中漏掉了 —— 就是 没有 通知等待线程的那个线程。所以需要添加一些额外的上下文信息,以便协助识别出等待泄漏。通常,可能报告出两种故障模型 —— 死锁,或应用程序响应程度逐渐下降。
首先来考虑标准的死锁类型的问题报告:应用程序什么也不做(虽然对用户引发的事件可能仍然有响应)—— 应用程序部分或者完全冻结。等待泄漏的故障现象与普通的死锁报告类似,不同之处在于在堆栈转储中没有死锁的迹象。如果看到这种情况,就应当考虑可能是遇到了等待泄漏。
第 2 个场景是一个逐渐过载、响应越来越差的应用程序。在这个例子中,随着时间的推移,越来越多的线程进入等待泄漏状态,这意味着越来越多的线程(本来应当做事的)只是闲在那里,什么都不做。结果就是,应用程序被傻等着永远不会到来的通知的那些线程所阻塞。结果有些资源被耗尽 —— 可能是线程池用尽、或者是多过的线程导致内存不足错误、或者由于应用程序最终出现了与第一类死锁类型相同的故障现象的情况。这可能是一个比较容易诊断的等待泄漏,因为可以比较某一段时间内的堆栈转储,并查看某些特定的 Object.wait()
堆栈(可能在使用同一个锁)的数量是否一直持续增长。刚才看到的生产示例中有一个响应越来越慢的服务器,到最后,仅仅在几分种之后,43 个等待泄漏堆栈就变成了 108 个等待堆栈,很快服务器就不再响应任何请求。
有趣的是,我们并不相信有什么可以自动发现等待泄漏的方法,除非只有等待泄漏线程被遗忘(就像本文中的模拟情况,但是在真正的应用程序中很少有同类情况)。实际上,多数情况下很难确定哪个应该调用 notify()
的代码绝对不会因为被锁定的监视器而再次执行。所以手工检查可能是我们能做的最好方式 —— 而这正是本文的目标,在您的性能调优武器库中加上另一个工具。如果您偶然碰到等待泄漏,我们敢保证您早晚会认出它来,我们希望本文能尽早给您带来帮助。
- “ 关注性能: 引用对象”(developerWorks,2003 年 8 月)提供了清除对象泄漏的详细技术。
- “ Question of the month: Handling OutOfMemoryError”(Java Performance Tuning,2003 年 11 月)提供了更多对象泄漏的细节。
- “ Java 理论与实践: JDK 5.0 中更灵活、更具可伸缩性的锁定机制”(developerWorks,2004 年 10 月)提供了一些可以替代 wait/notify 的方法。
- Sun 的 1.4 JVM 包含一个死锁探测器。
- Java 专家新闻邮件的 93 号问题上有这篇文章 Automatically Detecting Thread Deadlocks in 5.0。
- 请阅读 关注性能 系列中的全部技巧。
- 作者的 Web 站点 Java Performance Tuning 上提供了丰富的性能技巧和推荐读物。
- 请阅读 Jack Shirazi 撰写的 Java Performance Tuning, 2nd Edition(O'Reilly,2003 年)一书中有关性能调优的全部内容。
- 在 developerWorks Java 技术专区 可以找到数百篇有关 Java 各个方面的技术文章。
- 请访问 Developer Bookstore,获得技术书籍的完整清单,其中包括数百本 Java 相关主题 的书籍。
Jack Shirazi 是 JavaPerformanceTuning.com 的总监、 Java Performance Tuning, 2nd Edition (O'Reilly)的作者。
Kirk Pepperdine 是 Java Performance Tuning.com 的 CTO,已经在对象技术和性能调优上钻研了 15 年。Kirk 是 Ant Developer's Handbook (MacMillan)一书的合著者。