序
基准测试业务逻辑是非常困难的,很多时候基准测试是基于时间来衡量的,很多人认为这些时间很容易得到,并且认为获取系统时间的消耗很微不足道,我们可以基于获取系统的时间来测量任何我们想测量的东西。这边文章我们来探索一些上面的这些误区。
这篇文章会涉及到基准测试方法论,如果你还没有了解JMH的话,可以通过先了解一下JMH。
构造性能模型
基准测试数据和基准测试本身没有关系,重要的是我们使用什么模型来得到这些数据。优雅的性能模型会使得基准测试做的很好,并且能够使得我们理解计算机、运行时、库、和用户代码是怎么在一起工作的。
我们将会从一个例子开始,这个例子还没有直接包含基准时间。我们首先问自己一个问题:volatile写操作的代价是什么?对于基准测试来说这是一个很简单的问题对吗?只要我们开一些线程去测量一下不就好了。
好吧,我们跑一下下面的基准测试代码:
/** * @author float.lu */ @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @State(Scope.Thread) @Warmup(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) @Measurement(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) @Fork(50) public class VolatileWriteSucks { private int plainV; private volatile int volatileV; @Benchmark public int baseline() { return 42; } @Benchmark public int incrPlain() { return plainV++; } @Benchmark public int incrVolatile() { return volatileV++; } public static void main(String ...s) throws Exception { Options options = new OptionsBuilder() .include(VolatileWriteSucks.class.getSimpleName()) .build(); new Runner(options).run(); } }
硬件环境
Benchmark Mode Cnt Score Error Units VolatileWriteSucks.baseline avgt 250 3.945 ± 1.232 ns/op VolatileWriteSucks.incrPlain avgt 250 3.851 ± 0.529 ns/op VolatileWriteSucks.incrVolatile avgt 250 11.807 ± 0.798 ns/op
上面的测试数据可以看出,volatile变量的写操作几乎比正常变量的写操作慢3倍!这意味着我们如果在应用中使用volatile类型变量,系统将会慢三倍!有人说我们可以避免使用volatile类型变量类使得系统不会那么慢,好吧,我不知道怎么阻止这类人,但是这个实验有一个致命的瑕疵。
这个瑕疵并不是基准测试方法论,的确,基准测试已经达到了它的测试目的,即在当前特定环境下我们花了多少时间来递增volatile变量。但是我们很想知道:当我们进行重量级的操作的时候,系统的行为是怎样的?事实上,我们重量级操作的代码和轻量级操作的代码通常是混合在一起工作的,轻量级代码分担一些由重量级代码引起的一些代价的责任,因此,为了获得更有用的数据,我们需要制造这种“混合状态”。
模拟真实环境是很痛苦的,幸运的是,由于我们面对这个问题太频繁了,JMH为我们做了些事情,我们来看看JMH为我们提供的BlackHole.consumeCPU(int tokens)方法,这个方法根据参数tokens线性的消耗CPU,它不会休眠,但是会真的消费CPU时间,这使得我们可以构建更复杂的实验和构建更干净的测试代码:
/** * @author float.lu */ @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @State(Scope.Thread) @Warmup(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) @Measurement(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) @Fork(50) public class VolatileBackoff { @Param({"0", "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"}) private int tokens; private int plainV; private volatile int volatileV; @Benchmark public void baseline_Plain() { Blackhole.consumeCPU(tokens); } @Benchmark public int baseline_Return42() { Blackhole.consumeCPU(tokens); return 42; } @Benchmark public int baseline_ReturnV() { Blackhole.consumeCPU(tokens); return plainV; } @Benchmark public int incrPlain() { Blackhole.consumeCPU(tokens); return plainV++; } @Benchmark public int incrVolatile() { Blackhole.consumeCPU(tokens); return volatileV++; } public static void main(String ...s) throws Exception { Options options = new OptionsBuilder() .include(VolatileBackoff.class.getSimpleName()) .build(); new Runner(options).run(); } }
上面测试代码我们可以看到,我们在真正的操作之前会进行“back off”,@Param参数使得我们可以模拟backoff,模拟CPU的分片工作。好吧,看看我们的实验结果:
如果你的实验数据结果也是和上面的图差不多,并且认为很好的话,那么就大错特错了,我们将抽出baseline_Plain,忽略其他的baseline来绘制图表:
看起来很cool,图表似乎可以证明我们的backoff的确生效了。我们立马可以得出结论:在从20 tokens之后,我们可以忽略volatile的消耗。我们回头看看实验数据,上面的所有的图表,我们可以看出volatile操作只有在小于50ns的操作中才会比plain操作慢。
好吧,我们还有一些其他的plain操作。在我们看看他们之前,我们先问问自己:抽出baseline_Plain是一个很好的主意么?答案是:不。让我们抽出baseline_Plain42在来看看图表:
额?比baseline还快?对于经验丰富的性能测试专家来说,这个结果并不令人惊讶,因为性能是不可以组合的:因为我们没有办法预测两个不同的模块在一起时候的性能表现。有的人会说了:你确定你不是在开玩笑?baseline_Returen42之所以比较慢,是因为很显然他比baseline_Plain有更多的操作。baseline_Plain只是返回一个常量,为了公平起见,我们来看看baseline_ReturnV,baseline_ReturnV在返回之前会从内存中读取数据:
看看,它更快呀!关键点在于,baseline测量仍然是实验数据,我们下面会直接比较incrPlain和incrVolatile:
结果似乎比较和谐,volatile操作时间会戏剧性的被分摊掉,上面的实验显示了两个很重要的点:
我们需要性能模型来预测系统在不同条件下的行为。
很重要的一点,这些混合实验使得我们从不同的方式混合操作,从而更有力的对独立的性能表现进行预测。
等下,什么?我们的确可以忽略“混合”这步,直接来测量?我们可以直接将volatile操作包含早System.nanoTime()里面,不需要任何baseline,完了,这下你毁了。System.nanoTime()其实是不可靠的,后面再继续说。