- 在本机进程间进行数据传输时,是文件共享还是socket通信效率高?
- Netty开发中使用多个并发channel是否有助于提高吞吐?
- 怎么设置SOCKET发送缓冲区大小以及其他TCP参数以充分利用网络带宽?
我们在开发中,可能会经常面临上面类似的问题,要准确回答这种性质的问题,得到让人信服的结论,不能只靠分析和猜测,正确的方法是针对不同实现策略和算法,在相同的负载或者环境下执行性能测试,比较它们的测试结果,根据性能评分得出结论。 我们把这种性能测试和评估的过程称为Micro benchmark
。
和测试阶段执行的load test和stress test不同,Micro benchmark
针对的是单个应用中的某一部分代码,不用准备复杂的环境和构造数据,是轻量级的测试活动,它应当和Unit test一样,成为开发的日常环节。这样,我们可以在代码重构,性能优化甚至daily build时,快速执行,发现性能缺陷,避免到集成测试或者更晚的压力阶段发现性能问题。当然,这就意味着我们需要一个自动化的Micro benchmark
执行框架。
实现Micro benchmark
自动化框架似乎不是一件难事,无外乎就是按照指定的时间或者次数,不断循环执行测试代码,最后根据调用次数和执行时间等数据计算出TPS、平均响应时间等指标,最后以一定格式输出测试结果。但是,对大多数开发人员来说,针对JAVA应用完成这么一个框架几乎是一件不可能的事情,根本原因在于JVM/JIT在运行中会对JAVA应用执行大量动态优化,如果不能正确处理(很多时候甚至是避免)这些优化,我们的测试代码在JVM中的执行方式很可能偏离我们的意图,最终导致错误的、无法被信任的测试结果,本来性能较好的方案得分较低,明显性能差的方案反而得分更高。关于这一点,可能很多同学很难相信,眼见为实,我们以一个具体的例子(参考:Avoiding Benchmarking Pitfalls on the JVM)来说明。
比如我们实现了一个简单的自动benchmark框架:
public class WrongBench {
public static void bench(String name, long runMillis, int loop,
int warmup, int repeat, Runnable runnable) {
System.out.println("Running: " + name);
int max = repeat + warmup;
long average = 0L;
for (int i = 0; i < max; i++) {
long nops = 0;
long duration = 0L;
long start = System.currentTimeMillis();
while (duration < runMillis) {
for (int j = 0; j < loop; j++) {
runnable.run();
nops++;
}
duration = System.currentTimeMillis() - start;
}
long throughput = nops / duration;
boolean benchRun = i >= warmup;
if (benchRun) {
average = average + throughput;
}
System.out.print(throughput + " ops/ms" + (!benchRun ? " (warmup) | " : " | "));
}
average = average / repeat;
System.out.println("\n[ ~" + average + " ops/ms ]\n");
}
}
在上面的实现中,runnable
代表被测试的任务,name
是benchmark的名称,runMillis
和loop
表示每轮测试的持续时间和内部循环次数,warmup
和repeat
则是热身阶段和正式测试阶段的执行迭代个数。我们通过这个实现,不断循环执行被测试任务,最终输出该任务的吞吐量(ops/ms)。
接下来,我们使用这个简单的测试框架来测试几个函数的性能:
public class DistanceAlgo {
public static double distance(
double x1, double y1,
double x2, double y2) {
double dx = x2 - x1;
double dy = y2 - y1;
return Math.sqrt((dx * dx) +
(dy * dy));
}
public static double constant(
double x1, double y1,
double x2, double y2) {
return 0.0d;
}
public static void nothing() {
}
}
通过junit启动测试
@Test
public void benchmarkAll(){
benchmarkDistance();
benchmarkContant();
benchmarkNothing();
}
public void benchmarkDistance(){
bench("distance", RUN_MILLIS, LOOP, WARMUP, REPEAT, () ->
DistanceAlgo.distance(0.0d, 0.0d, 10.0d, 10.0d));
}
public void benchmarkContant(){
bench("constant", RUN_MILLIS, LOOP, WARMUP, REPEAT, () ->
DistanceAlgo.constant(0.0d, 0.0d, 10.0d, 10.0d));
}
public void benchmarkNothing(){
bench("nothing", RUN_MILLIS, LOOP, WARMUP, REPEAT,
DistanceAlgo::nothing);
}
在测试之前,虽然我们不能知道具体的分数,但是这几种算法的排名是显然的,nothing
得分最高,因为什么也没有执行,distance
得分最低,因为它的计算量最大。可是实际的测试结果,让我们大吃一惊:distance
的tpms最高,nothing
最低!这显然不合常理。
Running: distance
[ ~48913203 ops/ms ]
Running: constant
[ ~1128029 ops/ms ]
Running: nothing
[ ~307605 ops/ms ]
之所以会得到这样的测试结果,实际上就是没有处理好JVM优化造成的后果。
首先,我们不应该在一个进程内执行多个benchmark。测试第一个benchmark时,JVM发现总是生成使用相同的Runnable
调用bench方法,因此会针对这个bench调用,生成native code。而这段生成的native code在测试第二个benchmark时不再适用,因此JVM后会继续优化,生成新的native code,可以同时支持第一个Runnable
和第二个Runnable
,但是这段新代码的性能不如之前的代码。而在运行第三个benchmark时,JVM发现了第三个Runnbale
,但是JVM此时无能为力,无法继续优化(JVM的限制,对于一个调用,最多只能绑定两个实现),因此,第三个benchmark只能通过效率较低的无优化代码执行。 这样,三个benchmark没有在公平的环境下比较,较先执行的benchmark总是得分更高。
其次,即使我们把这些benchmark分别在不同的JVM中执行,对distance
的测试仍然不准确,我们会发现它的性能和contant
非常接近:
Running: constant
[ ~49005393 ops/ms ]
Running: distance
[ ~48977615 ops/ms ]
但是distance
是基于double进行计算,contant
则始终返回常量,两者的性能差异应该比上面要更大。
这样的结果是由于JVM执行了Dead code elimination(DCE)
优化。JVM发现distance
的计算结果没有被任何地方使用,并且该计算没有改变任何状态,所以JVM将删除对distance
的计算代码!为了验证这一点,我们修改一下benchmark代码。
static double last = 0.0d;
...
@Test
public void benchmarkDistanceWithReturn(){
bench("distance_with_return", RUN_MILLIS, LOOP, WARMUP, REPEAT, () ->
last = DistanceAlgo.distance(0.0d, 0.0d, 10.0d, 10.0d));
}
修改后,我们把distance
的计算结果设置到类的静态属性中,这样JVM不会忽略distance
的计算结果。执行测试后,我们得到的结果是38434424,和constant
的49005393和被DCE的distance
的47636229有较大差距。
从上面这个例子可以看出,JVM的优化动作确实会对我们的benchmark造成影响,使得运行时的benchmark代码不能反映我们的初始意图:要么代码被忽略执行;要么一部分得到了额外优化另一部分没有。
实际上,JVM还有很多其它的优化动作,比如:constant-folding
, loop unrolling
, in-lining
等等(有兴趣的同学可以参考 OpenJDK wiki Performance Techniques page)。这些优化动作都可能对benchmark造成不同的影响。
要正确实现一个benchmark框架,我们必须非常清楚JVM的运行优化策略,然后采取措施,避免我们的benchmark代码不受这些优化动作的影响,这些对普通开发人员确实太困难了。幸运地是,我们可以使用JMH(java micro benchmark harness), 这个框架由Open SDK的开发团队提供,他们基于自己对 JDK/JVM实现的了解,在开发micro bench框架时考虑了各种情况,通过一些特别手段规避JVM/JIT的优化对benchmark的影响,有兴趣的同学可以详细了解他们在这方面的努力。
我们将在后续的文章里详细介绍JMH的使用。