以下内容在JVM server模式之下运行, JIT会被充分利用


Java语言非常智能, 有静态编译与动态编译的能力,
会自己分析, 将无用的代码进行优化
在我们写JMH的时候, 这些自动优化的操作依然会进行
这样就带来一个问题: 我们要测试的代码如果被自动优化了, 很可能得到的测试结果与真实的结果差距会非常巨大

在我们写JMH的时候可能要写一些优化用例的代码, 这些优化并不是为了提高执行效率, 而是为了防止JVM自作主张的”优化”我们的代码, 因为我们就是为了测耗时的


1. 返回值未使用优化

在很多场景下, 如果我们的方法没有返回值, 计算过程可能都会被直接省略
但我们想测试执行效率, 就必须让方法真实执行
下面看个例子, 看看JVM是如何自动优化导致测试结果不准确的

1.1 完整示例

这里测试了一个空方法的耗时

  1. @Warmup(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS)
  2. @Measurement(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS)
  3. @State(Scope.Thread)
  4. @BenchmarkMode(Mode.AverageTime)
  5. @OutputTimeUnit(TimeUnit.NANOSECONDS)
  6. public class Sample_08_DeadCode {
  7. private double x = Math.PI; //π
  8. private double compute(double d) {
  9. for (int c = 0; c < 10; c++) {
  10. d = d * d / Math.PI;
  11. }
  12. return d;
  13. }
  14. @Benchmark
  15. public void baseline() {
  16. }
  17. @Benchmark
  18. public void measureWrong() {
  19. compute(x);
  20. }
  21. @Benchmark
  22. public double measureRight() {
  23. //正确的做法, 把结果返回, 让JVM认为计算不能省略
  24. return compute(x);
  25. }
  26. public static void main(String[] args) throws RunnerException {
  27. Options opt = new OptionsBuilder()
  28. .include(Sample_08_DeadCode.class.getSimpleName())
  29. .forks(1)
  30. .jvmArgs("-server") //注意这里一定要设置server模式, 以充分利用JIT
  31. .build();
  32. new Runner(opt).run();
  33. }
  34. }

测试报告:

  1. Benchmark Mode Cnt Score Error Units
  2. Sample_08_DeadCode.baseline avgt 0.278 ns/op
  3. Sample_08_DeadCode.measureRight avgt 11.106 ns/op
  4. Sample_08_DeadCode.measureWrong avgt 0.283 ns/op

1.2 被优化掉的方法

  1. @Benchmark
  2. public void baseline() {
  3. }
  4. //错误的例子, 这个方法会被JIT优化成空方法
  5. @Benchmark
  6. public void measureWrong() {
  7. compute(x); //因为我们没有使用计算结果, JVM会直接把这段代码优化掉, 相当于基准测试了个空方法
  8. }

有时候JVM太智能了, 但我们在JMH里面并不想他们太智能;
发现没? measureWrong()方法明明写了耗时的计算代码, 居然被优化后耗时居然跟空方法几乎一样
这是因为JVM有JIT功能, 通过逃逸分析发现这个计算结果根本没有被使用, 在server模式下优化策略比较激进, 直接把没用的计算指令优化掉了

1.3 将计算结果返回防止优化

  1. @Benchmark
  2. public double measureRight() {
  3. //正确的做法, 把结果返回, 让JVM认为计算不能省略
  4. return compute(x);
  5. }

如果我们将计算结果当做返回值返回, 那么jvm就不会对计算过程进行优化, JVM会以为我们的计算是真正有用的


2. 使用黑洞

上面那种情况, 我们只有一个计算结果的时候, 直接返回就行了
但如果我们有2个计算结果, 返回也只能返回1个对象
另外一个没有返回的对象, 还是会被智能的JVM检测到进行优化
使得测试结果违背我们的预期

2.1 完整示例

此例请在JDK8之下使用

  1. @Warmup(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS)
  2. @Measurement(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS)
  3. @BenchmarkMode(Mode.AverageTime)
  4. @OutputTimeUnit(TimeUnit.NANOSECONDS)
  5. @State(Scope.Thread)
  6. public class Sample_09_Blackholes {
  7. double x1 = Math.PI; //π
  8. double x2 = Math.PI * 2; //2π
  9. //基准
  10. @Benchmark
  11. public double baseline() {
  12. return Math.log(x1); //求自然对数, 以e为底
  13. }
  14. //错误示例
  15. @Benchmark
  16. public double measureWrong() {
  17. Math.log(x1); //编译器会自动识别, 在JIT的时候直接不执行, 使得JMH结果不准确
  18. return Math.log(x2); //真正有用的计算
  19. }
  20. //正确示例1
  21. @Benchmark
  22. public double measureRight_1() {
  23. //所有计算结果都真实使用了
  24. return Math.log(x1) + Math.log(x2);
  25. }
  26. //正确示例2
  27. @Benchmark
  28. public void measureRight_2(Blackhole bh) {
  29. //如果执行结果不使用编译器会自动优化
  30. //为了防止编译器自作主张, 这里使用JMH提供的黑洞Blackhole对象对执行结果进行消费
  31. bh.consume(Math.log(x1));
  32. bh.consume(Math.log(x2));
  33. }
  34. public static void main(String[] args) throws RunnerException {
  35. Options opt = new OptionsBuilder()
  36. .include(Sample_09_Blackholes.class.getSimpleName())
  37. .forks(1)
  38. .build();
  39. new Runner(opt).run();
  40. }
  41. }

测试报告:

  1. Benchmark Mode Cnt Score Error Units
  2. Sample_09_Blackholes.baseline avgt 18.827 ns/op
  3. Sample_09_Blackholes.measureRight_1 avgt 34.445 ns/op
  4. Sample_09_Blackholes.measureRight_2 avgt 37.923 ns/op
  5. Sample_09_Blackholes.measureWrong avgt 18.180 ns/op

2.2 被优化掉的方法

  1. double x1 = Math.PI; //π
  2. double x2 = Math.PI * 2; //2π
  3. //错误示例
  4. @Benchmark
  5. public double measureWrong() {
  6. Math.log(x1); //编译器会自动识别, 在JIT的时候直接不执行, 使得JMH结果不准确
  7. return Math.log(x2); //真正有用的计算
  8. }

Math.log(x1)在这里计算自然对数的这行代码, 被编译器识别出来是无意义的计算, 就直接被优化掉了
真正执行JMH测试的时候, 并没有被执行

  1. Benchmark Mode Cnt Score Error Units
  2. Sample_09_Blackholes.baseline avgt 18.827 ns/op
  3. Sample_09_Blackholes.measureWrong avgt 18.180 ns/op

2.3 通过返回值防止JVM自动优化

这里我们也可以用返回值的方式处理

  1. double x1 = Math.PI; //π
  2. double x2 = Math.PI * 2; //2π
  3. //正确示例1
  4. @Benchmark
  5. public double measureRight_1() {
  6. //所有计算结果都真实使用了
  7. return Math.log(x1) + Math.log(x2);
  8. }

但实际这种方式有个缺点, 它增加了一个计算返回值的操作, 在计算规模比较小的用例里, 可能会对JMH的结果有较大影响

2.4 使用黑洞

JMH还提供了一个方便的黑洞工具来给我们使用, 能方便的防止JVM自动优化

  1. double x1 = Math.PI; //π
  2. double x2 = Math.PI * 2; //2π
  3. //正确示例2
  4. @Benchmark
  5. public void measureRight_2(Blackhole bh) {
  6. //如果执行结果不使用编译器会自动优化
  7. //为了防止编译器自作主张, 这里使用JMH提供的黑洞Blackhole对象对执行结果进行消费
  8. bh.consume(Math.log(x1));
  9. bh.consume(Math.log(x2));
  10. }

直接将每个阶段的计算结果当做参数传入黑洞对象中
JVM就不会自作主张了


3. 处理常量折叠

Java不愧为后端使用最广泛的语言, 特性实在太多
编译器也很强大
比如这里, 它会在编译时进行分析, 如果

3.1 完整示例

  1. @Warmup(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS)
  2. @Measurement(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS)
  3. @State(Scope.Thread)
  4. @BenchmarkMode(Mode.AverageTime)
  5. @OutputTimeUnit(TimeUnit.NANOSECONDS)
  6. public class Sample_10_ConstantFold {
  7. private double x = Math.PI;
  8. private final double wrongX = Math.PI;
  9. private double compute(double d) {
  10. for (int c = 0; c < 1000; c++) {
  11. d = d * d / Math.PI;
  12. }
  13. return d;
  14. }
  15. @Benchmark
  16. public double baseline() {
  17. // simply return the value, this is a baseline
  18. return Math.PI;
  19. }
  20. @Benchmark
  21. public double measureWrong_1() {
  22. //常量折叠了
  23. return compute(Math.PI);
  24. }
  25. @Benchmark
  26. public double measureWrong_2() {
  27. //常量折叠了
  28. return compute(wrongX);
  29. }
  30. @Benchmark
  31. public double measureRight() {
  32. // This is correct: the source is not predictable.
  33. return compute(x);
  34. }
  35. public static void main(String[] args) throws RunnerException {
  36. Options opt = new OptionsBuilder()
  37. .include(Sample_10_ConstantFold.class.getSimpleName())
  38. .forks(1)
  39. .jvmArgs("-server")
  40. .build();
  41. new Runner(opt).run();
  42. }
  43. }

测试结果

  1. Benchmark Mode Cnt Score Error Units
  2. Sample_10_ConstantFold.baseline avgt 1.805 ns/op
  3. Sample_10_ConstantFold.measureRight avgt 4798.269 ns/op
  4. Sample_10_ConstantFold.measureWrong_1 avgt 2.318 ns/op
  5. Sample_10_ConstantFold.measureWrong_2 avgt 2.493 ns/op

这差距恐怖不?
如果不用点手段防止JVM的自动优化, 那他妈测试结果就离谱

3.2 被优化掉的方法

  1. private final double wrongX = Math.PI;
  2. private double compute(double d) {
  3. for (int c = 0; c < 1000; c++) {
  4. d = d * d / Math.PI;
  5. }
  6. return d;
  7. }
  8. @Benchmark
  9. public double measureWrong_1() {
  10. //常量折叠了
  11. return compute(Math.PI);
  12. }
  13. @Benchmark
  14. public double measureWrong_2() {
  15. //常量折叠了
  16. return compute(wrongX);
  17. }

你想想如果你是编译器, 在看到上面代码之后, 你是不是会想, 这他妈的有必要反复计算这个方法吗?
算一次直接把结果存下来就OK了, 因为每次计算结果都是100%可预测
所以真正在跑JMH的时候, 并不能真正衡量 compute()方法的性能
这种优化就叫做常量折叠, 在编译期直接就给你算好结果放那了

3.3 防止优化

上面会出现常量折叠的原因就是, 计算结果可预测
那么我们只要让计算结果不可预测, 就可以防止JVM自动优化了
最直接的方法就是, 让入参不是一个常量

  1. private double x = Math.PI;
  2. @Benchmark
  3. public double measureRight() {
  4. // This is correct: the source is not predictable.
  5. return compute(x);
  6. }

JVM经过一顿分析, 发现传入的参数x, 是个变量, 他的值现在是π, 但不能保证未来还是π, 所以就不敢对其进行优化