Java并发编程实战 并发程序的测试总结

在测试并发程序时 所面临的主要挑战在于:潜在错误的发生并不具有确定性 而是随机的 要在测试中将这些故障暴露出来 就需要比普通的串行程序测试覆盖更广的范围并且执行更长的时间

正确性测试
在为某个并发类设计单元测试时 首先需要执行与测试串行类时相同的分析——找出需要检查的不变性条件和后验条件 幸运的话 在类的规范中将给出其中大部分的条件 而在剩下的时间里 当编写测试时将不断地发现新的规范

基于信号量的有界缓存

@ThreadSafe
public class SemaphoreBoundedBuffer  {
    private final Semaphore availableItems, availableSpaces;
    @GuardedBy("this") private final E[] items;
    @GuardedBy("this") private int putPosition = 0, takePosition = 0;

    public SemaphoreBoundedBuffer(int capacity) {
        if (capacity <= 0)
            throw new IllegalArgumentException();
        availableItems = new Semaphore(0);
        availableSpaces = new Semaphore(capacity);
        items = (E[]) new Object[capacity];
    }

    public boolean isEmpty() {
        return availableItems.availablePermits() == 0;
    }

    public boolean isFull() {
        return availableSpaces.availablePermits() == 0;
    }

    public void put(E x) throws InterruptedException {
        availableSpaces.acquire();
        doInsert(x);
        availableItems.release();
    }

    public E take() throws InterruptedException {
        availableItems.acquire();
        E item = doExtract();
        availableSpaces.release();
        return item;
    }

    private synchronized void doInsert(E x) {
        int i = putPosition;
        items[i] = x;
        putPosition = (++i == items.length) ? 0 : i;
    }

    private synchronized E doExtract() {
        int i = takePosition;
        E x = items[i];
        items[i] = null;
        takePosition = (++i == items.length) ? 0 : i;
        return x;
    }
}

基本的单元测试
BoundedBuffer的最基本单元测试类似于在串行上下文中执行的测试 首先创建一个有界缓存 然后调用它的各个方法 并验证它的后验条件和不变性条件 我们很快会想到一些不变性条件:新建立的缓存应该是空的 而不是满的 另一个略显复杂的安全测试是 将N个元素插入到容量为N的缓存中(这个过程应该可以成功 并且不会阻塞) 然后测试缓存是否已经填满(不为空)

BoundedBuffer的基本单元测试

public class TestBoundedBuffer extends TestCase {
    private static final long LOCKUP_DETECT_TIMEOUT = 1000;
    private static final int CAPACITY = 10000;
    private static final int THRESHOLD = 10000;

    void testIsEmptyWhenConstructed() {
        SemaphoreBoundedBuffer bb = new SemaphoreBoundedBuffer(10);
        assertTrue(bb.isEmpty());
        assertFalse(bb.isFull());
    }

    void testIsFullAfterPuts() throws InterruptedException {
        SemaphoreBoundedBuffer bb = new SemaphoreBoundedBuffer(10);
        for (int i = 0; i < 10; i++)
            bb.put(i);
        assertTrue(bb.isFull());
        assertFalse(bb.isEmpty());
    }

}

这些简单的测试方法都是串行的 在测试集中包含一组串行测试通常是有帮助的 因为它们有助于在开始分析数据竞争之前就找出与并发性无关的问题

对阻塞操作的测试
在测试方法的阻塞行为时 将引入额外的复杂性:当方法被成功地阻塞后 还必须使方法解除阻塞 实现这个功能的一种简单方式就是使用中断——在一个单独的线程中启动一个阻塞操作 等到线程则塞后再中断它 然后宣告阻塞操作成功 当然 这要求阻塞方法通过提前返回或者抛出InterruptedException来响应中断

测试阻塞行为以及对中断的响应性

void testTakeBlocksWhenEmpty() {
        final SemaphoreBoundedBuffer bb = new SemaphoreBoundedBuffer(10);
        Thread taker = new Thread() {
            public void run() {
                try {
                    int unused = bb.take();
                    fail(); // if we get here, it's an error
                } catch (InterruptedException success) {
                }
            }
        };
        try {
            taker.start();
            Thread.sleep(LOCKUP_DETECT_TIMEOUT);
            taker.interrupt();
            taker.join(LOCKUP_DETECT_TIMEOUT);
            assertFalse(taker.isAlive());
        } catch (Exception unexpected) {
            fail();
        }
    }

安全性测试
如果要构造一些测试来发现并发类中的安全性错误 那么这实际上是一个 先有蛋还是先有鸡 的问题:测试程序自身就是并发程序 要开发一个良好的并发测试程序 或许比开发这些程序要测试的类更加困难

在构建对并发类的安全性测试中 需要解决的关键问题在于 要找出那些容易检查的属性 这些属性在发生错误的情况下极有可能失败 同时又不会使得错误检查代码人为地限制并发性 理想情况是 在测试属性中不需要任何同步机制

适合在测试中使用的随机数生成器

public class XorShift {
    static final AtomicInteger seq = new AtomicInteger(8862213);
    int x = -1831433054;

    public XorShift(int seed) {
        x = seed;
    }

    public XorShift() {
        this((int) System.nanoTime() + seq.getAndAdd(129));
    }

    public int next() {
        x ^= x << 6;
        x ^= x >>> 21;
        x ^= (x << 7);
        return x;
    }
}

测试BoundedBuffer的生产者-消费者程序

public class PutTakeTest extends TestCase {
    protected static final ExecutorService pool = Executors.newCachedThreadPool();
    protected CyclicBarrier barrier;
    protected final SemaphoreBoundedBuffer bb;
    protected final int nTrials, nPairs;
    protected final AtomicInteger putSum = new AtomicInteger(0);
    protected final AtomicInteger takeSum = new AtomicInteger(0);

    public static void main(String[] args) throws Exception {
        new PutTakeTest(10, 10, 100000).test(); // sample parameters
        pool.shutdown();
    }

    public PutTakeTest(int capacity, int npairs, int ntrials) {
        this.bb = new SemaphoreBoundedBuffer(capacity);
        this.nTrials = ntrials;
        this.nPairs = npairs;
        this.barrier = new CyclicBarrier(npairs * 2 + 1);
    }

    void test() {
        try {
            for (int i = 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);
        }
    }

    static int xorShift(int y) {
        y ^= (y << 6);
        y ^= (y >>> 21);
        y ^= (y << 7);
        return y;
    }

    class Producer implements Runnable {
        public void run() {
            try {
                int seed = (this.hashCode() ^ (int) System.nanoTime());
                int sum = 0;
                barrier.await();
                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);
            }
        }
    }
}

这些测试应该放在多处理器的系统上运行 从而进一步测试更多形式的交替运行 然而 CPU的数量越多并不一定会使测试越高效 要最大程度地检测出一些对执行时序敏感的数据竞争 那么测试中的线程数量应该多于CPU数量 这样在任意时刻都会有一些线程在运行 而另一些被交换出去 从而可以检查线程间交替行为的可预测性

资源管理的测试
到目前为止 所有的测试都侧重于类与它的设计规范的一致程度——在类中应该实现规范中定义的功能 测试的另一个方面就是要判断类中是否没有做它不应该做的事情 例如资源泄漏 对于任何持有或管理其他对象的对象 都应该在不需要这些对象时销毁对它们的引用 这种存储资源泄漏不仅会妨碍垃圾回收器回收内存(或者线程 文件句柄 套接字 数据库连接或其他有限资源) 而且还会导致资源耗尽以及应用程序失败

测试资源泄漏

class Big {
        double[] data = new double[100000];
    }

    void testLeak() throws InterruptedException {
        SemaphoreBoundedBuffer bb = new SemaphoreBoundedBuffer(CAPACITY);
        int heapSize1 = snapshotHeap();
        for (int i = 0; i < CAPACITY; i++)
            bb.put(new Big());
        for (int i = 0; i < CAPACITY; i++)
            bb.take();
        int heapSize2 = snapshotHeap();
        assertTrue(Math.abs(heapSize1 - heapSize2) < THRESHOLD);
    }

    private int snapshotHeap() {
        /* Snapshot heap and return heap size */
        return 0;
    }

使用回调
在构造测试案例时 对客户提供的代码进行回调是非常有帮助的 回调函数的执行通常是在对象生命周期的一些已知位置上 并且在这些位置上非常适合判断不变性条件是否被破坏 例如 在ThreadPoolExecutor中将调用任务的Runnable和ThreadFactory

测试ThreadPoolExecutor的线程工厂类

class TestingThreadFactory implements ThreadFactory {
    public final AtomicInteger numCreated = new AtomicInteger();
    private final ThreadFactory factory = Executors.defaultThreadFactory();

    public Thread newThread(Runnable r) {
        numCreated.incrementAndGet();
        return factory.newThread(r);
    }
}

验证线程池扩展能力的测试方法

public class TestThreadPool extends TestCase {

    private final TestingThreadFactory threadFactory = new TestingThreadFactory();

    public void testPoolExpansion() throws InterruptedException {
        int MAX_SIZE = 10;
        ExecutorService exec = Executors.newFixedThreadPool(MAX_SIZE);

        for (int i = 0; i < 10 * MAX_SIZE; i++)
            exec.execute(new Runnable() {
                public void run() {
                    try {
                        Thread.sleep(Long.MAX_VALUE);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                }
            });
        for (int i = 0;
             i < 20 && threadFactory.numCreated.get() < MAX_SIZE;
             i++)
            Thread.sleep(100);
        assertEquals(threadFactory.numCreated.get(), MAX_SIZE);
        exec.shutdownNow();
    }
}

产生更多的交替操作
由于并发代码中的大多数错误都是一些低概率事件 因此在测试并发错误时需要反复地执行许多次 但有些方法可以提高发现这些错误的概率 在前面提到过 在多处理器系统上 如果处理器的数量少于活动线程的数量 那么与单处理器系统或者包含多个处理器的系统相比 将能产生更多的交替行为 同样 如果在不同的处理器数量 操作系统以及处理器架构的系统上进行测试 就可以发现那些在特定运行环境中才会出现的问题

使用Thread.yield来产生更多的交替操作

public synchronized void transferCredits(Account from,Account to,int amount) {
from.setBalance(from.getBalance() - amount);
if (random.nextInt(1000) > THRESHOLD)
	Thread.yield();
to.setBalance(to.getBalance() + amount);
}

性能测试
性能测试通常是功能测试的延伸 事实上 在性能测试中应该包含一些基本的功能测试 从而确保不会对错误的代码进行性能测试
虽然在性能测试与功能测试之间肯定会存在重叠之处 但它们的目标是不同的 性能测试将衡量典型测试用例中的端到端性能 通常 要获得一组合理的使用场景并不容易 理想情况下 在测试中应该反映出被测试对象在应用程序中的实际用法

在PutTakeTest中增加计时功能

基于栅栏的定时器

public class BarrierTimer implements Runnable {
    private boolean started;
    private long startTime, endTime;

    public synchronized void run() {
        long t = System.nanoTime();
        if (!started) {
            started = true;
            startTime = t;
        } else
            endTime = t;
    }

    public synchronized void clear() {
        started = false;
    }

    public synchronized long getTime() {
        return endTime - startTime;
    }
}

采用基于栅栏的定时器进行测试

public class TimedPutTakeTest extends PutTakeTest {
    private BarrierTimer timer = new BarrierTimer();

    public TimedPutTakeTest(int cap, int pairs, int trials) {
        super(cap, pairs, trials);
        barrier = new CyclicBarrier(nPairs * 2 + 1, timer);
    }

    public void test() {
        try {
            timer.clear();
            for (int i = 0; i < nPairs; i++) {
                pool.execute(new PutTakeTest.Producer());
                pool.execute(new PutTakeTest.Consumer());
            }
            barrier.await();
            barrier.await();
            long nsPerItem = timer.getTime() / (nPairs * (long) nTrials);
            System.out.print("Throughput: " + nsPerItem + " ns/item");
            assertEquals(putSum.get(), takeSum.get());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

使用TimedPutTakeTest的程序

public static void main(String[] args) throws Exception {
        int tpt = 100000; // trials per thread
        for (int cap = 1; cap <= 1000; cap *= 10) {
            System.out.println("Capacity: " + cap);
            for (int pairs = 1; pairs <= 128; pairs *= 2) {
                TimedPutTakeTest t = new TimedPutTakeTest(cap, pairs, tpt);
                System.out.print("Pairs: " + pairs + "\t");
                t.test();
                System.out.print("\t");
                Thread.sleep(1000);
                t.test();
                System.out.println();
                Thread.sleep(1000);
            }
        }
        PutTakeTest.pool.shutdown();
    }

多种算法的比较
虽然BoundedBuffer是一种非常合理的实现 并且它的执行性能也不错 但还是没有ArrayBlockingQueue或LInkedBlockingQueue那样好(这也解释了为什么这种缓存算法没有被选入类库中) java.util.concurrent中的算法已经通过类似的测试进行了调优 其性能也已经达到我们已知的最佳状态 此外 这些算法还能提供更多的功能 BoundedBuffer运行效率不高的主要原因是:在put和take方法中都含有多个可能发生竞争的操作 例如 获取一个信号量 获取一个锁 以及释放信号量等 在其他实现方法中 可能发生竞争的位置将少很多

响应性衡量
到目前为止 我们的重点是吞吐量的测量 这通常是并发程序最重要的性能指标 但有时候 我们还需要知道某个动作经过多长时间才能执行完成 这时就要测量服务时间的变化情况 而且 如果能获得更小的服务时间变动性 那么更长的平均服务时间是有意义的 可预测性 同样是一个非常有价值的性能特征 通过测量变动性 使我们能回答一些关于服务质量的问题 例如 操作在100毫秒内成功执行的百分比是多少

避免性能测试的陷阱
理论上 开发性能测试程序是很容易的——找出一个典型的使用场景 编写一段程序多次执行这种使用场景 并统计程序的运行时间 但在实际情况中 你必须提防多种编码陷阱 它们会使性能测试变得毫无意义

垃圾回收
垃圾回收的执行时序是无法预测的 因此在执行测试时 垃圾回收器可能在任何时刻运行 如果测试程序执行了N次迭代都没有触发垃圾回收操作 但在第N+1次迭代时触发了垃圾回收操作 那么即使运行次数相差不大 仍可能在最终测试的每次迭代时间上带来很大的(但却虚假的)影响

动态编译
与静态编译语言(例如 C或C++)相比 编写动态编译语言(例如Java)的性能基准测试要困难得多 在HotSpot JVM(以及其他现代的JVM)中将字节码的解释与动态编译结合起来使用 当某个类第一次被加载时 JVM会通过解译字节码的方式来执行它 在某个时刻 如果一个方法运行的次数足够多 那么动态编译器会将它编译为机器代码 当编译完成后 代码的执行方式将从解释执行变成直接执行
这种编译的执行时机是无法预测的 只有在所有代码都编译完成以后 才应该统计测试的运行时间 测量采用解释执行的代码速度是没有意义的 因为大多数程序在运行足够长的时间后 所有频繁执行的代码路径都会被编译 如果编译器可以在测试期间运行 那么将在两个方面对测试结果带来偏差:在编译过程中将消耗CPU资源 并且 如果在测量的代码中既包含解释执行的代码 又包含编译执行的代码 那么通过测试这种混合代码得到的性能指标没有太大意义

对代码路径的不真实采样
运行时编译器根据收集到的信息对已编译的代码进行优化 JVM可以与执行过程特定的信息来生成更优的代码 这意味着在编译某个程序的方法M时生成的代码 将可能与编译另一个不同程序中的方法M时生成的代码不同 在某些情况下 JVM可能会基于一些只是临时有效的假设进行优化 并在这些假设失效时抛弃已编译的代码

不真实的竞争程度
并发的应用程序可以交替执行两种不同类型的工作:访问共享数据(例如从共享工作队列中取出下一个任务)以及执行线程本地的计算(例如 执行任务 并假设任务本身不会访问共享数据) 根据两种不同类型工作的相关程度 在应用程序中将出现不同程度的竞争 并表现出不同的性能与可伸缩性
如果有N个线程从共享工作队列中获取任务并执行它们 并且这些任务都是计算密集型的以及运行时间较长(但不会频繁地访问共享数据) 那么在这种情况下几乎不存在竞争 吞吐量仅受限于CPU资源的可用性 然而 如果任务的生命周期非常短 那么在工作队列上将会存在严重的竞争 此时的吞吐量将受限于同步的开销

无用代码的消除
在编写优秀的基准测试程序(无论是何种语言)时 一个需要面对的挑战就是:优化编译器能找出并消除那些不会对输出结果产生任何影响的无用代码(Dead Code) 由于基准测试通常不会执行任务计算 因此它们很容易在编译器的优化过程中被消除 在大多数情况下 编译器从程序中删除无用代码都是一种优化措施 但对于基准测试程序来说却是一个大问题 因为这将使得被测试的内容变得更少 如果幸运的话 编译器将删除整个程序中的无用代码 从而得到一份明显虚假的测试数据 但如果不幸运的话 编译器在消除无用代码后将提高程序的执行速度 从而使你做出错误的结论

要编写有效的性能测试程序 就需要告诉优化器不要将基准测试当做无用代码而优化掉 这就要求在程序中对每个计算结果都要通过某种方式来使用 这种方式不需要同步或者大量的计算

其他的测试方法
还有其他一些QA方法 它们在找出某些类型的错误时非常高效 而在找出其他类型的错误时则相对低效 通过使用一些补充的测试方法 例如代码审查和静态分析等 可以获得比在使用任何单一方法更多的可信度

代码审查
正如单元测试和压力测试在查找并发错误时是非常高效和重要的手段 多人参与的代码审查通常是不可替代的(另一方面 代码审查也不能取代测试) 代码审查还有其他的好处 它不仅能发现错误 通常还能提高描述实现细节的注释的质量 因此将降低后期维护的成本和风险

静态分析工具
静态分析是指在进行分析时不需要运行代码 而代码核查工具可以分析类中是否存在一些常见的错误模式 在一些静态分析工具(例如 开源的FindBugs)中包含了许多错误模式检查器 能够检测出多种常见的编码错误 其中许多错误都很容易在测试与代码审查中遗漏

FindBugs包含的检查器中可以发现以下与并发相关的错误模式 而且一直在不断地增加新的检查器:

  • 不一致的同步
  • 调用Thread.run
  • 未被释放的锁
  • 空的同步块
  • 双重检查加锁
  • 在构造函数中启动一个线程
  • 通知错误
  • 条件等待中的错误
  • 对Lock和Condition的误用
  • 在休眠或者等待的同时持有一个锁
  • 自旋循环

面向方面的测试技术
AOP可以用来确保不变性条件不被破坏 或者与同步策略的某些方面保持一致

分析与监测工具
大多数商业分析工具都支持线程 这些工具在功能与执行效率上存在着差异 但通常都能给出对程序内部的详细信息(虽然分析工具通常采用侵入式实现 因此可能对程序的执行时序和行为产生极大的影响)

小结
要测试并发程序的正确性可能非常困难 因为并发程序的许多故障模式都是一些低概率事件 它们对于执行时序 负载情况以及其他难以重现的条件都非常敏感 而且 在测试程序中还会引入额外的同步或执行时序限制 这些因素将掩盖被测试代码中的一些并发问题 要测试并发程序的性能同样非常困难 与使用静态编译语言(例如C)编写的程序相比 用Java编写的程序在测试起来更加困难 因为动态编译 垃圾回收以及自动优化等操作都会影响与时间相关的测试结果
要想尽可能地发现潜在的错误以及避免它们在正式产品中暴露出来 我们需要将传统的测试技术(要谨慎地避免在这里讨论的各种陷阱)与代码审查和自动化分析工具结合起来 每项技术都可以找出其他技术忽略的问题

你可能感兴趣的:(Java并发)