事实证明,要发挥多核硬件所带来的收益是很困难和有风险的。当使用并发正确和安全地编写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 {
protected final Object lock = new Object();
private int balance;
protected int readBalance() {
return balance;
}
protected void adjustBalance(int adjustment) {
balance = balance + adjustment;
}
// ... methods that synchronize on "lock" while calling
// readBalance() or adjustBalance(..)
}
|
这个类的开发人员决定通过两个内部的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;
}
public void applyBonus() {
adjustBalance(bonus);
}
}
|
在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<RespTimeGraphDataBean>域中的集合因为不一致同步而产生的警告截屏
像前面一样,我们可以要求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能够发现的更多缺陷样例,这些缺陷难以被发现,但很可能是灾难性的。 |