目录
前言
性能调优一直是工作中很重要的必会技能,如何知晓自己写代码的优劣呢?当然是看代码运行时间,时间越短,说明代码越优。相信在日常中我们常常测试代码性能的方法是在要测量的代码开始位置记录startTime,然后在结尾处记录endTime,如下所示:
/** * 测试样例1 * * @author hubo.mao created on 2023-02-14 12:02 */ public class Example01 { public static void main(String[] args) { int[] arr = new int[1000]; for (int i = 0; i < arr.length; i++) { // 生成随机数 int j = RandomUtil.randomInt(1000); arr[i] = j; } //插入排序 long startTime = System.currentTimeMillis(); for (int i = 0; i < 10000; i++) { InsertSort.sort(arr); } long endTime = System.currentTimeMillis(); System.out.println("插入排序10000次耗时: " + (endTime - startTime) + "ms"); //快速排序 long startTime1 = System.currentTimeMillis(); for (int i = 0; i < 10000; i++) { QuickSort.sort(arr); } long endTime1 = System.currentTimeMillis(); System.out.println("快速排序10000次耗时: " + (endTime1 - startTime1) + "ms"); } } //输出 //插入排序10000次耗时: 20ms //快速排序10000次耗时: 905ms |
这样的统计方式,用在业务代码里,不仅可以统计代码运行时间,还生成响应时间监控便于维护,所以并不见得有什么问题。但是,如果我们更主要的目的是为了评估一段基于方法层面的代码性能测试,使用上述方式是不可靠的,原因有三:
- 首先,在写代码的时候经常有这种怀疑:写法A快还是写法B快,某个位置是用ArrayList还是LinkedList,HashMap还是TreeMap,HashMap的初始化size要不要指定,指定之后究竟比默认的DEFAULT_SIZE性能好多少?如果还是通过for循环或者手撸method来测试你的内容的话,无异于重复造轮子。
- 其次,JVM在执行时,会对一些代码块,或者一些频繁执行的逻辑,进行JIT编译和内联优化,在得到一个稳定的测试结果之前,需要先循环上万次 ,进行预热。预热前和预热后的性能差别是非常大的。
- 此外,评估性能有很多的指标。如果这些指标数据,每次都要手工去算的话,那肯定是枯燥乏味且低效的。
- 最后,GC资源回收的不确定性,可能运行很快,回收很慢,同时由于时间精度问题,本身获取到的时间戳也是存在误差的。
所以,突然发现进行一次严格的基准测试的难度大大增加。那么如何才能进行一次严格的基准测试呢?Java虚拟机团队开发开发的JMH(Java Microbenchmark Harness)应运而生。
JMH是什么?
JMH是一个Java工具,用于构建、运行和分析 Java 和其他针对 JVM 的语言编写的代码的纳秒/微秒/秒级的基准测试套件。简单的来说就是基于方法层面的基准测试,精度可以达到微秒级,当你希望进一步优化方法执行性能的时候,就可以使用JMH对优化的结果进行量化的分析。
- 单位换算:1秒(s)=1000000微秒(us)=1000000000纳秒(ns)。
- 基准测试:是指通过设计科学的测试方法、测试工具和测试系统,实现对一类测试对象的某项性能指标进行定量的和可对比的测试。比如鲁大师、安兔兔,都是按一定的基准或者在特定条件下去测试某一对象的的性能,比如显卡、IO、CPU之类的。
JMH的应用场景
- 想定量地知道某个方法需要执行多长时间,以及执行时间和输入 n 的相关性,从而针对性的进行优化。
-
一个接口有两种不同实现(例如实现 A 使用了 FixedThreadPool,实现 B 使用了 ForkJoinPool),不知道哪种实现性能更好,对比接口不同实现在给定条件下的吞吐量。
- 分析性能损耗,在原接口方法业务逻辑中添加新的业务代码时,对整个业务方法的性能影响。如:在原业务逻辑中,添加一个插入操作日志的操作,可以分析新加操作对整个业务方法的性能影响。
-
查看多少百分比的请求在多长时间内完成,即测试方法多次调用时百分比区间内的耗时,如:测试调用某个方法,50%以内的调用耗时是8.2ms/op,90%以内是9.3ms/op,99.99%以内是10.2ms/op,等等。
JMH的依赖导入
JMH是 JDK9自带的,如果你是JDK9之前的版本也可以通过以下依赖导入。在JDK12的源代码中添加了一套基本的微基准测试,使开发人员可以轻松运行现有的微基准测试并创建新的基准测试。
<propert ies> <JMH.version>1.33</JMH.version> </properties> <dependencies> <dependency> <groupId>org.openjdk.JMH</groupId> <artifactId>JMH-core</artifactId> <version>${JMH.version}</version> </dependency> <dependency> <groupId>org.openjdk.JMH</groupId> <artifactId>JMH-generator-annprocess</artifactId> <version>${JMH.version}</version> <scope>provided</scope> </dependency> </dependencies> //这个插件是为了支持预览版,如果有需要的话 <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.0</version> <configuration> <release>12</release> <compilerArgs>--enable-preview</compilerArgs> </configuration> </plugin> </plugins> </build> |
JMH的注解详解
@BenchmarkMode | 基准测试模式。一共有5种可选值:(其实是4种) Mode.Throughput:吞吐量模式,即单位时间内方法的吞吐量 Mode.AverageTime:平均耗时模式,即一定测试次数内方法执行的平均耗时 Mode.SampleTime:随机采样模式,即最终结果为取样结果分布比例 Mode.SingleShotTime:单次执行模式,即只会执行一次(以上的模式通常会有预热、会迭代执行多次,这个模式可用于测试某些特定场景,如冷启动时的性能) Mode.All:即以上模式都执行一遍 ----------------------------------- 用法示例:(benchmark模式为平均耗时模式) @BenchmarkMode(Mode.AverageTime) |
@OutputTimeUnit | 测试结果的时间单位。其值为java.util.concurrent.TimeUnit 枚举中的值,通常用的值是秒、毫秒、微妙(需要注意的是,在不同测试模式下,需要选择合适的时间单位,从而获取更精确的测试结果。) ------------------------------------ 用法示例:(benchmark结果时间单位为毫秒) @OutputTimeUnit(TimeUnit.MILLISECONDS) |
@Benchmark | 基准测试,方法级注解(配置在方法名上)。用于标注需要进行benchmark (基准测试)的方法 ------------------------------------ 用法示例:(方法需要benchmark) @Benchmark |
@Warmup | 预热参数。配置预热的相关参数,参数含义是:iterations(预热次数)、time (预热时间)、timeUnit (时间单位) ------------------------------------ 用法示例:(预热10次,每次20s) @Warmup(iterations = 10, time = 20, timeUnit = TimeUnit.SECONDS) |
@Measurement | 度量参数。即benchmark基本参数。参数含义是:iterations(测试次数)、time (每次测试时间)、timeUnit (时间单位) ------------------------------------ 用法示例:(测试5次,每次30s) @Measurement(iterations = 5, time = 30, timeUnit = TimeUnit.SECONDS) |
@Fork | 分叉,即进程数。用于配置将使用多少个进程进行测试 ------------------------------------ 用法示例:(使用3个进程) @Fork(3) |
@Threads | 线程数。每个Fork(进程)中的线程数,一般可设为测试机器cpu核心数。 ------------------------------------ 用法示例:(使用4个线程) @Threads(4) |
@Param | 成员参数,属性级注解。用于测试方法在不同入参情况下的性能表现。 ------------------------------------ 用法示例:(入参值依次为1 、10、100) @Param({“1”, “10”, “100”}) |
@Setup | 设置,方法级注解。用于标注benchmark前的操作,通常用于测试前初始化参数资源,如初始化数据库连接等。 ------------------------------------ 用法示例:(初始化方法) @Setup |
@TearDown | 拆卸,方法级注解。用于标注benchmark后的操作,通常用于测试后回收资源,如关闭数据库连接等。 ------------------------------------ 用法示例:(回收方法) @TearDown |
@State | 状态,表示一个类/方法的可用范围,其值有3个: Scope.Thread:默认状态,每个线程分配一个独享的实例; Scope.Benchmark:测试中的所有线程共享实例;(多线程测试情况下) Scope.Group:同一个组的线程共享实例; ------------------------------------ 用法示例:(默认值,每个线程分配一个实例) @State(Scope.Thread) |
@Group | 测试组,方法级注解。适用分组测试,每组线程数不一样的场景。 ------------------------------------ 用法示例:(组名为“group_name”的一个组) @Group(“group_name”) |
@GroupThreads | 组线程数,方法级注解。通常和@Group搭配使用 ------------------------------------ 用法示例:(组线程数为10) @GroupThreads(10) |
@Timeout | 超时时间。每次测试迭代超时时间 ------------------------------------ 用法示例:(每次测试超时时间为20min) @Timeout(time = 20, timeUnit = TimeUnit.MINUTES) |
JMH的使用详解
例一:一个简单例子
/** * 测试样例2:一个简单的例子 * * @author hubo.mao created on 2023-02-14 15:43 */ @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MILLISECONDS) @State(Scope.Thread) public class Example02 { @Benchmark public int testSleep01() { try { Thread.sleep(300); } catch (InterruptedException e) { e.printStackTrace(); } return 0; } public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(Example02.class.getSimpleName()) .forks(1) .warmupIterations(5) .measurementIterations(5) .build(); new Runner(opt).run(); } } |
测试结果详解:
/** * JMH版本 * jvm版本信息 * jvm程序(jdk安装路径) * jvm参数配置 * 预热参数:预热次数、每次持续时间 * 测试参数:测试次数、每次持续时间 * 每次测试迭代超时时间 * 每个测试进程的测试线程数 * 测试的模式 * 测试的方法 */ # JMH version: 1.33 # VM version: JDK 17.0.6, OpenJDK 64-Bit Server VM, 17.0.6+10-LTS # VM invoker: C:\Users\hubo.mao\.jdks\corretto-17.0.6\bin\java.exe # VM options: -javaagent:E:\Program File\IntelliJ IDEA 2022.1.3\lib\idea_rt.jar=11449:E:\Program File\IntelliJ IDEA 2022.1.3\bin -Dfile.encoding=UTF-8 # Blackhole mode: full + dont-inline hint (default, use -DJMH.blackhole.autoDetect=true to auto-detect) # Warmup: 5 iterations, 10 s each # Measurement: 5 iterations, 10 s each # Timeout: 10 min per iteration # Threads: 1 thread, will synchronize iterations # Benchmark mode: Average time, time/op # Benchmark: huob.mao.demoforJMH.example.Example02.testSleep01 /** * 当前第几次fork */ # Run progress: 0.00% complete, ETA 00:01:40 # Fork: 1 of 1 /** * 预热执行,每次预热执行耗时,总共预热5次 */ # Warmup Iteration 1: 311.723 ms/op # Warmup Iteration 2: 311.411 ms/op # Warmup Iteration 3: 311.650 ms/op # Warmup Iteration 4: 312.217 ms/op # Warmup Iteration 5: 312.776 ms/op /** * 反复执行,每次执行耗时,总共执行5次 */ Iteration 1: 311.409 ms/op Iteration 2: 312.607 ms/op Iteration 3: 310.553 ms/op Iteration 4: 310.750 ms/op Iteration 5: 310.904 ms/op /** * 测试结果:包括测试的方法、平均耗时[平局耗时的比例]、最大最小 耗时、测试结果数据离散度(stdev)等 * 测试总耗时 */ Result "huob.mao.demoforJMH.example.Example02.testSleep01": 311.245 ±(99.9%) 3.176 ms/op [Average] (min, avg, max) = (310.553, 311.245, 312.607), stdev = 0.825 CI (99.9%): [308.069, 314.420] (assumes normal distribution) # Run complete. Total time: 00:01:43 /** * 测试结论:测试的方法、测试类型(Mode)、测试总次数(Cnt)、测试结果(Score)、误差(Error)、单位(Units) */ Benchmark Mode Cnt Score Error Units Example02.testSleep01 avgt 5 311.245 ± 3.176 ms/op Process finished with exit code 0 |
上述结果中,被注释的内容是后期手动加上去的,前面控制台中打印出了很多测试结果辅助信息,通过这些信息我们可以清晰的了解测试的详细过程,至于最终结果,其实我们只需要看下面的测试结论就可以了,可以看到测试次数为5,每次运行时间为311.245ms,误差为±3.176ms。
例二:字符串拼接测试
/** * 测试样例3:字符串拼接测试 * * @author hubo.mao created on 2023-02-14 16:38 */ @BenchmarkMode(Mode.AverageTime) @State(Scope.Thread) @Fork(1) @OutputTimeUnit(TimeUnit.MILLISECONDS) @Warmup(iterations = 3) @Measurement(iterations = 5) public class Example03 { String string = ""; StringBuilder stringBuilder = new StringBuilder(); StringBuffer stringBuffer = new StringBuffer(); //使用+号拼接 @Benchmark public String stringAdd() { for (int i = 0; i < 1000; i++) { string = string + i; } return string; } //使用stringBuilder拼接 @Benchmark public String stringBuilderAppend() { for (int i = 0; i < 1000; i++) { stringBuilder.append(i); } return stringBuilder.toString(); } //使用stringBuffer拼接 @Benchmark public String stringBufferAppend() { for (int i = 0; i < 1000; i++) { stringBuffer.append(i); } return stringBuffer.toString(); } public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(Example03.class.getSimpleName()) .build(); new Runner(opt).run(); } } |
测试结果:
Benchmark Mode Cnt Score Error Units Example03.stringAdd avgt 5 188.821 ± 143.508 ms/op Example03.stringBufferAppend avgt 5 6.168 ± 3.454 ms/op Example03.stringBuilderAppend avgt 5 6.182 ± 3.537 ms/op |
从测试结果可以看出,通过StringBuilder处理字符串的方式比直接用+号相加的方式性能都要强一些,StringBuilder与StringBuffer性能差不多,所以在实际开发中,使用StringBuilder与StringBuffer连接字符串是很有必要的。
例三:Json序列化测试
/** * 测试样例3:序列化Json测试 * * @author hubo.mao created on 2023-02-14 17:14 */ @OutputTimeUnit(TimeUnit.MILLISECONDS) @BenchmarkMode(Mode.SingleShotTime) @Warmup(iterations = 5) @Measurement(iterations = 1) @State(Scope.Benchmark) @Fork(1) public class Example04 { public static void main(String[] args) throws Exception{ Options options = new OptionsBuilder() .include(Example04.class.getName()) .build(); new Runner(options).run(); } /** * 序列化次数 */ @Param({"100", "10000", "1000000"}) private int number; private UserInfo userinfo; private String fastjson_jsonStr; private String gson_jsonStr; private String jackson_jsonStr; private Gson gson; ObjectMapper objectMapper; /** * fastjson bean2Json */ @Benchmark public void fastjson_bean2Json(Blackhole bh){ for (int i=0;i<number;i++){ bh.consume(JSON.toJSONString(userinfo)); } } /** * gson bean2Json */ @Benchmark public void gson_bean2Json(Blackhole bh){ for (int i=0;i<number;i++){ bh.consume(gson.toJson(userinfo)); } } /** * jackson bean2Json */ @Benchmark public void jackson_bean2Json(Blackhole bh) throws JsonProcessingException { for (int i=0;i<number;i++){ bh.consume(objectMapper.writeValueAsString(userinfo)); } } /** * fastjson json2Bean */ @Benchmark public void fastjson_json2Bean(Blackhole bh){ for (int i=0;i<number;i++){ bh.consume(JSON.parseObject(fastjson_jsonStr,UserInfo.class)); } } /** * gson json2Bean */ @Benchmark public void gson_json2Bean(Blackhole bh){ for (int i=0;i<number;i++){ bh.consume(gson.fromJson(gson_jsonStr,UserInfo.class)); } } /** * jackson json2Bean */ @Benchmark public void jackson_json2Bean(Blackhole bh) throws JsonProcessingException { for (int i=0;i<number;i++){ bh.consume(objectMapper.readValue(jackson_jsonStr,UserInfo.class)); } } /** * 初始化参数 */ @Setup public void init() throws JsonProcessingException { userinfo = new UserInfo("hubo.mao", "man", 18); fastjson_jsonStr = JSON.toJSONString(userinfo); gson= new Gson(); gson_jsonStr = gson.toJson(userinfo); objectMapper = new ObjectMapper(); jackson_jsonStr = objectMapper.writeValueAsString(userinfo); } /** * 用户信息 * * @author hubo.mao created on 2023-02-14 17:57 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public static class UserInfo { String UserName; String Gender; int age; } } /** * record是JDK14中引入的一个新特性,可以快速构建对象 * 本来想用record关键字构造对象,但是record生成的对象没有默认构造函数,导致fastjson报错。 *其次record生成对象的每个字段是private并且是final的,导致gson报错 * * record反编译结果: * record UserInfo(String UserName, String Gender, int Age) { * UserInfo(String UserName, String Gender, int Age) { * this.UserName = UserName; * this.Gender = Gender; * this.Age = Age; * } * * public String UserName() { * return this.UserName; * } * * public String Gender() { * return this.Gender; * } * * public int Age() { * return this.Age; * } * } */ //record UserInfo(String UserName, String Gender, int Age){} |
测试结果:
Benchmark (number) Mode Cnt Score Error Units //fastjson序列化 Example04.fastjson_bean2Json 100 ss 0.793 ms/op Example04.fastjson_bean2Json 10000 ss 4.023 ms/op Example04.fastjson_bean2Json 1000000 ss 78.802 ms/op //fastjson反序列化 Example04.fastjson_json2Bean 100 ss 0.559 ms/op Example04.fastjson_json2Bean 10000 ss 3.333 ms/op Example04.fastjson_json2Bean 1000000 ss 90.035 ms/op //gson序列化 Example04.gson_bean2Json 100 ss 0.581 ms/op Example04.gson_bean2Json 10000 ss 10.993 ms/op Example04.gson_bean2Json 1000000 ss 566.084 ms/op //gson反序列化 Example04.gson_json2Bean 100 ss 1.031 ms/op Example04.gson_json2Bean 10000 ss 14.279 ms/op Example04.gson_json2Bean 1000000 ss 398.579 ms/op //jackson序列化 Example04.jackson_bean2Json 100 ss 1.316 ms/op Example04.jackson_bean2Json 10000 ss 5.246 ms/op Example04.jackson_bean2Json 1000000 ss 164.387 ms/op //jackson反序列化 Example04.jackson_json2Bean 100 ss 1.451 ms/op Example04.jackson_json2Bean 10000 ss 8.725 ms/op Example04.jackson_json2Bean 1000000 ss 273.166 ms/op |
从测试结果可以看出,不论是序列化还是反序列化,fastjson的性能比gson、jackson都要好一些,当然,jackson性能也很不错(不愧是spring默认的序列化和反序列化工具),尤其是当序列化与反序列化次数较多时,fastjson优势尤其明显。当然,由于用于测试的实体bean数据结构还是较为简单,在一些较为复杂的数据结构场景下,其各自的性能表现可能有所不一样。
例四:失效的基准测试
有些情况下,由于有些代码会被JVM优化掉, 使得基准测试结果不准确,
/** * 测试样例5:失效的基准测试 * * @author hubo.mao created on 2023-02-14 19:06 * * 这个例子展示了一种场景: 有些代码会被JVM优化掉, 使得基准测试结果不准确 * ----------------------------- * baseline(): 空方法 * measureWrong(): 由于计算结果并没有返回, JVM会自动优化, 使其耗时测得与baseLine()一样 * measureRight(): 将计算结果返回了, JVM不会自动优化, 这样才能真正测得真实的执行效率 */ @Warmup(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS) @Measurement(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS) @State(Scope.Thread) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) public class Example05 { private double x = Math.PI; //π private double compute(double d) { for (int c = 0; c < 10; c++) { d = d * d / Math.PI; } return d; } @Benchmark public void baseline() { // 空函数 } /** * 错误的例子, 这个方法会被JIT优化成空方法 */ @Benchmark public void measureWrong() { //因为我没有使用计算结果, JVM会直接把这段代码优化掉, 相当于基准测试了个空方法 compute(x); } /** * 使用Blackhole可以防止代码被优化 */ @Benchmark public void measureWrongWithBh(Blackhole bh) { bh.consume(compute(x)); } @Benchmark public double measureRight() { //正确的做法, 把结果返回, 让JVM认为计算不能省略 return compute(x); } public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(Example05.class.getSimpleName()) .forks(1) //如果是JDK8,注意这里一定要设置server模式(另一为client模式), 以充分利用JIT,我使用的是JDK11 //.jvmArgs("-server") .build(); new Runner(opt).run(); } } |
测试结果:
Benchmark Mode Cnt Score Error Units Example05.baseline avgt 1.132 ns/op Example05.measureRight avgt 19.239 ns/op Example05.measureWrong avgt 0.387 ns/op Example05.measureWrongWithBh avgt 10.291 ns/op |
从结果可以看出,在measureWrong()方法中没有使用计算结果, JVM会直接把这段代码优化掉, 相当于基准测试了个空方法。所以在真正使用JMH的时候要注意这类问题。
例五:测试结果可视化
JMH套件支持测试结果可视化,只需要在启动类上设置参数resultFormat和output,就可以将结果以Json的格式保存到文件中,如下所示:
public static void main(String[] args) throws Exception{ Options options = new OptionsBuilder() .include(Example04.class.getName()) .resultFormat(ResultFormatType.JSON) .output("D:/Coding/demoForJMH/res.json") .build(); new Runner(options).run(); } |
当测试结束后,在指定位置就会生成对应的测试结果json格式的文件:
将文件导入JMH Visualizer网站,就可以得到对应的可视化结果,下图是测试了常用排序算法每次执行的时间:
总结
JMH是一套基本的微基准测试套件,让开发人员可以轻松地运行现有的微基准测试和创建新的微基准测试。在本wiki中,我使用了4个使用样例介绍了部分特性,由于篇幅的原因,就不继续展开,感兴趣的同学可以自行测试,十分有趣。
为什么要使用JMH?
通过以上我给出的例子我们概括出为什么需要JMH
- 方便:使用方便,配置一些注解即可测试,且测量纬度、内置的工具丰富;
- 专业:JMH自动地帮我们避免了一些测试上的“坑”,避免不了的也在例子中给出了说明;
- 准确:预热、fork隔离、避免方法内联、避免常量折叠等很多方法可以提高测量的准确性。
可以使用JMH做链路测试吗?
JMH只适合细粒度的方法测试,并不适用于系统之间的链路测试!如果实在需要测试有外部参数的请求,你得提前准备好输入参数,或者你要有输入参数的定义域,也可以在setup中自己生成。
Jmeter也可以做性能测试,与JMH区别是什么?
很多场景下JMeter和JMH都可以做性能测试,但是对于严格意义上的基准测试来说,只有JMH才适合。JMeter的测试结果精度相对JVM较低、所以JMeter不适合于类级别的基准测试,更适合于对精度要求不高、耗时相对较长的操作。
注意事项
- JMH中的参数配置,许多参数可以直接在main方法的options中设置,也可以通过在类上直接添加注解配置。
- 跑测试的时候要直接用run的方式跑,不要用debug的方式跑,否则会出错。
- 不同的JDK版本测试结果可能不同,如果是JKD8,那么需要开启server模式才能激活代码优化。
参看文档: