并发的测试并不容易,原因在于并发的不确定性——并发场景下,很多问题无法重现,甚至它还有一个专有的名词,“海森堡错误”。此外,对于Java来说,性能测试经常是比较困难的,这是由于JVM做了太多事,犹如一个黑箱,像垃圾回收,实时编译,提前编译等,对于性能的影响其实很大,要命的事,它们通常不是程序可控的范围,所以需要更加小心。
正确性测试
正确性测试有一部分是串行正确性——即,在串行环境下,我们通常考虑的该程序的种种不变性条件。例如初始容量,各种操作等等。这部分就不展开了,也没有什么特殊的。
阻塞测试
阻塞是并行程序常见的情况,我们通常利用阻塞来减少空循环造成的开销。然而,阻塞并不好测试——如果程序阻塞来,我就收不到任何反馈;如果程序反馈,说明阻塞失败了;假如程序长时间阻塞,那么我又怎么知道下一刻它不会恢复正常?
我们会联想到中断机制,利用中断机制,来确定程序是处于阻塞状态。
void testTakeBlocksWhenEmpty() {
final BoundedBuffer<Integer> bb = new BoundedBuffer<Integer>(10);
Thread taker = new Thread() {
public void run() {
try {
// 空队列获取元素,阻塞
int unused = bb.take();
// 如果执行fail(),则表示阻塞失败
fail(); // if we get here, it's an error
} catch (InterruptedException success) {
}
}
};
try {
taker.start();
// 主线程sleep一小段时间,保证taker
Thread.sleep(LOCKUP_DETECT_TIMEOUT);
// 打断
taker.interrupt();
taker.join(LOCKUP_DETECT_TIMEOUT);
// 如果中断成功,则这里输入false
assertFalse(taker.isAlive());
} catch (Exception unexpected) {
fail();
}
}
安全性测试
思考一个问题,一个生成者-消费者队列,如何测试它的安全性?——每一个生产出来的元素都被正确地放入队列,并且被正确地消费。这看起来似乎不是特别难,但实际上,以上包括数量正确性,元素本身正确性,消费正确性(不会重复),生产正确性(正确入队)。
可以建立一个影子队列,在生产的时候把每一个元素push进去,在消费的时候,再找到它删除,最后检验是否为空,以及有没有找不到的情况。这当然可以,但问题在于,这个队列是共享资源,而且操作比较重,可想而知会引入大量但同步操作——这本身是不是又使得并发场景发生变化。
这就成了一个悖论了——为了测试并发程序,我们必然要开发一个并发测试程序,但开发完又会影响原来的并发程序。这有可能引起海森堡错误——假入并发测试程序后测试正常,撤出并发测试程序后就不行了。
所以,测试并发程序的要点就是:
尽可能减少并发测试程序引入的资源竞争
引入校验和就是一个好方法——在生产产品过程中,生成顺序敏感的校验和,然后就可以测试是否正确地生产,消费,顺序是否正确。当然这只针对单生产者-单消费者场景。在多生产者-多消费者的场景下,顺序肯定是无法保证的,所以这个时候的校验和应该是顺序不敏感的。
// 由于现代编译器比较聪明,常量可能会在编译阶段直接得到结果,因而还需要保证每一次获得的数是随机的,也是不可预测的
// xorShift结合hashCode,nanoTime作为输入解决这个问题,它非常快速,而且线程安全
static int xorShift(int y) {
y ^= (y << 6);
y ^= (y >>> 21);
y ^= (y << 7);
return y;
}
public class PutTakeTest {
private static final ExecutorService pool = Executors.newCachedThreadPool();
private final AtomicInteger putSum = new AtomicInteger(0);
private final AtomicInteger takeSum = new AtomicInteger(0);
private final CyclicBarrier barrier;
private final BoundedBuffer<Integer> bb;
private final int nTrials, nPairs;
public static void main(String[] args) {
new PutTakeTest(10, 10, 100000).test(); // sample parameters
pool.shutdown();
}
PutTakeTest(int capacity, int npairs, int ntrials) {
this.bb = new BoundedBuffer<Integer>(capacity);
this.nTrials = ntrials;
this.nPairs = npairs;
this.barrier = new CyclicBarrier(npairs * 2 + 1);
}
void test() {
try {
for (inti = 0; i < nPairs; i++) {
pool.execute(new Producer());
pool.execute(new Consumer());
}
// 阻塞,直到所有生产者消费者就绪,用于确保所有线程并发执行
barrier.await(); // wait for all threads to be ready
// 阻塞,直到所有生产者消费者完成,才可以计算校验和
barrier.await(); // wait for all threads to finish
assertEquals(putSum.get(), takeSum.get());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/* inner classes of PutTakeTest (Listing 12.5) */
class Producer implements Runnable {
public void run() {
try {
// 获得随机种子
int seed = (this.hashCode() ^ (int) System.nanoTime());
int sum = 0;
// 阻塞,直到所有生产者消费者就绪
barrier.await();
// 生产nTrials个产品
for (int i = nTrials; i > 0; --i) {
bb.put(seed);
sum += seed;
seed = xorShift(seed);
}
// 计算生产校验和,原子操作,竞争很少
putSum.getAndAdd(sum);
// 阻塞,直到所有线程执行完成
barrier.await();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
class Consumer implements Runnable {
public void run() {
try
{
// 阻塞
barrier.await();
// 消费
int sum = 0;
for (int i = nTrials; i > 0; --i) {
sum += bb.take();
}
// 计算消费校验和
takeSum.getAndAdd(sum);
// 阻塞
barrier.await();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
}
上述代码需要理解CyclicBarrier,它和CountDownLatcher很像,但它支持反复使用——即一次倒计时后,重新归位。上述代码就循环了两次。
资源管理安全性
主要是测试资源的申请,释放是否正常。
典型的例子,例如内存。任何涉及到管理内存的类,都要避免内存泄漏的问题,不适用的引用记得设置为null。
可以使用一些堆工具获取测试前后的堆变化,这些堆工具会强制执行一次垃圾回收,然后生成一份快照。通过对比两次内存快照就可以判断是否出现内存泄漏。
注意
- 活动线程数量应该大于处理器数量,这样可以尽可能地产生更多的线程交替行为,用于测试并发程序。
- 在不同os,处理器,平台上测试可能能获得更全面的结果
- 使用
Thread.yield有可能能增加更多的交替行为,但该调用的实现是开放性的(可以是空操作)。Thread.sleep更加可靠,但也更慢。
性能测试
我们可以想办法测试程序的吞吐量(可以通过总时间/总任务获得平均处理时间,倒数即单位吞吐量)或响应性,结合图表工具来测试并发程序的可伸缩性。例如:

性能陷阱
前文也提到,Java因为JVM为程序员做了太多事,所以相应也带来很多性能测试方面的不确定性问题。
- 垃圾回收,无法确定垃圾回收的时机
- 动态编译,JIT和AOT技术,无法确定哪段代码被编译过,哪段代码是运行时编译
- 代码路径,代码执行路径不同,同样会影响动态编译
- 无用的代码消除,JVM编译优化的时候,可能会将没有使用的代码消除调,导致性能测试不符合预期。这个时候可以对这些对象执行一些无意义的空判断,防止被优化掉。
if (foo.x.hashCode() == System.nanoTime())
System.out.print(" ");

并发程序测试因不确定性而复杂,正确性测试包括串行正确性和阻塞测试,其中阻塞测试可通过中断机制确定状态。安全性测试关注资源管理,如影子队列检验并发场景下的正确性。性能测试面临JVM的动态行为挑战,如垃圾回收和编译优化,需借助工具分析内存和吞吐量。测试时要注意线程数量、平台差异和性能陷阱。
6万+

被折叠的 条评论
为什么被折叠?



