二、性能测试方法

1 原则1:测试真实应用

应该在产品实际使用的环境中进行性能测试。

1.1 微基准测试

1. 必须使用被测的结果

例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
public void doTest() {
int nLoops = 50;
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) + fibImpl1(n - 1);
if (Double.isInfinite(d)) throw new ArithmeticException("Overflow");
return d;
}

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

1
2
3
long then = System.currentTimeMillis();
long now = System.currentTimeMillis();
System.out.println("Elapsed time:" + (now - then));

解决在于,确保读取被测结果,而不只是简单地写。实际上,将局部变量 l 的定义改为实例变量(并用关键字 volatile 声明)就能测试这个方法的性能了。(必须声明 volatile 的原因参见第九章)。

2. 不要包括无关的操作

多余的循环迭代的多余的,如果编译器足够智能的话,就能发现这个问题,从而只执行一遍循环。
另外,fibImpl(1000) 的性能可能与 fibImpl(1) 相差很大。如果目的是为了比较不同实现的性能,测试的输入就应该考虑用一系列数据。如下:

1
2
3
for(int i = 0; i < nLoops; i++) {
l = fibImpl1(random.nextInteger());
}

但是,微基准测试中的输入值必须事先计算好。

3. 必须输入合理的参数

此时还有第三个隐患:任意选择的随机输入值对于这段被测代码的用法来说并不具有代表性,实际用户可能只输入 100 以下的值。输入参数大于 1476 时,会抛出异常,因为此时计算出的是 double 类所能表示的最大斐波那契数。考虑如下实现:

1
2
3
4
5
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);
}

虽然很难想象会有比原先用递归更慢的实现,但我们假定有这个实现。通过大量输入值比较这两种实现(fibImplSlow 和 verySlowImpl),发现前者比后者快得多——仅仅因为在方法开始时进行了范围检查。
如果在真实场景中,用户只会传入小于 100 的值,那这个比较就是不正确的。(仅仅在原先的实现上添加了边界测试就使得性能变好,通常这是不可能的)。

Java 的一个特点就是代码执行的越多性能越好,第四章详解。基于这点,微基准测试应该包括热身期,使得编译器能生成优化的代码。
微基准测试需要热身期,否则测量的是编译而不是被测代码的性能了。

综上,正确的微基准测试代码可能是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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[1]);
}
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) + fibImpl1(n - 1);
if (Double.isInfinite(d)) throw new ArithmeticException("Overflow");
return d;
}
}

调用 fibImpl1() 的循环和方法开销,将每个结果都写入 volatile 变量中的额外开销导致测量结果有出入。
此外还要留意编译效应:频繁调用的方法、调用时的栈深度、方法参数的实际类型等,它还依赖代码实际运行的环境。
这里的基准测试,有大量的循环,整体时间以秒计,但每轮循环迭代通常是纳秒级。纳秒累计起来,“极少成都”就会成为频繁出现的性能问题。
特别是在做回归测试的时候,追踪级别设为纳秒很有意义。如果集合操作每次都节约几纳秒,日积月累下来意义就很重大了(第十二章详解),但是,对于那些不频繁的操作来说,例,同时只需要处理一个请求的 servlet ,修复微基准测所发现的纳秒级性能衰弱就是浪费时间(后者才是过度优化!)。

2 原则2:理解批处理流逝时间,吞吐量和响应时间

在客户端——服务器的吞吐量测试中,并不考虑客户端的思考时间。客户端向服务器发送请求,当它收到响应时,立刻发送新的请求。
指标常常被称为每秒事务数(TPS)、每秒请求数(RPS)、每秒操作数(OPS)。