您的位置:首页 > 编程语言 > Java开发

[Java Concurrency in Practice]第十二章 并发程序的测试

2015-09-07 10:23 801 查看

并发程序的测试

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

在进行安全性测试时,通常会采用测试不变性条件的形式,即判断某个类的行为是否与其规范保持一致。

活跃性测试包括进展测试和无进展测试两方面,这些都是很难量化的。

与活跃性相关的是性能测试。性能测试可以通过多个方面来衡量,包括:

吞吐量:指一组并发任务中已完成任务所占的比例。

响应性:指请求从发出到完成之间的时间(也称为延迟)。

可伸缩性:指在增加更多资源的情况下(通常指CPU),吞吐量(或者缓解短缺)的提升情况。

12.1 正确性测试

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

为了进一步说明,接下来将构建一组测试用例来测试一个有界缓存。下面程序给出了BoundedBuffer的实现,其中使用Semaphore来实现缓存的有界属性和阻塞行为(在实际工作中应该使用ArrayBlockingQueue 或 LinkedBlockingQueue,而不是自己编写,但这里用于说明如何对添加和删除等方法进行控制的技术,在其他数据结构中同样可以使用)。

public class BoundedBuffer<E> {
//可用信号量、空间信号量
private final Semaphore availableItems, availableSpaces;
private final E[] items;//缓存
private int putPosition = 0, takePosition = 0;//放、取索引位置

public BoundedBuffer(int capacity) {
availableItems = new Semaphore(0);//初始时没有可用的元素
availableSpaces = new Semaphore(capacity);//初始时空间信号量为最大容量
items = (E[]) new Object[capacity];
}

public boolean isEmpty() {
//如果可用信号量为0,则表示缓存为空
return availableItems.availablePermits() == 0;
}

public boolean isFull() {
//如果空间信号量为0,表示缓存已满
return availableSpaces.availablePermits() == 0;
}

public void put(E x) throws InterruptedException {
availableSpaces.acquire();//阻塞获取空间信号量
doInsert(x);
availableItems.release();//可用信号量加1
}

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;
}
}


12.1.1 基本的单元测试

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

public class BoundedBufferTest extends TestCase {
//刚构造好的缓存是否为空测试
public void testIsEmptyWhenConstructed() {
BoundedBuffer<Integer> bb = new BoundedBuffer<Integer>(10);
assertTrue(bb.isEmpty());
assertFalse(bb.isFull());
}

//测试是否满
public void testIsFullAfterPuts() throws InterruptedException {
BoundedBuffer<Integer> bb = new BoundedBuffer<Integer>(10);
for (int i = 0; i < 10; i++)
bb.put(i);
assertTrue(bb.isFull());
assertFalse(bb.isEmpty());
}
}


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

12.1.2 对阻塞操作的测试

如果某方法需要在某些特定条件下阻塞,那么当测试这种行为时,只有当线程不再继续执行时,测试才是成功的。要测试一个方法的阻塞行为,类似于测试一个抛出异常的方法:如果这个方法可以正常返回,那么就意味着测试失败。

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

“等待并直到线程阻塞后”这句话说起来简单,做起来难。实际上,你必须估计执行这些指令可能需要多长时间,并且等待的时间会更长。如果估计的时间不准确(在这种情况下,你会看到伪测试失败),那么应该增大这个值。

下面程序给出了一种测试阻塞操作的方法。这种方法会创建一个“获取”线程,该线程将尝试从空缓存中获取一个元素。如果take方法成功,那么表示测试失败。执行测试的线程启动“获取”线程,等待一段时间,然后中断该线程。如果“获取”线程正确地在take方法中阻塞,那么将抛出InterruptedException,而捕获到这个异常的catch块将把这个异常视为成功,并让线程退出。然后,主测试线程会尝试与“获取“线程合并,通过调用Thread.isAlive来验证join方法是否成功方法,如果”获取“线程可以响应中断,那么join能很快地完成。

public void testTakeBlocksWhenEmpty() {
final BoundedBuffer<Integer> bb = new BoundedBuffer<Integer>(10);
Thread taker = new Thread() {
public void run() {
try {
int unused = bb.take();
fail();  // 如果运行到这里,就说明有错误,fail会抛出异常
} catch (InterruptedException  success) { }
}};
try {
taker.start();
Thread.sleep(1);
taker.interrupt();//中断阻塞线程
taker.join(10);//等待阻塞线程完成
assertFalse(taker.isAlive());//断言阻塞线程已终止
} catch (Exception unexpected) {
fail();
}
}


如果take操作由于某种意料之外的原因停滞了,那么支持限时的join方法能确保测试最终完成。这个测试方法测试了take的多种属性——不仅能阻塞,而且在中断后还能抛出InterruptedException。在这种情况下,最好还是对Thread进行子类化而不是使用线程池中的Runnable,即通过join来正确地结束测试。

开发人员会尝试使用Thread.getState来验证线程能否在一个条件等待上阻塞,但这种方法并不可靠。被阻塞线程并不需要进入WAITING或TIMED_WAITING等状态,因此JVM可以选择通过自旋等待来实现阻塞。类似地,由于在Object.wait或Condition.await等方法上存在伪唤醒,因此,即使一个线程等待的条件尚未成真,也可能从WAITING或TIMED_WAITING等状态临时性地转换到RUNNABLE状态。即使忽略这些不同实现之间的差异,目标线程在进入阻塞状态时也会消耗一定的时间。Thread.getState的返回结果不能用于并发控制,它将限制测试的有效性——其主要作用还是作为测试信息的来源。

12.1.3 安全性测试

要想测试一个并发类在不可预测的并发访问下能否正确执行,需要创建多个线程分别执行put和take操作,并在执行一段时间后判断在测试中是否会出现问题。

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

public class PutTakeTest extends TestCase {
protected static final ExecutorService pool = Executors
.newCachedThreadPool();
protected CyclicBarrier barrier;//为了尽量做到真正并发,使用屏障
protected final BoundedBuffer<Integer> 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 BoundedBuffer<Integer>(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(); // 等待所有线程都准备好
barrier.await(); // 等待所有线程完成,即所有线程都执行到这里时才能往下执行
assertEquals(putSum.get(), takeSum.get());//如果不等,则会抛异常
} catch (Exception e) {
throw new RuntimeException(e);
}
}

class Producer implements Runnable {
public void run() {
try {
//等待所有生产-消费线程、还有主线程都准备好后才可以往后执行
barrier.await();
// 种子,即起始值
int seed = (this.hashCode() ^ (int) System.nanoTime());
int sum = 0;//线程内部检验和
for (int i = nTrials; i > 0; --i) {
bb.put(seed);//入队
/*
* 累计放入检验和,为了不影响原程序,这里不要直接使用全局的
* putSum来累计,而是等每个线程试验完后再将内部统计的结果一
* 次性存入
*/
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);
}
}
}

/*
* 测试时尽量不是使用类库中的随机函数,大多数的随机数生成器都是线程安全的,
* 使用它们可能会影响原本的性能测试。在这里我们也不必要使用高先是的随机性。
* 所以使用简单而快的随机算法在这里是必要的。
*/
static int xorShift(int y) {
y ^= (y << 6);
y ^= (y >>> 21);
y ^= (y << 7);
return y;
}
}


详细说明参阅书本P209。

在初始化CyclicBarrier时将计数值指定为工作者线程的数量再加1,并在运行开始和结束时,使工作者线程和测试线程都在这个栅栏处等待。这能确保所有线程在开始执行任何工作之前,都首先执行到同一位置。PutTakeTest使用这项技术来协调工作者线程的启动和停止,从而产生更多的并发交替操作。我们仍然无法确保调度器不会采用串行方式来执行每个线程,但只要这些线程的执行时间足够长,就能降低调度机制对结果的不利影响。

PutTakeTest使用了一个确定性的结束条件,从而在判断测试何时完成时就不需要在线程之间执行额外的协调。test方法将启动相同数量的生产者线程和消费者线程,它们将分别插入(put)和取出(take)相同数量的元素,因此增加和删除的总数相同。

向PutTakeTest这种测试能很好地发现安全性问题。例如,在实现由信号量控制的缓存时,一个常见的错误就是在执行插入和取出的代码中忘记实现互斥行为(可以使用synchronized或ReentrantLock)。如果在PutTakeTest使用BoundedBuffer中忘记将doInsert和doExtract声明为synchronized,那么在运行PutTakeTest时会立即失败。通过多个线程来运行PutTakeTest,并且使这些线程在不同系统上的不同容量的缓存上迭代数百万池,使我们能进一步确定在put和take方法中不存在数据破坏问题。

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

在一些测试中通常要求执行完一定数量的操作后才能停止运行,如果在测试代码中出现了一个错误并抛出一个异常,那么这个测试将永远不会结束。最常见的解决方法是:让测试框架放弃那些没有在规定时间内完成的测试,具体要等待多长的时间,则要凭经验来确定,并且要对故障进行分析以确保所出现的问题并不是由于没有等待足够长的时间而造成的。

12.1.4 资源管理的测试

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

通过一些测量应用程序中内存使用情况的堆检查工具,可以很容易地测试出对内存的不合理占用,许多商用和开源的堆分析工具中都支持这种功能。下面程序的testLeak方法中包含了一些堆分析工具用于抓取堆的快照,这将强制执行一次垃圾回收,然后记录堆大小和内存使用量信息。

//大对象
class Big { double[] data = new double[100000]; }
void testLeak() throws InterruptedException {
BoundedBuffer<Big> bb = new BoundedBuffer<Big>(CAPACITY);
//使用前堆大小快照,这里可以调用第三方堆追踪(heap-profiling)工具来记录。堆追踪工具会强制进行垃圾回收,然后记录下堆大小和内存用量信息
int heapSize1 =  /* snapshot heap */ ;
for (int i = 0; i < CAPACITY; i++)
bb.put(new Big());
for (int i = 0; i < CAPACITY; i++)
bb.take();
int heapSize2 =  /* snapshot heap */ ;
assertTrue(Math.abs(heapSize1-heapSize2) < THRESHOLD);
}


testLeak方法将多个大型对象插入到一个有界缓存中,然后将它们移除。第2个堆快照中的内存用量应该与第1个堆快照中的内存用量基本相同。然而,doExtract如果忘记将返回元素的引用置为空(items[i] = null),那么在两次快照中报告的内存用量将明显不同。(这是为数不多几种需要显式地将变量置空的情况之一。大多数情况下,这种做法不仅不会带来帮助,甚至还会带来负面作用。)

12.1.5 使用回调

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

在测试线程池时,需要测试执行策略的多个方面:在需要更多的线程时创建新线程,在不需要时不创建,以及当需要回收空闲线程时执行回收操作等。要构建一个全面地测试方案是很困难的,但其中许多方面的测试都可以单独进行。

通过使用自定义的线程工厂,可以对线程的创建过程进行控制。下面程序TestingThreadFactory中将记录已创建线程的数量。这样,在测试过程中,测试方案可以验证已创建线程的数量。我们还可以对TestingThreadFactory进行扩展,使其返回一个自定义的Thread,并且该对象可以记录自己在何时结束,从而在测试方案中验证线程在被回收时是否与执行策略一致。

class TestingThreadFactory implements ThreadFactory {
public final AtomicInteger numCreated = new AtomicInteger();//记录已创建的工作线程数
private final ThreadFactory factory
= Executors.defaultThreadFactory();

public Thread newThread(Runnable r) {//Executor框架在创建工作线程时回调此方法
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();
}
}


12.1.6 产生更多的交替操作

由于并发代码中的大多数错误都是一些低概率事件,因此在测试并发错误时需要反复地执行许多次,但有些方法可以提高发现这些错误的概率。有一种有用的方法可以提高交替操作的数量,以便能有效地搜索程序的状态空间:在访问共享状态的操作中,使用Thread.yield将产生更多的上下文切换。(这项技术的有效性与具体的平台相关,因为JVM可以将Thread.yield作用一个空操作。如果使用一个睡眠时间较短的sleep,那么虽然慢些,但却更可靠。)

下面程序中的方法在两个账户之间执行转账操作,在两次更新操作之间,像”所有账户的总和应等于零“这样的一些不变性条件可能会被破坏。当代码在访问状态时没有使用足够的同步,将存在一些对执行时序敏感的错误,通过在某个操作的执行过程中调用yield方法,可以将这些错误暴露出来。这种方法需要在测试中添加一些调用并且在正式产品中删除这些调用,这将给开发人员带来不便,通过使用面向方面编程(AOP)的工具,可以降低这种不便性。

使用Thread.yield,让线程从Thread.yield调用点切换到另一线程,有助于发现Bug,该方法只适合用于测试环境中。下面使用该方法在取出与存入间切换到另一线程:

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);
}


12.2 性能测试

性能测试通常是功能测试的延伸。事实上,在性能测试中应该包含一些基本的功能测试,从而确保不会对错误地代码进行性能测试。

虽然在性能测试与功能测试之间肯定会存在重叠之处,但它们的目标是不同的。性能测试将衡量典型测试用例中的端到端性能。通常,要获得一组合理地使用场景并不容易,理想情况下,在测试中应该反映被测试对象在应用程序中的实际用法。

在某些情况下,也存在某种显而易见的测试场景。在生产者 - 消费者设计中通常都会用到有界缓存,因此显然需要测试生产者向消费者提供数据时的吞吐量。

12.2.1 在PutTakeTest中增加计时功能

扩展上面的PutTakeTest,给它加上时间测量特性。测试性能时的时间最好取多个线程的平均消耗时间,这样会精确一些。在PutTakeTest中我们已经使用了CyclicBarrier去同时启动和结束工作者线程了,所以我们只要使用一个关卡动作(在所有线程都达关卡点后开始执行的动作)来记录启动和结束时间,就完成了对该测试的扩展。下面是扩展后的PutTakeTest:

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);
}
}

public static void main(String[] args) throws Exception {
int tpt = 100000; // 每对线程(生产-消费)需处理的元素个数
//测试缓存容量分别为1、10、100、1000的情况
for (int cap = 1; cap <= 1000; cap *= 10) {
System.out.println("Capacity: " + cap);
//测试工作线程数1、2、4、8、16、32、64、128的情况
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();
}

//关卡动作,在最后一个线程达到后执行。在该测试中会执行两次:
//一次是执行任务前,二是所有任务都执行完后
static 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;
}
}
}/*
Capacity: 1
Pairs: 1   Throughput: 7135 ns/item Throughput: 7090 ns/item
Pairs: 2   Throughput: 7127 ns/item Throughput: 7186 ns/item
Pairs: 4   Throughput: 7206 ns/item Throughput: 7193 ns/item
Pairs: 8   Throughput: 7204 ns/item Throughput: 7193 ns/item
Pairs: 16  Throughput: 7222 ns/item Throughput: 7183 ns/item
Pairs: 32  Throughput: 7290 ns/item Throughput: 7259 ns/item
Pairs: 64  Throughput: 7341 ns/item Throughput: 7550 ns/item
Pairs: 128 Throughput: 9574 ns/item Throughput: 9522 ns/item
Capacity: 10
Pairs: 1   Throughput: 783 ns/item  Throughput: 767 ns/item
Pairs: 2   Throughput: 757 ns/item  Throughput: 797 ns/item
Pairs: 4   Throughput: 769 ns/item  Throughput: 789 ns/item
Pairs: 8   Throughput: 785 ns/item  Throughput: 812 ns/item
Pairs: 16  Throughput: 799 ns/item  Throughput: 819 ns/item
Pairs: 32  Throughput: 845 ns/item  Throughput: 843 ns/item
Pairs: 64  Throughput: 833 ns/item  Throughput: 836 ns/item
Pairs: 128 Throughput: 939 ns/item  Throughput: 966 ns/item
Capacity: 100
Pairs: 1   Throughput: 753 ns/item  Throughput: 743 ns/item
Pairs: 2   Throughput: 743 ns/item  Throughput: 737 ns/item
Pairs: 4   Throughput: 742 ns/item  Throughput: 735 ns/item
Pairs: 8   Throughput: 738 ns/item  Throughput: 723 ns/item
Pairs: 16  Throughput: 735 ns/item  Throughput: 732 ns/item
Pairs: 32  Throughput: 731 ns/item  Throughput: 729 ns/item
Pairs: 64  Throughput: 753 ns/item  Throughput: 755 ns/item
Pairs: 128 Throughput: 735 ns/item  Throughput: 738 ns/item
Capacity: 1000
Pairs: 1   Throughput: 735 ns/item  Throughput: 725 ns/item
Pairs: 2   Throughput: 749 ns/item  Throughput: 714 ns/item
Pairs: 4   Throughput: 743 ns/item  Throughput: 747 ns/item
Pairs: 8   Throughput: 746 ns/item  Throughput: 753 ns/item
Pairs: 16  Throughput: 751 ns/item  Throughput: 754 ns/item
Pairs: 32  Throughput: 754 ns/item  Throughput: 740 ns/item
Pairs: 64  Throughput: 752 ns/item  Throughput: 755 ns/item
Pairs: 128 Throughput: 747 ns/item  Throughput: 750 ns/item
*/


不同容量缓存下TimedPutTakeTest运行效果:



12.2.2 多种算法的比较

虽然上面的BoundedBuffer是一种相当可靠的实现,它的运行机制也非常合理,但是它还不足以和ArrayBlockingQueue 与LinkedBlockingQueue相提并论,这也解释了为什么这种缓存算法没有被选入类库中。并发类库中的算法已经被选择并调整到最佳性能状态了。BoundedBuffer性能不高的主要原因:put和take操作分别都有多个操作可能遇到竞争——获取一个信号量,获取一个锁、释放信号量。

在测试的过程中发现LinkedBlockingQueue的伸缩性好于ArrayBlockingQueue,这主要是因为链接队列的put和take操作允许有比基于数组的队列更好的并发访问,好的链接队列算法允许队列的头和尾彼此独立地更新。LinkedBlockingQueue中好的并发算法抵消了创建节点元素的开销,那么这种算法通常具有更高的可伸缩性。这似乎与传统性能调优相违背。



12.2.3 响应性衡量

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





12.3 避免性能测试陷阱

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

12.3.1 垃圾回收

…略

12.3.2 动态编译

…略

12.3.3 对代码路径的不真实采样

…略

12.3.4 不真实的竞争程度

…略

12.3.5 无用代码的消除

…略

12.4 其他测试方法

代码审查(人工检查代码),竞态分析工具(FindBugs),面向方面的测试技术,分析与检测工具(jvisualvm)

小结

要测试并发程序的正确性可能非常困难,因为并发程序的许多故障模式都是一些低概率事件,它们对于执行时序、负载情况以及其他难以重现的条件都非常敏感。而且,在测试程序中还会引入额外的同步或执行时序限制,这些因素将掩盖被测试代码中的一些并发问题。要测试并发程序的性能同样非常困难,与使用静态编译语言(例如C)编写的程序相比,用Java编写的程序在测试起来更加困难,因为动态编译、垃圾回收以及自动化等操作都会影响与时间相关的测试结果。

要想尽可能地发现潜在的错误以及避免它们在正式产品中暴露出来,我们需要将传统的测试技术(要谨慎地避免在这里讨论的各种陷阱)与代码审查和自动化分析工具结合起来,每项技术都可以找出其他技术忽略的问题。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  并发 性能测试