并发程序的测试

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

并发的测试并不容易,原因在于并发的不确定性——并发场景下,很多问题无法重现,甚至它还有一个专有的名词,“海森堡错误”。此外,对于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。
可以使用一些堆工具获取测试前后的堆变化,这些堆工具会强制执行一次垃圾回收,然后生成一份快照。通过对比两次内存快照就可以判断是否出现内存泄漏。

注意

  1. 活动线程数量应该大于处理器数量,这样可以尽可能地产生更多的线程交替行为,用于测试并发程序。
  2. 在不同os,处理器,平台上测试可能能获得更全面的结果
  3. 使用Thread.yield有可能能增加更多的交替行为,但该调用的实现是开放性的(可以是空操作)。Thread.sleep更加可靠,但也更慢。

性能测试

我们可以想办法测试程序的吞吐量(可以通过总时间/总任务获得平均处理时间,倒数即单位吞吐量)或响应性,结合图表工具来测试并发程序的可伸缩性。例如:
在这里插入图片描述

性能陷阱

前文也提到,Java因为JVM为程序员做了太多事,所以相应也带来很多性能测试方面的不确定性问题。

  • 垃圾回收,无法确定垃圾回收的时机
  • 动态编译,JIT和AOT技术,无法确定哪段代码被编译过,哪段代码是运行时编译
  • 代码路径,代码执行路径不同,同样会影响动态编译
  • 无用的代码消除,JVM编译优化的时候,可能会将没有使用的代码消除调,导致性能测试不符合预期。这个时候可以对这些对象执行一些无意义的空判断,防止被优化掉。
if (foo.x.hashCode() == System.nanoTime())
    System.out.print(" ");
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值