Java 性能测试的四项原则

引言

计算机软件作为人类智慧的结晶,帮助我们在这个日新月异的社会中完成了大量工作。我们的日常生活中已经离不开软件,玲琅满目的软件已经渗透到了我们生活的各个角落,令我们目不暇接。我们都希望软件变得更好,运行处理的速度更快,在当今硬件性能突飞猛进的变革中,软件性能的提升也是一个永不落伍的话题。软件性能测试的实质,是从哲学的角度看问题,找出其内在联系,因果关系,形式内容关系,重叠关系等等。假如这些关系我们在分析过程中理清了,那么性能测试问题就会变得迎刃而解。

在软件开发过程中,性能测试往往在开发前期容易被忽略。直到有一天问题暴露后,开发人员被迫的直面这个问题,大多数情况下,这是令开发人员感觉到非常痛苦事情。所以在软件开发前期以及开发过程中性能测试的考量是必要的,那么具备相应理论知识和实践方法也是一个优秀工程师所应当具备的素养,这里我们概括有四项原则,这些原则可以帮助开发人员丰富、充实测试理论,系统的开展性能测试工作,从而获得更有价值的结果。

实际项目中的性能测试才有意义

第一个原则就是性能测试只有在实际项目中实施才是有意义的,这样才使得测试工作具有针对性,而且目标会更加明确。这个原则中有三个类别的基准可以指导开发人员度量性能测试的结果,但是每一种方法都有它的优点和劣势,我们将结合实际例子,来总结阐述。

  • 微观基准,可以理解为在某一个方法或某一个组件中进行的单元性能测试。比如检测一个线程同步和一个非线程同步的方法运行时所需要的时间。或者对比创建一个单独线程和使用一个线程池的性能开销。或者对比执行一个算法中的某一个迭代过程所需要的时间。当我们遇到这些情况时,我们常常会选择做一个方法层面的性能测试。这些情况的性能测试,都可以尝试使用微观基准的方法进行性能测试。微观基准看似编写起来简单快捷,但是编写能够准确反映性能问题的代码并非一件易事。接下来通过例子让我们从代码中发现一些问题。这是一个单线程的程序片段,通过计算 50 次循环迭代来检测执行方法所耗费的时间体现性能差异:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void doTest() {
double l;
long then = System.currentTimeMillis();
int nLoops = 50;
for (int i = 0; i < nLoops; i++) {
l = compute(50);
}
long now = System.currentTimeMillis();
System.out.println("Elapsed time:" + (now - then));
}

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

执行这段代码我们会发现一个问题,那就是执行时间只有短短的几秒。难道果真是程序性能很高?答案并非如此,其实在整个执行过程中 compute 计算方法并没有调用而是被编译器自动忽略了。那么解决这个问题的办法是将 double 类型的“l”换成 volatile 实例变量。这样能够确保每一个计算后所得到的结果是可以被记录下来,用 volatile 修饰的变量,线程在每次使用变量的时候,都会读取变量修改后的最后的值。

要特别值得注意的是,当考虑为多线程写一个微基准性能测试用例时,假如几个线程同时执行一小段业务逻辑代码,这可能会引发潜在的线程同步所带来的性能开销和瓶颈。此时微观微基准测试的结果往往引导开发人员为了保持同步进行不断的优化,这样会浪费很多时间,对于解决更紧迫的性能问题,这样做就显得得不偿失。

我们再试想这样一个例子,微基准测试两个线程调用同步方法的情况,因为基准代码很小,那么测试用例大部分时间将消耗在同步过程中。即使微基准测试在整体的同步过程中只占 50%,那么两个线程尝试执行同步方法的几率也是相当高的。基准运行将会非常缓慢,添加额外的线程会造成更大的性能问题。

基于微观基准的测试过程中,是不能含有额外的对性能产生影响的操作,我们知道执行 compute(1000) 和 compute(1) 在性能上是有很大差异的,假如我们的目标是对比两个不同实现方法之间的性能差异,那么就应当考虑一系列的输入测试值作为前提,传递给测试目标,参数就需要多样化。这里以我们的经验解决的办法就是使用随机值:

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

现在,产生随机数的时间也包含在了整个循环执行过程中,因此测试结果中包含了随机数生成所需要的时间,这并不能客观的体现 compute 方法真实的性能。所以在构建微观基准时,输入的测试值必须是预先准备好的,且不会对性能测试产生额外的影响。正确的做法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void doTest() {
double l;
int nLoops = 10;
Random random = new Random();
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 = compute(input[i]);
} catch (IllegalArgumentException iae) {

}

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

微观基准中输入的测试值必须是符合业务逻辑的。所有的输入的值并不一定会被代码用到,实际的业务可能对输入的数据有特定的要求,不合理的输入值可能导致代码在执行过程中就抛出异常而中断,从而使得我们难以判断代码执行的效率。所以在准备测试数据的时候应当考虑到输入数据的有效性,保证代码执行的完整性。比如下面的例子输入的参数如果是大于 1476 ,执行会立即中断,从而影响了真实性能结果的产生。

1
2
3
4
5
public double ImplSlow(int n) {
if (n < 0) throw new IllegalArgumentException("Must be > 0");
if (n > 1476) throw new ArithmeticException("Must be < 1476");
return verySlowImpl(n);
}

通常情况下,对参与到实际业务计算的值提前检测对提升性能是有帮助的,但是假如用户大多数输入的值是合理的,那么提前检查数据的有效性就显得冗余了。所以编写核心逻辑代码的时候,我们建议只针对一般情况做处理,保证执行的效率的高效性。假设访问一个 collection 对象时,每一次能够节省几毫秒的话,那么在多次的访问情况下就会对性能的提升产生重大的意义。

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
41
42
43
44
45
46
47
48
49
50
51
52
53
public class Test1 {

private volatile double l;
private int nLoops;
private int[] input;


private Test1(int n) {
nLoops = n;
input = new int[nLoops];
Random random = new Random();
for (int i = 0; i < nLoops; i++) {
input[i] = random.nextInt(50);
}
}

public void doTest(boolean isWarmup) {
long then = System.currentTimeMillis();

for (int i = 0; i < nLoops; i++) {
try {
l = compute(input[i]);
} catch (IllegalArgumentException iae) {
}
if (!isWarmup) {
long now = System.currentTimeMillis();
System.out.println("Elapsed time:" + (now - then));
}
}

}

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

public static void main(String[] args) {
// TODO Auto-generated method stub
Test1 test1 = new Test1(Integer.parseInt("10");));
test1.doTest(true);
test1.doTest(false);
}

}

总得说来,微观基准作用是有限的,在频繁调用的方法中使用微观基准的度量方法会帮助我们检测代码的性能,如果用在不会被频繁调用的方法中是不合适的,应当考虑其它方法。

  • 宏观基准,当我们测量应用程序性能时,应当纵览整个系统,影响应用程序性能的原因可能是多方面的,不能片面的认为性能瓶颈只会在程序本身上。通过下面这个例子我们将探讨离开宏观基准的性能测试是不可能找到影响应用程序性能真正的瓶颈。

上图数据来自客户实体,触发应用程序的核心业务计算方法,该方法从数据库加载数据,并传导给核心业务中的计算方法,得到结果保存到数据库,最终响应客户的请求。每个图形中的数字分别代表了这个模块所能处理客户请求的数量。核心业务模块的优化多数情况是受限于业务的要求。假设我们优化这些核心模块,使其可以处理 200 RPS 时,我们发现加载数据的模块依然只能处理 100 RPS,也就是说整个系统的吞吐能力其实仍然为 100 RPS,最终对应用程序整体的性能提升是没有任何帮助的。从这个例子我们得知,我们花费再多的精力在核心业务上的优化意义并不大,我们应当从整体运行情况来看,发现真正影响性能的瓶颈来解决问题,这就是宏观基准原则的意义。

  • 折衷基准,相比微观基准和宏观基准,一个单独功能模块的性能测试,或者一系列特定操作的性能测试被称为折衷基准。它是介于微观基准和宏观基准之间的折衷方案。基于微观基准测试的正确性是较难把握的,性能瓶颈的判断绝不能仅仅依赖于此。如果我们要使用微观基准作为性能的测量方法,那么不妨在此之前先尝试基于宏观基准的测试。它可以帮助我们了解系统以及代码是如何工作的,从而形成一个系统整体逻辑结构图。接下来可以考虑基于折衷基准的测试,来真正发现潜在的性能瓶颈。需要明确的是折衷基准的测试方法并不是完整应用程序测试的替代方法,更多情况下我们认为它更适用于一个功能模块的自动测试。

批量,吞吐量和响应时间的测量方法

性能测试中的第二个重要的原则是引入多样的测量方法来分析程序的性能。

  • 批量执行所用时间的测量方法(耗时法),这是种简单而快速有效的方法,通过测量完成特定任务所消耗的时间来测量整体性能。但是需要特别注意,假如所测试的应用程序中使用缓存数据技术来为了获得更好的性能表现时,多次循环使用该方法可能无法完全反应性能问题。那么可以尝试在初始状态开始时应用耗时法做一次性能的评估,然后当缓存建立后,再次尝试此方法。

  • 吞吐量的测量方法,在一段时间内考察完成任务的数量的能力,被称为吞吐量测量方法。在测试客户服务器的应用程序时,吞吐量的测量意味着客户端发送请求到服务器是没有任何延迟的,当客户端接收到响应后,应当立即发出新的请求,直到最终结束,统计客户端完成任务的总数。这种相对理想的测试方法通常称之为“Zero-think-time”。可是通常情况下,客户端可能会有多个线程做同一件事情,吞吐量则意味着每秒钟内所有客户端的操作数,而不是测量的某一个时段内的所有操作总数。这种测量经常称为每秒事务/(TPS),每秒请求 (RPS),或每秒操作数 (OPS)。

测试所有基于客户端和服务器端应用程序都存在一种风险,客户端不能以足够快的速度发送数据到服务器端,这种情况的发生可能是由于客户端此时没有足够的 CPU 资源去运行需要数量的线程,或者客户端必须耗用更长的时间来处理当前的请求。这种情况下,实际上测量的是客户端的性能,而非服务器的性能,与吞吐量测量方法是背道而驰的。其实这种风险是由每个客户端线程处理任务的数量和硬件配置决定的。“Zero-think-time”在吞吐量测试中可能经常会遇见以上的情况,由于每个客户端线程都需要处理大量的任务,因此吞吐量测试通常被应用于较少的客户端线程程序。吞吐量测量方法也同样适应用于带有缓存技术的应用程序,尤其是当测试的数据是一个并不固定的情况下。

  • 响应时间的测量方法,响应时间的测量方法是指客户端发出一个请求后直到接收到服务器的响应返回后的时间消耗。响应时间测量方法不同于吞吐量测量方法,在响应时间测试过程中,客户端线程可能会在操作的过程中某一时刻休眠,这就引出“think- time”这个关键词,当“think- time”被引入到测试过程中,也就是意味着待处理任务量是固定的,测量的是服务器响应请求的速率是怎样的。大多数情况下,响应时间的测量方法用来模拟用户真实操作,从而测量应用程序的性能。

多变性

性能测试的第三个原则是理解测试结果如何随时间改变,即使每一次测试使用同样的数据,可能获得的结果也是不同的。一些客观因素,比如后台运行的进程,网络的负载情况,这些都可能带来测试结果的不同,所以在测试过程中存在着一些随机性的因素。这就产生了一个问题: 当比较两次运行得到的测试结果时,它们之间的差异是由回归测试产生的,还是是随机变化而导致的呢?

我们不能简单的通过测量多次运行回归测试的平均结果来评判性能的差异。这时我们可以使用统计分析的方法,假设两种情况的平均值是一样的,然后通过概率来判断这样的假设是成立的。如果假设不成立,那么就说明有很高的概率证明平均数存在差异。

在回归测试中原始代码被视为基线,新增加的代码称为样本。三次运行基线和样本,产生时间如表 1:

表 1. 三次运行基线和样本结果
| 次数 | 基准 | 样本 |
| —- | —- | —- |
| 1 | 1.0 | 0.5 |
| 2 | 0.8 | 1.25 |
| 3 | 1.2 | 0.5 |
| 平均 | 1 | 0.75 |

看起来样本的平均值显示有 25%的提升,可事实证明样本和基线有相同性能的概率是 43%。也就是说 57%的概率存在性能上的不同。43%是基于 T 检验所得到的结果,T 检验主要用于样本含量较小(例如 n<30),总体标准差σ未知的正态分布资料。t 检验是用 t 分布理论来推论差异发生的概率,从而比较两个平均数的差异是否显著。它与 z 检验、卡方检验并列。现在的 T 检验结果告诉我们这样一个信息::57%概率显示样本和基线存在性能差异,差异最大值是 25%。也可以理解为性能差有 57%的置信度向理想发现发展,结果有 25%的改善。

在考量回归测试的结果时,离开了统计分析的方法,而只关注平均值来做出判断,含糊的理解这些数字的含义是不可取的。性能工程师的工作是看数据,理解这些概率,基于所有可用的数据确定在何处花时间。

尽早测试,经常测试

第四个原则就是工程师应该视性能测试是整个开发过程必要的部分,尽早进行性能测试,经常进行性能的测试,是一个好的工程师应该做到的。在代码提交到代码库之前,就应当做性能测试,因为性能问题也会导致回归测试失败。所以提早发现问题会提高整个项目的质量,减小交付的风险性。

在一个典型的项目开发周期过程中,项目计划常常是建立一个功能提交的时间表,所有功能的开发必须要在某一个时间点全部提交到代码库中,在项目发布之前,所有的精力都致力于解决功能上的 Bug,那么很有可能在这个过程中发现性能问题,这会导致两个问题产生:

  • 开发人员在时间的约束下不得不提交代码以满足时间表,一旦发现出严重的性能问题他们会非常畏惧,所以开发人员在测试开始的早期解决性能问题能够产生 1%的回归测试代价,而如果开发人员一直在等待晚上的冻结功能开发的时候才开始检查代码将会导致 20%的回归测试的代价。
  • 任何为解决性能做出的修改都有可能带来巨大的成本,有时不仅仅是代码的修改,更有可能是软件架构的修改。所以最好在软件设计之时就充分的考虑到未来可能带来的性能问题。

尽早测试性能有以下四点可作为指导:

  • 提早准备测试用户以及测试环境的设计和创建;
  • 性能测试应该考虑尽量用脚本来完成;
  • 通过性能监控工具尽量收集有可能得到的运行信息,为将来分析提供便利;
  • 一定要在一个能真实模拟多数用户的机器环境下进行性能测试。

总结

最后,基于我们讲过的方法作为基础,构建一个自动化的测试系统来收集测试过程中产生的各种信息,能够很好的帮助我们分析发现性能瓶颈。