12.3 测试并发应用程序
测试并发应用程序是一项艰巨的任务。应用程序的线程在计算机上运行时无法保证任何执行顺序(除非引入了同步机制),因此很难(大部分情况下是不可能)对所有可能出现的情况都进行测试。还有些错误不可能进行重现,因为它们仅发生在偶然或者独特的场合中。或者由于CPU核数的原因,错误会在一台机器上发生但是不会在另一台上发生。为探查和重现这些场景,就要使用不同的工具。
- Debug:可以使用调试器调试应用程序。如果应用程序中仅有少量线程,这个过程将会非常枯燥,而且需要在每个线程中都一步一步地进行调试。可以对Eclipse或NetBeans进行配置以测试并发应用程序。
- MultithreadedTC:这是Google Code的一个备案项目,可用于在并发应用程序中强制规定执行顺序。
- Java PathFinder:这是NASA用于验证Java程序的一种执行环境。它还支持对并发应用程序的有效性验证。
- Unit testing:可以创建一组单元测试(使用JUnit或者TestNG),并且多次进行每个测试(例如1000次)。如果每个测试都成功了,那么即使应用程序出现竞争,其可能性也并不高,也是可被生成环境所接受的。你可以在自己的代码中加入一些断言,以此验证是否存在竞争条件。
在下面的各节中,你将看到使用MultithreadedTC和Java PathFinder工具测试并发应用程序的一些基本例子。
12.3.1 使用MultithreadedTC测试并发应用程序
MultithreadedTC是一个备案项目,可以通过网址http://code.google.com/p/multithreadedtc/下载。它的最新版本是2007年发布的,不过仍然可以使用它测试小型并发应用程序或者单独测试大型应用程序的部件。尽管不能用它测试实际任务或者线程,但是可以使用它测试不同的执行顺序,从而检验是否会导致竞争条件或者死锁。
它基于一个内部时钟进行计时,该时钟可以控制不同线程的执行顺序,以测试该执行顺序是否会导致什么并发问题。
首先,需要将两个库关联到项目中。
- MultithreadedTC库:最新版本是1.01版。
- JUnit库:我们使用4.12版测试了这个例子。
要使用MultithreadedTC库实施测试,要扩展MultithreadedTestCase类,该类扩展了JUnit库的Assert类。可以实现如下方法。
initialize():该方法将在测试执行开始时执行。如果需要执行初始化代码以创建数据对象、数据库连接等,可以重载该方法。finish():该方法将在测试执行结束后执行。可以对其重载以实现对测试的验证。threadXXX():可以为测试中的每个线程实现一个名称以thread关键字开头的方法。例如,如果想要测试三个线程,就要在自己的类中实现三个方法。
MultithreadedTestCase类提供了waitForTick()方法。该方法接收你要等待的时数作为参数。该方法使调用线程休眠,直到内部时钟达到该时刻为止。
第一个时刻是时数为0的时刻。MultithreadedTC框架以特定时间间隔检查测试线程的状态。如果所有运行的线程都在waitForTick()方法中等待,那么它将增加时数,并且唤醒所有等待该时刻的线程。
下面看一个使用它的例子。假设要测试一个Data对象内部的int属性,需要一个线程来增加该属性的值和一个线程来减小该属性的值。可以创建一个名为TestClassOk的类扩展MultithreadedTestCase类。我们用到了数据对象的三个属性:将要增加的数据量、将要减少的数据量和数据的初始值,代码如下:
public class TestClassOk extends MultithreadedTestCase {private Data data;private int amount;private int initialData;public TestClassOk (Data data, int amount) {this.amount=amount;this.data=data;this.initialData=data.getData();}
我们实现两个方法来模拟两个线程的执行。第一个线程在threadAdd()方法中实现。
public void threadAdd() {System.out.println("Add: Getting the data");int value=data.getData();System.out.println("Add: Increment the data");value+=amount;System.out.println("Add: Set the data");data.setData(value);}
该方法读取数据的值,增加其值,并且再次输出数据的值。第二个方法在threadSub()方法中实现。
public void threadSub() {waitForTick(1);System.out.println("Sub: Getting the data");int value=data.getData();System.out.println("Sub: Decrement the data");value-=amount;System.out.println("Sub: Set the data");data.setData(value);}}
首先,等待时刻1。然后,获取该数据的值,减少其值,并且重新输出该数据的值。
为了执行该测试,可以使用TestFramework类的runOnce()方法。
public class MainOk {public static void main(String[] args) {Data data=new Data();data.setData(10);TestClassOk ok=new TestClassOk(data,10);try {TestFramework.runOnce(ok);} catch (Throwable e) {e.printStackTrace();}}}
当测试开始执行时,两个线程(threadAdd()和threadSub())以并发方式启动。threadAdd()线程开始执行其代码,而threadSub()线程则在waitForTick()方法中等待。当threadAdd()线程完成执行后,MultithreadedTC的内部时钟探测到在waitForTick()方法中只有一个线程正在等待,因此它将时数增加到1,并且唤醒执行其代码的线程。
在下面的屏幕截图中,将看到执行本例后的输出结果。在这种情况下,一切都运行正常。

不过,你可以改变线程的执行顺序以产生一个错误。例如,可以按下面的顺序实现,它将导致一个竞争条件。
public void threadAdd() {System.out.println("Add: Getting the data");int value=data.getData();waitForTick(2);System.out.println("Add: Increment the data");value+=amount;System.out.println("Add: Set the data");data.setData(value);}public void threadSub() {waitForTick(1);System.out.println("Sub: Getting the data");int value=data.getData();waitForTick(3);System.out.println("Sub: Decrement the data");value-=amount;System.out.println("Sub: Set the data");data.setData(value);}
在这种情况下,执行顺序要保证两个线程都首先读取数据的值,然后进行操作,因此最后的结果就不会正确。
在下面的屏幕截图中,可以看到该例的执行结果。

在这种情况下,assertEquals()方法会抛出一个异常,因为预期的值和实际值不一样。
该库的主要缺陷在于,它仅对测试基本的并发代码有用,因此当你实施测试时,不能用它来测试真实的线程代码。
12.3.2 使用Java Pathfinder测试并发应用程序
Java Pathfinder(或者说JPF)是NASA的一个开源执行环境,可以用于验证Java应用程序。它含有自己的虚拟机,用于执行Java字节码。从内部来看,它探测代码中那些可以有多条执行路径的节点,并且执行所有可能的路径。在并发应用程序中,这意味着它将执行应用程序中线程之间所有可能的执行顺序。它还含有一些工具,可以帮助检测竞争条件和死锁。
该工具的主要优点在于,它允许你完整地测试并发应用程序,保证应用程序不会出现竞争条件和死锁。该工具还有一些不太方便的地方。
- 需要从其源代码安装它。
- 如果应用程序很复杂,将有成千上万种可能的执行路径,这样测试过程就会耗时很长(如果应用程序很复杂,很可能会花费许多时间)。
下面的各节将展示如何使用Java Pathfinder测试并发应用程序。
- 安装Java Pathfinder
如前所述,需要从源码安装JPF。该源码位于Mercurial资源库,因此第一步是安装Mercurial,而且因为我们将用到Eclipse IDE,所以还需要安装Mercurial plugin for Eclipse。
接着,请下载Mercurial。你下载的安装程序应该提供了安装助手,可将Mercurial安装到计算机。在Mercurial安装完毕之后,可能需要重启计算机。
可以使用Eclipse菜单中的Help | Install new software选项下载Mercurial plugin for Eclipse。这和安装其他插件的步骤相同。
还可以安装一个JPF plugin for Eclipse。
现在可以访问Mercurial资源库的浏览器视图,并且添加Java Pathfinder资源库。我们将仅使用在http://babelfish.arc.nasa.gov/hg/jpf/jpf-core中存放的核心模块。访问该资源库并不需要用户名或密码。当你创建了该资源库后,可以右键点击该资源库,在菜单中选择Clone repository选项,将其源码下载到计算机。该选项将打开一个窗口,其中有一些选项可供选择,不过可以保留默认值并且点击Next按钮。然后,选择要下载的版本。保留默认值,并且点击Next按钮。最后,点击Finish按钮完成下载过程。Eclipse将自动运行ant以编译该项目。如果出现了编译问题,就必须先解决这些问题并且重新启动ant。
如果一切正常,工作空间中将出现一个名为jpf-core的项目,如下面的屏幕截图所示。

最后一个配置步骤是通过对JPF的配置创建一个名为site.properties的文件。如果你点击Window | Preferences菜单访问配置窗口,并且选择JPF Preferences选项,将看到JPF用于查找该文件的路径。如果需要,可以更改该路径。

因为我们将仅使用核心模块,所以该文件将仅记录jpf-core项目的路径。
jpf-core = D:/dev/book/projectos/jpf-core
- 运行Java Pathfinder
安装了JPF之后,看看如何使用它测试并发应用程序。首先要实现一个并发应用程序。在我们的例子中,将使用一个Data类,它带有一个内部的int值。该值的初始状态为0。Data类中有一个increment()方法用于增加其值。
然后,还有一个名为NumberTask的任务,它实现了Runnable接口,将对Data对象的值做10次递增操作。
public class NumberTask implements Runnable {private Data data;public NumberTask (Data data) {this.data=data;}@Overridepublic void run() {for (int i=0; i<10; i++) {data.increment(10);}}}
最后,还有一个实现main()方法的MainNumber类。我们将启动两个NumberTasks对象来修改同一Data对象。最后会得到Data对象的最终值。
public class MainNumber {public static void main(String[] args) {int numTasks=2;Data data=new Data();Thread threads[]=new Thread[numTasks];for (int i=0; i<numTasks; i++) {threads[i]=new Thread(new NumberTask(data));threads[i].start();}for (int i=0; i<numTasks; i++) {try {threads[i].join();} catch (InterruptedException e) {e.printStackTrace();}}System.out.println(data.getValue());}}
如果一切正常并且没有竞争条件出现,最终的结果将是200,但是代码并未采用任何同步机制,因此很可能出现竞争条件。
如果使用JPF执行该应用程序,需要在项目中创建一个扩展名为.jpf的配置文件。例如,我们创建了NumberJPF.jpf文件,其中含有要用到的最基本的配置。
+classpath=${config_path}/bintarget=com.javferna.packtpub.mastering.testing.main.MainNumber
修改JPF的类路径,添加项目的bin目录,并且指明应用程序的主类。现在,准备通过JPF执行应用程序。为此,右键点击.jpf文件并且从弹出菜单中选择Verify选项。我们将在控制台中看到大量输出消息。每条输出消息都来自于应用程序的一条不同的执行路径。

当JPF结束所有可能执行路径的执行后,它会给出有关执行过程的统计信息。

JPF的执行结果显示并未检测到错误,但是可以看到,大多数结果都不是200,因此应用程序存在预期的竞争条件。
12.3.2节开头曾提到,JPF提供了探测竞争条件和死锁的工具。JPF通过一种Listener机制实现这些功能,它实现了观察者(Observer)模式,对代码执行过程中发生的特定事件做出响应。例如,可以使用下面的监听器。
PreciseRaceDetector:使用该监听器探测竞争条件。DeadlockAnalyzer:使用该监听器探测死锁情况。CoverageAnalyzer:使用该监听器在JPF执行结束后输出覆盖率信息。
可以在.jpf文件中配置要使用的监听器,该文件中含有执行过程的配置情况。例如,我们对此前NumberListenerJPF.jpf文件中的测试进行了扩展,加入了PreciseRaceDetector和CoverageAnalyzer监听器。
+classpath=${config_path}/bintarget=com.javferna.packtpub.mastering.testing.main.MainNumberlistener=gov.nasa.jpf.listener.PreciseRaceDetector,gov.nasa.jpf.listener.CoverageAnalyzer
如果通过JPF的Verify选项执行该配置文件,在该应用程序结束时会看到,当它探测到第一个竞争条件时,会在控制台中给出有关这一情况的信息。

还会看到CoverageAnalyzer监听器输出这样的信息。

JPF是非常强大的应用程序,其中还含有更多监听器和扩展机制。可以通过网址http://babelfish.arc.nasa.gov/trac/jpf/wiki查看其完整文档。
