事实证明,要发挥多核硬件所带来的收益是很困难和有风险的。当使用并发 _ 正确 _ 和 _ 安全 _ 地编写 Java 软件时,我们需要很仔细地进行思考。因为错误使用并发会导致偶尔才出现的缺陷,这些缺陷甚至能够躲过最严格的测试环境。
静态分析工具提供了一种方式,可以在代码执行之前探查并修正并发错误。它能够在代码执行之前分析程序的源码或编译形成的字节码,进而发现隐藏在代码之中的缺陷。
Contemplate 的 ThreadSafe Solo 是一个商用的 Eclipse 静态分析插件,其目的就是专门用来发现并诊断隐藏在 Java 程序之中的缺陷。因为专注于并发方面的缺陷,所以 ThreadSafe 能够发现其他商用或免费静态分析工具无法发现的缺陷,这些工具通常会忽视这种缺陷或者根本就不是为了查找这种缺陷而设计的。就目前我们所能确定的,其他的 Java 静态分析工具都不能捕获以下样例中的任何缺陷。
在本文中,我会通过一系列并发缺陷来介绍 ThreadSafe,这些都是具体的样例和实际的 OSS 代码,这里展现了 ThreadSafe 的高级静态分析以及与 Eclipse 的紧密集成,这样我们就能在代码产品化之前,及早发现并诊断这些缺陷。如果想在你的代码上体验 ThreadSafe 的话,可以在 Contemplate 站点上下载免费试用版本。
在本文中作为样例所使用的并发缺陷都是由于开发人员没有正确地同步对共享数据的访问所引起的。这类缺陷同时是 Java 代码中最常见的并发缺陷形式,也是在代码检查和测试中最难探查的缺陷之一。ThreadSafe 能够探测出众多没有正确使用同步的场景,如下文所述,它同时还能为开发人员提供至关重要的上下文信息,从而有助于对问题做出诊断。
原本正确的同步随着时间的推移变得不正确了
如果一个类的实例会被多个线程并发调用,那么在设计的时候,开发人员必须要仔细考虑如何对同一个实例进行并发访问,以保证能够正确地进行处理。即便找到了好的设计方案,也很难保证这个经过仔细设计的同步协议在将来添加代码时能够得到充分的尊重。当新编写的代码违反已有的并发设计时,ThreadSafe 能够帮助指出这些场景。
对于简单的同步任务,Java 提供了多种不同的基础设施,包括 synchronized 关键字以及更为灵活的 java.util.concurrent.locks 包。
作为一个简单示例,我们使用 Java 内置的同步设施来安全并发地访问共享资源,考虑如下的代码片段,实现了模拟的“银行账户”类。
public class BankAccount {<br></br> protected final Object lock = new Object();<br></br> private int balance;<br></br> protected int readBalance() {<br></br> return balance;<br></br> }<br></br> protected void adjustBalance(int adjustment) {<br></br> balance = balance + adjustment;<br></br> }<br></br> // ... methods that synchronize on "lock" while calling<br></br> // readBalance() or adjustBalance(..)<br></br> }
这个类的开发人员决定通过两个内部的 API 方法,即 readBalance() 和 adjustBalance(),来对 balance 域提供访问功能。这些方法给定了 protected 级别的可见性,所以它们可能会被 BankAccount 的子类访问。鉴于在 BankAccount 实例上任何对外暴露的特定操作都会涉及到对这些方法进行一系列复杂的调用,这些方法应该作为一个原子的步骤来执行,而内部的 API 方法本身并不进行任何的同步。相反,这些方法的调用者要同步 lock 域中所存储的对象,以保证互斥性以及对 balance 域更新的原子性。
在程序规模很小的时候,程序的设计可以装在某个开发人员的脑子中,出现并发相关问题的风险相对来讲会比较小。但是,在实际的项目中,最初精心设计的程序需要进行扩展以适应新的功能,而这通常是由项目的新工程师来完成的。
现在,假设在最初的代码编写一段时间之后,另外一个开发人员编写了 BankAccount 的子类来添加一些新的可选功能。令人遗憾的是,这个新的开发人员并不一定了解之前的开发人员所设计好的同步机制,他并没有意识到如果没有预先同步保存在 lock 域中的对象,是不能调用 readBalance() 和 adjustBalance(…) 的。
新工程师所编写的 BankAccount 子类代码可能会如下所示:
public class BonusBankAccount extends BankAccount { private final int bonus; public BonusBankAccount(int initialBalance, int bonus) { super(initialBalance); if (bonus < 0) throw new IllegalArgumentException("bonus must be >= 0"); this.bonus = bonus;<br></br> }<br></br> public void applyBonus() {<br></br> adjustBalance(bonus);<br></br> }<br></br>}
在 applyBonus() 的实现中存在着问题。为了正确地遵循 BankAccount 类的同步策略,applyBonus() 在调用 adjustBalance() 时应该同步 lock。不过,这里没有执行同步,所以 BonusBankAccount 的作者在这里引入了一个严重的并发缺陷。
尽管这个缺陷很严重,但是在测试甚至生产阶段要探测到它却是很困难的。这个缺陷的表现形式为不一致的账户余额,这是由于缺少同步会导致某个线程对 balance 域的更新对其他线程是不可见的。这个缺陷不会导致程序崩溃,但是会以难以跟踪的方式,默默地产生不一致的结果。在四核的硬件上,尝试以四个线程并发地对同一个账户进行返现和贷出操作,在 40,000 个事务中会有 11 个是失效的。
ThreadSafe 可以用来识别类似于 BonusBankAccount 类所引入的并发缺陷。在上面提到的两个类上运行 ThreadSafe 的 Eclipse 插件,会产生如下的输出:
(点击图像放大)
在Eclipse 中,ThreadSafe 视图的截屏
这个截屏显示ThreadSafe 已经发现balance 域没有进行一致的同步。
要获取更多的上下文信息,可以让ThreadSafe 显示对balance 域的访问,它还会为我们展现每次访问所持有的锁:
(点击图像放大)
ThreadSafe Accesses 视图的截屏
通过这个视图,我们可以清楚地看到在 adjustBalance() 方法中对 balance 域没有进行一致性的同步。使用 Eclipse 的调用层级(call hierarchy)视图(在这里可以通过右键点击视图中 adjustBalance() 这一行快速访问),我们可以看到这个讨厌的代码路径是怎样产生的。
Eclipse 调用层级的截屏,展现了 BonusBankAccount 对 adjustBalance 方法的调用
访问集合时,不正确的同步
上面提到的 BankAccount 类是一个很简单的例子,展现了访问域时没有进行正确的同步。当然,大多数 Java 对象都是由其他对象组成的,常见的表现形式就是对象集合。Java 提供了种类繁多的集合类,当对集合进行并发访问时,每一个集合类都有其是否需要进行同步的需求。
对集合的不一致同步可能会对程序的行为带来特别严重的影响。当对一个域的访问没有正确的同步时,可能“只是”丢失更新或使用过期数据,而有些集合原本并没有设计成支持并发使用,对这些集合的不一致同步则可能会违反集合内部的不变形(invariants)。如果违反了集合的内部不变形可能并不会马上出现可见性的问题,但是可能会导致很诡异的行为,比如在程序的后续执行中会出现无限循环或数据损坏。
当访问共享的集合时,不一致地使用同步的样例出现在 Apache JMeter 之中,这是一个很流行的测试应用在负载下性能的开源工具。在 2.1.0 版本的 Apache JMeter 上运行 ThreadSafe 会产生如下的警告:
存储在 RespTimeGraphVisualizer.internalList : List
像前面一样,我们可以要求 ThreadSafe 展现这个报告的更多信息,包括对这个域的访问以及它所持有的锁:
(点击图像放大)
探查internalList 的ThreadSafe Accesses 视图的截屏
现在我们可以看到有三个方法访问存储在internalList 域中的集合。其中有一个方法是actionPerformed,它将会由Swing Gui 框架在UI 线程上调用。
另外一个访问internalList 所存储集合的方法是add()。同样的,探查这个方法可能的调用者,我们会发现它确实会由一个线程的run() 来调用,而这个线程并不是应用的UI 线程,这表明应该要使用同步。
Eclipse 的调用层级结构截屏,展现了 run() 方法
当使用 Android 框架时,缺少同步
应用程序运行时所在的并发环境通常并不在应用开发人员的控制之下。框架会调用各个部分来响应用户、网络或其他的外部事件,通常来讲某个方法能被哪条线程来调用都有内在的需求。
未正确使用框架的一个样例可以在 Git 版本的 Android email 客户端 K9Mail 上找到(在本文的结尾处,我们提供了所测试版本的链接)。在 K9Mail 上运行 ThreadSafe 会得到如下的警告,表明 mDraftId 域会被 Android 的后台进程以及另外一个进程所访问,但是 _ 没有进行 _ 同步。
针对异步回调方法的未同步访问,ThreadSafe 所产生的报告
使用 ThreadSafe 的 Accesses 视图,我们可以看到 mDraftId 域会被名为 doInBackground 的方法所访问。
(点击图像放大)
ThreadSafe 的 Accesses 视图展现了对 mDraftId 的每个访问
doInBackground 方法是 Android 框架 AsyncTask 基础设施的一部分,它用来在后台执行耗时的任务,这是与主 UI 线程相分离的。正确使用 AsyncTask.doInBackground(…) 能够保证对用户的输入保持响应,但是必须要注意的是后台线程与主 UI 线程之间的交互必须要正确地同步。
进一步进行探查,使用 Eclipse 的调用层级结构视图,我们会发现 onDiscard() 方法,这个方法也访问了 mDraftId 域,这个方法是被 onBackPressed() 所调用的。而这个方法通常是由 Android 框架在主线程中调用的,并 _ 不是 _ 运行 AsyncTasks 的后台线程,这就表明这里会有一个潜在的并发缺陷。
不正确地使用同步的数据结构
对于相对简单场景,Java 内置的同步集合就提供了合适的线程安全性功能,无需我们费太多功夫。
同步集合对原有的集合类进行了包装,提供了与底层集合相同的接口,但是对同步集合实例的所有访问都进行了同步。同步集合要通过调用特定的静态方法来获得,类似的调用方式如下所示:
private List<X> threadSafeList = Collections.synchronizedList(new LinkedList<X>());
相对于其他线程安全的数据结构,同步集合使用起来很容易,但是在它们的使用中也有很微妙的陷阱。在使用同步集合时,一个常见的错误就是在没有同步集合本身的情况下,对它们进行遍历。鉴于没有强制要求对集合进行排他性的访问,所以在迭代其元素的时候,集合可能会被其他的线程修改。这可能会导致间歇性地抛出 ConcurrentModificationException,或者出现无法预知的行为,这取决于线程的具体调度。同步的需求明确记录在 JDK API 文档之中:
当对返回的 list 进行遍历的时候,用户必须手动地对其进行同步: ```
List list = Collections.synchronizedList(new ArrayList());
…
synchronized (list) {
Iterator i = list.iterator(); // Must be in synchronized block
while (i.hasNext())
foo(i.next());
}复制代码
不遵循该建议的话可能会导致无法预知的行为
尽管如此,当迭代一个同步集合时,还是很容易忘记进行同步的,尤其是它们与常规的非同步集合有着相同的接口。
在 2.10 版本的 Apache JMeter 之中,可以看到这种错误的样例。ThreadSafe 报告了如下“对同步集合的不安全遍历”场景:
ThreadSafe 所产生的报告不安全遍历的截屏
ThreadSafe 报告的那一行中包含了如下的代码:
Iterator<Map.Entry<String, JMeterProperty>> iter = propMap.entrySet().iterator();
在这里,迭代是基于一个同步集合的 _ 视图(view)_ 进行的,它是通过调用 entrySet() 得到的。因为集合的视图是“活跃的(live)”,因此这段代码同样可能产生上文所述的无法预知行为或 ConcurrentModificationException。
结论
我展现了一小部分并发相关的缺陷,这些都是在实际的 Java 程序中很常见的,并且演示了 Contemplate ThreadSafe 能够如何帮助我们发现并诊断它们。
总体而言,不管是已有的还是新编写的 Java 代码,静态分析工具都能有助于发现隐藏在代码之中的缺陷。静态分析能够对传统的软件质量技术形成补充,这些传统的技术包括测试和代码审查,静态分析提供了一种快捷且可重复的方式来扫描代码,目的在于发现一些为大家所熟知但是比较难以发现且严重的缺陷。并发的缺陷尤其难以在测试中很可靠的发现,因为它们依赖于不确定的并发线程调度。
ThreadSafe 还能发现其他一系列的并发缺陷,包括因为不正确地使用并发集合框架所引起的原子性错误以及错误使用阻塞方法可能引起的死锁。 ThreadSafe 的技术资料以及样例视频中展示了 ThreadSafe 能够发现的更多缺陷样例,这些缺陷难以被发现,但很可能是灾难性的。
参考资料
- Contemplate Website ——包括 ThreadSafe 的更多信息、如何获得试用版以及如何购买。
- Apache JMeter 2.10 的下载地址——上文中样例的源码下载地址。
- k9mail Git repository ——K9Mail 源码的下载地址。本文所使用的版本具有的 SHA1 id 为: b500047e426baa0807570c2f2836d0cf9ba6cc19
关于作者
Robert Atkey是 Contemplate 公司的高级软件工程师,开发和维护核心的分析框架。他 2006 年在爱丁堡大学毕业,获得计算机科学的博士学位,在此以后的工作领域是软件的实战和理论,既包括学术方面也涵盖实际的行业发展。
原文英文链接: Discover and Diagnose Java Concurrency Problems Using Contemplate’s ThreadSafe
评论