复制代码

为懒人提供无限可能,生命不息,code不止

人类感性的情绪,让我们知难行难
我思故我在
日拱一卒,功不唐捐
  • 首页
  • 前端
  • 后台
  • 数据库
  • 运维
  • 资源下载
  • 实用工具
  • 接口文档工具
  • 登录
  • 注册

系统测试

【原创】性能测试方法->微基准测试

作者: whooyun发表于: 2017-04-05 17:00

  微基准测试用来测量微小代码单元的性能,包括调用同步方法的用时与非同步方法的用时比较,创建线程的代价与使用线程池的代价,执行某种算法的耗时与其替代实现的耗时,等等。

微基准测试看起来很好,但要写对却很困难。考虑以下代码,被测的方法是计算出第 50个斐波那契数,这段代码试图用微基准测试来测试不同实现的性能:

    public void doTest() {
// 主循环
        double l;
        long then = System.currentTimeMillis();
        for (int i = 0; i < nLoops; i++) {
            l = fibImpl1(50);
        }
        long now = System.currentTimeMillis();
        System.out.println("Elapsed time: " + (now - then));
    }
...

    private double fibImpl1(int n) {
        if (n < 0) throw new IllegalArgumentException("Must be > 0");
        if (n == 0) return 0d;
        if (n == 1) return 1d;
        double d = fibImpl1(n - 2) + fibImpl(n - 1);
        if (Double.isInfinite(d)) throw new ArithmeticException("Overflow");
        return d;
    }
 

代码看起来简单,却存在很多问题。

1. 必须使用被测的结果
这段代码的最大问题是,实际上它永远都不会改变程序的任何状态。因为斐波那契的计算结果从来没有被使用,所以编译器可以很放心地去除计算结果。智能的编译器(包括当前的 Java 7 和 Java 8)最终执行的是以下代码:

long then = System.currentTimeMillis();
long now = System.currentTimeMillis();
System.out.println("Elapsed time: " + (now - then));
结果是,无论计算斐波那契的方法如何实现,循环执行了多少次,实际的流逝时间其实只有几毫秒。循环如何被消除的细节请参见第 4 章。
有个方法可以解决这个问题,即确保读取被测结果,而不只是简单地写。实际上,将局部变量 l 的定义改为实例变量(并用关键字 volatile 声明)就能测试这个方法的性能了。(实例变量 l 必需声明为 volatile 的原因请参见第 9 章。)


2. 不要包括无关的操作
即便使用了被测结果,依然还有隐患。上述代码只有一个操作:计算第 50 个斐波那契数。可想而知,其中有些迭代操作是多余的。如果编译器足够智能的话,就能发现这个问题,从而只执行一遍循环——至少可以少几次迭代,因为那些迭代是多余的。另外, fibImpl(1000) 的性能可能与 fibImpl(1) 相差很大。如果目的是为了比较不同实现的性能,测试的输入就应该考虑用一系列数据。也就是说,解决这个问题,需要给 fibImpl1() 传入不同的参数。可以使用随机值,但仍然必须小心。
下面是种简单方法,即在循环中使用随机数生成器:

for (int i = 0; i < nLoops; i++) {
l = fibImpl1(random.nextInteger());
}
可以看到,循环中包括了计算随机数,所以测试的总时间是计算斐波那契数列的时间,加上生成一组随机数的时间。这可不是我们的目的。微基准测试中的输入值必须事先计算好,比如:
int[] input = new int[nLoops];
for (int i = 0; i < nLoops; i++) {
input[i] = random.nextInt();
}
long then = System.currentTimeMillis();
for (int i = 0; i < nLoops; i++) {
try {
l = fibImpl1(input[i]);
} catch (IllegalArgumentException iae) {
}
}
long now = System.currentTimeMillis();
3. 必须输入合理的参数
此处还有第 3 个隐患,就是测试的输入值范围:任意选择的随机输入值对于这段被测代码的用法来说并不具有代表性。在这个测试例子中,有一半的方法调用会立即抛出异常(即所有的负数)。输入参数大于 1476 时,也都会抛出异常,因为此时计算出来的是 double 类型所能表示的最大的斐波那契数。如果计算斐波那契数的速度大幅度提升,但例外情况直到计算结束时才被监测到时,在实现中会发生什么?考虑下面这种替代实现:
public double fibImplSlow(int n) {
if (n < 0) throw new IllegalArgumentException("Must be > 0");
if (n > 1476) throw new ArithmeticException("Must be < 1476");
return verySlowImpl(n);
}

虽然很难想象会有比原先用递归更慢的实现,但我们不妨假定有这么个实现并用在了这段代码里。通过大量输入值比较这两种实现,我们会发现,新的实现竟然比原先的实现快得多——仅仅是因为在方法开始时进行了范围检查。如果在真实场景中,用户只会传入小于 100 的值,那这个比较就是不正确的。通常情况下fibImpl() 会更快,正如第 1 章所说,我们应该为常见的场景进行优化。(显然这是个精心构造的例子。不管怎样,仅仅在原先的实现上添加了边界测试就使得性能变好,通常这是不可能的。)

综合所有因素,正确的微基准测试代码看起来应该是这样:

package net.sdo;
import java.util.Random;
public class FibonacciTest {
private volatile double l;
private int nLoops;
private int[] input;
public static void main(String[] args) {
FibonacciTest ft = new FibonacciTest(Integer.parseInt(args[0]));
ft.doTest(true);
ft.doTest(false);
}
private FibonacciTest(int n) {
nLoops = n;
input = new int[nLoops];
Random r = new Random();
for (int i = 0; i < nLoops; i++) {
input[i] = r.nextInt(100);
}
}
private void doTest(boolean isWarmup) {
long then = System.currentTimeMillis();
for (int i = 0; i < nLoops; i++) {
l = fibImpl1(input[i]);
}
if (!isWarmup) {
long now = System.currentTimeMillis();
System.out.println("Elapsed time:" + (now - then));
}
}
private double fibImpl1(int n) {
if (n < 0) throw new IllegalArgumentException("Must be > 0");
if (n == 0) return 0d;
if (n == 1) return 1d;
double d = fibImpl1(n - 2) + fibImpl(n - 1);
if (Double.isInfinite(d)) throw new ArithmeticException("Overflow");
return d;
}
}

甚至这个微基准测试的测量结果中也仍然有一些与计算斐波那契数没有太大关系:调用fibImpl1() 的循环和方法开销,将每个结果都写到 volatile 变量中也会有额外开销。此外还需要留意编译效应。编译器编译方法时,会依据代码的性能分析反馈来决定所使用的最佳优化策略。性能分析反馈基于以下因素:频繁调用的方法、调用时的栈深度、方法参数的实际类型(包括子类)等,它还依赖于代码实际运行的环境。编译器对于相同代码的优化在微基准测试中和实际应用中经常有所不同。如果用相同的测试衡量斐波那契方法的其他实现,就能看到各种编译效应,特别是当这个实现与当前的实现处在不同的类中时。

    最终,还要探讨微基准测试实际意味着什么。比如这里讨论的基准测试,它有大量的循环,整体时间以秒计,但每轮循环迭代通常是纳秒级。没错,纳秒累计起来,“积少成多”就会成为频繁出现的性能问题。特别是在做回归测试的时候,追踪级别设为纳秒很有意义。如果集合操作每次都节约几纳秒,日积月累下来意义就很重大了(示例参见第 12章)。对于那些不频繁的操作来说,例如那种同时只需处理一个请求的 servlet,修复微基准测试所发现的纳秒级性能衰减就是浪费时间,这些时间用在优化其他操作上可能会更有价值。

微基准测试难于编写,真正管用的又很有限。所以,应该了解这些相关的隐患后再做出决定,是微基准测试合情合理值得做,还是关注宏观的测试更好。