Java 多线程程序的测试

2022-07-18 13:52:13 浏览数 (1)

这个问题最初来自于一封公司内部的话题探讨邮件,再加上了一些我的理解。

首先,需要明确的是,用 Java 通常构建多线程安全的程序 “非常” 困难,如果还没有体会到 “非常” 的话,阅读《Java Concurrency in Practice》(中文名叫做《Java 并发编程实战》,在我的书单里面,我认为它基本是最好的系统介绍 Java 并发的书了)可能可以改变你的看法。

多线程的基础

基础是王道。对于任何一门语言都是如此,有的基础部分是和语言无关的,也有一部分是和 Java 语言相关的。这里我不过多展开,但是我想提一提对于 JSR 规范的理解。通常我们认为 Java 是一门啰嗦、冗长,容易使用,而且不容易造成破坏的语言,但是,要写完全正确的 Java 多线程程序,却根本不是这样,需要知道的东西非常多,譬如 JSR-133 和 JSR-166 这两个最重要的规范。

良好的设计

这一定是放在第一条。软件质量不是靠测出来的,而是靠设计出来的。尽可能简化问题,主动去避免一些潜在的问题,例如,我在这篇文章里面谈到了为什么多线程的 GUI 框架很难被设计出来;再比如 OSCache 中受到批评的 NeedsRefreshException,设计机制决定了它 bug 丛生,容易发生死锁。一些被广泛认可的经验总结都是指导软件设计的参考依据。天然地,从设计阶段就让程序对逻辑多线程执行的解读保持清晰和简单,这样的代码才谈得上可靠性。

代码评审

对于一些很难构造测试用例来覆盖的潜在问题,代码评审几乎成为了最后一道可能系统地发现问题的堤坝。当然,代码评审的局限性很明显,评审的人也很容易陷入细枝末节当中,通常意义上编码习惯和规范性问题,或者是单个类的使用造成的多线程潜在问题容易被发现,但是类和组件之间的并发情况下资源使用和条件关联的问题却很难被发现出来。

辅助工具检查

比如 FindBugs 这样的工具,可以看一看 bug description 的列表中 “ Multithreaded correctness” 的部分。当然,类似地,还有 PMD 这样的工具。这两个工具都可以针对编译后的 class 来查错,而不是只是源代码文件。还有一个名为 Coverity Code Advisor 的工具,我也是第一次听到,不过所属公司 Coverity,王垠曾经写文章评论过,感兴趣的可以去他的博客找。

说了那么多,现在才轮到真正说测试的部分。但是,即便如此,我依然要说,多线程程序的很多问题很难通过测试发现。倚仗测试去保证质量在这里只能说是下下策。

压力测试

首先,需要明确的是,和所有的情况的测试一样,测试出问题只能作为充分条件,不能作为必要条件,即多线程程序测试发现问题只能说明这段多线程程序是有问题的,但是程序有问题却不一定能够通过测试发现。最容易想到的方法大概是通过多线程场景下对多线程的代码逻辑反复执行,特别是做到可控制的压力测试,以期望其中的若干次运行得到非预期的结果。这是最粗暴也是最简单的办法。这样的方法是不可替代的。但是,很多多线程问题的出现情况复杂而且这种方式明显缺乏目的性,有乱枪打鸟的感觉。

Debug 大法

很好笑对不对?不要小看了单步执行,尤其对于不同线程中,执行分支路径之间的组合,用单步调试的方法可以模拟出很多种情况,当然,这样的测试必然是白盒的。对于多线程程序的问题,我不相信一个不看被测试代码的黑盒测试人员可以做好这件事情。

多线程程序测试的框架

这个要看具体情况了。在 Amazon 就有这样的一套内部使用的并发测试框架,在我以前的公司,也见过别的部门有人写过,基本上和传统测试代码编写无异,通过注解或者几个简单的工具类控制,就可以灵活地指定并行测试的参数了,比如线程数量等等(有代码指定,也有命令行参数指定)。

一些有趣的开源库

比如 Thread Weaver,这个库是专门用来写多线程单元测试的,它的原理是在代码中创建一些断点,接着代码执行的时候就可以挂在断点上面,这样就可以测试各种资源争用和条件组合了。给一个简单的例子,首先定义 MyList 这样的对象:

代码语言:javascript复制
public class MyWriter {
	private StringBuilder sb = new StringBuilder();
	private int counter = 0;

	public synchronized void write(String s) {
		sb.append(  counter   "-"   s   " ");
	}
	
	public synchronized String toString(){
		return sb.toString();
	}
}

现在测试它:

代码语言:javascript复制
public class MyWriterTest {
	private MyWriter writer;

	@Test
	public void testThreading() {
		AnnotatedTestRunner runner = new AnnotatedTestRunner();
		// Run all Weaver tests in this class, using MyList as the Class Under Test.
		runner.runTests(this.getClass(), MyWriter.class);
	}

	@ThreadedBefore
	public void before() {
		writer = new MyWriter();
	}

	@ThreadedMain
	public void mainThread() {
		writer.write("MainA");
		writer.write("MainB");
	}

	@ThreadedSecondary
	public void secondThread() {
		writer.write("SecondA");
		writer.write("SecondB");
	}

	@ThreadedAfter
	public void after() {
		System.out.print(writer.toString());
	}
}

其中 @ThreadedBefore 和 @ThreadedAfter 是一对,以前者为例,对于 API 文档里面的解释:“An annotation that designates part of a test suite that uses the AnnotatedTestRunner framework to perform multithreaded tests. A method tagged with the ThreadedBefore attribute will be run before every test case.”,我最初认为以 Threaded 开头的注解都会默认启动两个线程并行执行,后来发现确实是执行了两遍,但是通过在 @ThreadedBefore/After 的代码里面打印线程号发现,这两遍的线程号都是同样的,这里的机制我还不是很清楚;

而对于上述每执行一遍的过程中,又因为有了 @ThreadedMain 和 @ThreadedSecondary 这一对,这两个注解修饰的方法会分别放到单独的线程里面去并行执行。

以下是其中一种可能的输出结果:

代码语言:javascript复制
1-MainA 2-SecondA 3-MainB 4-SecondB 1-MainA 2-SecondA 3-MainB 4-SecondB 

当然,也可能是:

代码语言:javascript复制
1-MainA 2-SecondA 3-SecondB 4-MainB 1-MainA 2-SecondA 3-MainB 4-SecondB 

等等多种。

Thread Weaver 还允许使用 CodePosition 做精细控制,有一点复杂,文档里面有例子说明,就像是把 debug 过程给代码化了。

再比如 JPF,JPF 的全称叫做 Java Pathfinder,是可以自定义的 Java 字节码执行环境,经常被用来 Java 程序调试和校验。有了它,可以发现 Java 程序员的一些错误,收集运行时的信息,推断测试向量和创建相应的测试驱动器等等。它从系统上探测程序所有可能的执行路径,以发现死锁或未处理异常之类情形。

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接 《四火的唠叨》

0 人点赞