Java 性能测试难题
当初的 JVM 曾经越来越为智能,它能够在编译阶段、加载阶段、运行阶段对代码进行优化。比方你写了一段不怎么聪慧的代码,到了 JVM 这里,它发现几处能够优化的中央,就棘手帮你优化了一把。这对程序的运行诚然美好,却让开发者不能精确理解程序的运行状况。在须要进行性能测试时,如果不晓得 JVM 优化细节,可能会导致你的测试后果差之毫厘,失之千里,同样的,Java 诞生之初就有一次编译、随处运行的口号,JVM 提供了底层反对,也提供了内存管理机制,这些机制都会对咱们的性能测试后果造成不可预测的影响。
long start = System.currentTimeMillis();
// ....
long end = System.currentTimeMillis();
System.out.println(end - start);
下面可能就是你最常见的性能测试了,这样的测试后果真的精确吗?答案是否定的,它有上面几个问题。
- 工夫精度问题,自身获取到的工夫戳就是存在 误差 的,它和操作系统无关。
- JVM 在运行时会进行 代码预热 ,说白了就是 越跑越快。因为类须要装载、须要筹备操作。
- JVM 会在各个阶段都有可能对你的代码进行 优化解决。
- 资源回收 的不确定性,可能运行很快,回收很慢。
带着这些问题,忽然发现进行一次严格的基准测试的难度大大增加。那么如何能力进行一次严格的基准测试呢?
JMH 介绍
那么如何对 Java 程序进行一次精准的性能测试呢?难道须要把握很多 JVM 优化细节吗?难道要钻研如何防止,并进行正确编码能力进行严格的性能测试吗?显然不是,如果是这样的话,未免过于艰难了,好在有一款一款官网的微基准测试工具 – JMH.
JMH 的全名是 Java Microbenchmark Harness,它是由 Java 虚拟机团队 开发的一款用于 Java 微基准测试工具。用本人开发的工具测试本人开发的另一款工具,以子之矛,攻子之盾果然手到擒来,如臂使指。应用 JMH 能够让你不便疾速的进行一次严格的代码基准测试,并且有多种测试模式,多种测试维度可供选择;而且应用简略、减少注解便可启动测试。
JMH 应用
JMH 的应用首先引入 maven 所需依赖,以后最新版 为 1.23 版本。
<!--jmh 基准测试 -->
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.23</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.23</version>
<scope>provided</scope>
</dependency>
疾速测试
上面应用注解的形式指定测试参数,通过一个例子展现 JMH 基准测试的具体用法,先看一次运行成果,而后再理解每个注解的具体含意。
这个例子是应用 JMH 测试,应用加号拼接字符串和应用 StringBuilder
的 append
办法拼接字符串时的速度如何,每次拼接 1000 个数字进行平均速度比拟。
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
/**
* <p>
* JMH 基准测试入门
*
* @author niujinpeng
* @Date 2020/8/21 1:13
*/
@BenchmarkMode(Mode.AverageTime)
@State(Scope.Thread)
@Fork(1)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3)
@Measurement(iterations = 5)
public class JmhHello {
String string = "";
StringBuilder stringBuilder = new StringBuilder();
@Benchmark
public String stringAdd() {for (int i = 0; i < 1000; i++) {string = string + i;}
return string;
}
@Benchmark
public String stringBuilderAppend() {for (int i = 0; i < 1000; i++) {stringBuilder.append(i);
}
return stringBuilder.toString();}
public static void main(String[] args) throws RunnerException {Options opt = new OptionsBuilder()
.include(JmhHello.class.getSimpleName())
.build();
new Runner(opt).run();}
}
代码很简略,不做解释,stringAdd
应用加号拼接字符串 1000 次,stringBuilderAppend
应用 append
拼接字符串 1000 次。间接运行 main 办法,稍等片刻后能够失去具体的运行输入后果。
// 开始测试 stringAdd 办法
# JMH version: 1.23
# VM version: JDK 1.8.0_181, Java HotSpot(TM) 64-Bit Server VM, 25.181-b13
# VM invoker: D:\develop\Java\jdk8_181\jre\bin\java.exe
# VM options: -javaagent:C:\ideaIU-2020.1.3.win\lib\idea_rt.jar=50363:C:\ideaIU-2020.1.3.win\bin -Dfile.encoding=UTF-8
# Warmup: 3 iterations, 10 s each // 预热运行三次
# Measurement: 5 iterations, 10 s each // 性能测试 5 次
# Timeout: 10 min per iteration // 超时工夫 10 分钟
# Threads: 1 thread, will synchronize iterations // 线程数量为 1
# Benchmark mode: Average time, time/op // 统计办法调用一次的均匀工夫
# Benchmark: net.codingme.jmh.JmhHello.stringAdd // 本次执行的办法
# Run progress: 0.00% complete, ETA 00:02:40
# Fork: 1 of 1
# Warmup Iteration 1: 95.153 ms/op // 第一次预热,耗时 95ms
# Warmup Iteration 2: 108.927 ms/op // 第二次预热,耗时 108ms
# Warmup Iteration 3: 167.760 ms/op // 第三次预热,耗时 167ms
Iteration 1: 198.897 ms/op // 执行五次耗时度量
Iteration 2: 243.437 ms/op
Iteration 3: 271.171 ms/op
Iteration 4: 295.636 ms/op
Iteration 5: 327.822 ms/op
Result "net.codingme.jmh.JmhHello.stringAdd":
267.393 ±(99.9%) 189.907 ms/op [Average]
(min, avg, max) = (198.897, 267.393, 327.822), stdev = 49.318 // 执行的最小、均匀、最大、误差值
CI (99.9%): [77.486, 457.299] (assumes normal distribution)
// 开始测试 stringBuilderAppend 办法
# Benchmark: net.codingme.jmh.JmhHello.stringBuilderAppend
# Run progress: 50.00% complete, ETA 00:01:21
# Fork: 1 of 1
# Warmup Iteration 1: 1.872 ms/op
# Warmup Iteration 2: 4.491 ms/op
# Warmup Iteration 3: 5.866 ms/op
Iteration 1: 6.936 ms/op
Iteration 2: 8.465 ms/op
Iteration 3: 8.925 ms/op
Iteration 4: 9.766 ms/op
Iteration 5: 10.143 ms/op
Result "net.codingme.jmh.JmhHello.stringBuilderAppend":
8.847 ±(99.9%) 4.844 ms/op [Average]
(min, avg, max) = (6.936, 8.847, 10.143), stdev = 1.258
CI (99.9%): [4.003, 13.691] (assumes normal distribution)
# Run complete. Total time: 00:02:42
REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on
why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial
experiments, perform baseline and negative tests that provide experimental control, make sure
the benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts.
Do not assume the numbers tell you what you want them to tell.
// 测试后果比照
Benchmark Mode Cnt Score Error Units
JmhHello.stringAdd avgt 5 267.393 ± 189.907 ms/op
JmhHello.stringBuilderAppend avgt 5 8.847 ± 4.844 ms/op
Process finished with exit code 0
下面日志里的 //
正文是我手动减少下来的,其实咱们只须要看上面的最终后果就能够了,能够看到 stringAdd
办法均匀耗时 267.393ms,而 stringBuilderAppend
办法均匀耗时只有 8.847ms,可见 StringBuilder
的 append
办法进行字符串拼接速度快的多,这也是咱们举荐应用 append
进行字符串拼接的起因。
注解阐明
通过下面的示例,想必你也能够疾速的应用 JMH 进行基准测试了,不过下面的诸多注解你可能还有纳闷,上面一一介绍。
类上 应用了六个注解。
@BenchmarkMode(Mode.AverageTime)
@State(Scope.Thread)
@Fork(1)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3)
@Measurement(iterations = 5)
@BenchmarkMode(Mode.AverageTime) 示意统计均匀响应工夫,不仅能够用在类上,也可用在 测试方法 上。
除此之外还能够取值:
- Throughput:统计单位工夫内能够对办法测试多少次。
- SampleTime:统计每个响应工夫范畴内的响应次数,比方 0-1ms,3 次;1-2ms,5 次。
- SingleShotTime:跳过预热阶段,间接进行 一次 微基准 测试。
@State(Scope.Thread):每个进行基准测试的线程都会独享一个对象示例。
除此之外还能取值:
- Benchmark:多线程共享一个示例。
- Group:线程组共享一个示例,在测试方法上应用 @Group 设置线程组。
@Fork(1):示意开启一个线程进行测试。
**OutputTimeUnit(TimeUnit.MILLISECONDS):输入的工夫单位,这里写的是毫秒。
@Warmup(iterations = 3):微基准测试前进行三次预热执行,也可用在 测试方法 上。
@Measurement(iterations = 5):进行 5 次微基准测试,也可用在 测试方法 上。
在两个测试方法上只应用了一个注解 @Benchmark,这个注解示意这个办法是要进行基准测试的办法,它相似于 Junit 中的 @Test 注解。下面还提到某些注解还能够用到测试方法上,也就是应用了 @Benchmark 的办法之上,如果类上和测试方法同时存在注解,会以 办法上的注解 为准。
其实 JMH 也能够把这些参数间接在 main 办法中指定,这时 main 办法中指定的级别最高。
public static void main(String[] args) throws RunnerException {Options opt = new OptionsBuilder()
.include(JmhHello.class.getSimpleName())
.forks(1)
.warmupIterations(5)
.measurementIterations(10)
.build();
new Runner(opt).run();}
正确的微基准测试
如果编写的代码自身就存在着诸多问题,那么即便应用正确的测试方法,也不可能失去正确的测试后果。这些测试代码中的问题应该由咱们进行被动防止,那么有哪些常见问题呢?上面介绍两种最常见的状况。
无用代码打消(Dead Code Elimination)
也有网友形象的翻译成 死代码,死代码是指那些 JVM 通过查看发现的基本不会应用到的代码。比方上面这个代码片段。
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
/**
* <p>
* 测试死代码打消
*
* @author niujinpeng
* @Date 2020/8/21 8:04
*/
@BenchmarkMode(Mode.AverageTime)
@State(Scope.Thread)
@Fork(1)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 5, time = 3)
public class JmhDCE {
@Benchmark
public double test1() {return Math.log(Math.PI);
}
@Benchmark
public void test2() {double result = Math.log(Math.PI);
result = Math.log(result);
}
public static void main(String[] args) throws RunnerException {Options opt = new OptionsBuilder()
.include(JmhDCE.class.getSimpleName())
.build();
new Runner(opt).run();}
}
在这个代码片段里里,test1
办法对圆周率进行对数计算,并返回计算结果;而 test2
中不仅对圆周率进行对数计算,还对计算的后果再次对数计算,看起来简单一些,然而因为没有用到计算结果,所以 JVM 会主动打消这段代码,因为它没有任何意义。
Benchmark Mode Cnt Score Error Units
JmhDCE.test1 avgt 5 0.002 ± 0.001 us/op
JmhDCE.test2 avgt 5 ≈ 10⁻⁴ us/op
测试后果里也能够看到 test
均匀耗时 0.0004 微秒,而 test1
均匀耗时 0.002 微秒。
常量折叠(Constant Folding)
在对 Java 源文件编译的过程中,编译器通过语法分析,能够发现某些能间接失去计算结果而不会再次更改的代码,而后会将计算结果记录下来,这样在执行的过程中就不须要再次运算了。比方这段代码。
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
/**
* <p>
* 测试常量折叠
*
* @author niujinpeng
* @Date 2020/8/21 8:23
*/
@BenchmarkMode(Mode.AverageTime)
@State(Scope.Thread)
@Fork(1)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 5, time = 3)
public class JmhConstantFolding {
final double PI1 = 3.14159265358979323846;
double PI2 = 3.14159265358979323846;
@Benchmark
public double test1() {return Math.log(PI1) * Math.log(PI1);
}
@Benchmark
public double test2() {return Math.log(PI2) * Math.log(PI2);
}
public static void main(String[] args) throws RunnerException {Options opt = new OptionsBuilder().include(JmhConstantFolding.class.getSimpleName()).build();
new Runner(opt).run();}
}
test
1 中应用 final
润饰的 PI1 进行对象计算,因为 PI1 不能再次更改,所以 test1
的计算结果必然是不会更改的,所以 JVM 会进行常量折叠优化,而 test2
应用的 PI2
可能会被批改,所以只能每次进行计算。
Benchmark Mode Cnt Score Error Units
JmhConstantFolding.test1 avgt 5 0.002 ± 0.001 us/op
JmhConstantFolding.test2 avgt 5 0.019 ± 0.001 us/op
能够看到 test2
耗时要多的多,达到了 0.019 微秒。
其实 JVM 做的优化操作远不止下面这些,还有比方常量流传(Constant Propagation)、循环展开(Loop Unwinding)、循环表达式外提(Loop Expression Hoisting)、打消公共子表达式(Common Subexpression Elimination)、本块重排序(Basic Block Reordering)、范畴查看打消(Range Check Elimination)等。
总结
JMH 进行基准测试的应用过程并不简单,同为 Java 虚拟机团队开发,准确性毋容置疑。然而在进行基准测试时还是要留神本人的代码问题,如果编写的要进行测试的代码自身存在问题,那么测试的后果必然是不准的。把握了 JMH 基准测试之后,能够尝试测试一些罕用的工具或者框架的性能如何,看看哪个工具的性能最好,比方 FastJSON 真的比 GSON 在进行 JSON 转换时更 Fast 吗?
参考:
- https://www.ibm.com/developer…
- http://hg.openjdk.java.net/co…
- 深刻了解 Java 虚拟机:JVM 高级个性与最佳实际(第 3 版)第 11 章 后端编译与优化
最初的话
文章曾经收录在 Github.com/niumoo/JavaNotes,欢送 Star 和指教。更有一线大厂面试点,Java 程序员须要把握的外围常识等文章,也整顿了很多我的文字,欢送 Star 和欠缺,心愿咱们一起变得优良。
文章有帮忙能够点个「赞 」或「 分享 」,都是反对,我都喜爱!
文章每周继续更新,要实时关注我更新的文章以及分享的干货,能够关注「未读代码」公众号或者我的博客。