JMH 性能测试,试试你代码的性能如何
看到这里你可能会有些疑惑,Java 程序不是在启动之前都编译成了统一的字节码么,难道在字节码翻译为机器代码的过程中还有什么不为人知的优化处理手段?
下边我们来观察这么一段测试程序:
public?static?void?testStringAdd()?{??
long?begin?=?System.currentTimeMillis();??
String?item?=?new?String();??
for?(int?i?=?0;?i?<?100000;?i++)?{??
itemitem?=?item?+?"-";??
}??
long?end?=?System.currentTimeMillis();??
System.out.println("String?耗时:"?+?(end?-?begin)?+?"ms");??
}??
//循环 20 次执行同一个方法??
public?static?void?main(String[]?args)?{??
for(int?i=0;i<20;i++){??
testStringAdd();??
}??
}?
执行的程序耗时打印在了控制台上:
20 次的重复调用之后,发现首次和最后一次调用几乎存在 5 倍的差异。看来代码运行越跑越快是存在的了,但是为什么会有这种现象发生呢?
这里我们需要了解一项叫做 JIT 的技术。
JIT 技术
在介绍 JIT 技术之前,需要先进行些相关知识的补充铺垫。
解释型语言
解释型语言,是在运行的时候才将程序翻译成 机器语言 。解释型语言的程序不需要在运行前提前做编译工作,在运行程序的时候才翻译,解释器负责在每个语句执行的时候解释程序代码。这样解释型语言每执行一次就要“翻译”一次,效率比较低。代表语言:PHP。
编译型语言
在程序执行之前,提前就将程序编译成机器代码,这样后续机器在运行的时候就不需要额外去做翻译的工作,效率会相对较高。语言代表:C,C++。
而我们本文重点研究的是 Java 语言,我个人认为这是一门既具备解释特点又具备编译特点的高级语言。
JVM 是 Java 一次编译,跨平台执行的基础。当 Java 被编译为字节码形式的.class 文件之后,他可以在任意的 JVM 上运行。
PS: 这里说的编译,主要是指前端编译器。
前端编译器
将.java 文件编译为 JVM 可执行的.class 字节码文件,即 javac,主要职责包括:词法、语法分析,填充符号表,语义分析,字节码生成。输出为字节码文件,也可以理解为是中间表达形式(称为 IR:Intermediate Representation)。这时候的编译结果就是我们常见的 xxx.class 文件。
后端编译器
在程序运行期间将字节码转变成机器码,通过前端编译器和后端编译器的组合使用,通常就是被我们称之为混合模式,如 HotSpot 虚拟机自带的解释器还有 JIT(Just In Time Compiler)编译器(分 Client 端和 Server 端),其中 JIT 还会将中间表达形式进行一些优化。
所以一份 xxx.java 的文件实际在执行过程中会按照如下流程执行,首先经过前端解释器转换为.class 格式的字节码,再通过后端编译器将其解释为机器能够识别的机器代码。最后再由机器去执行计算。
真的就这么简单吗?
还记得我在上边贴出的那段测试代码吗,首次执行和最后执行的性能差异如此巨大,其实是在后端编译器处理的过程中加入优化的手段。
在编译时,主要是将 java 源代码文件编译为统一的字节码,但是编译成的字节码并不能直接运行,而是需要通过 JVM 读取运行。JVM 中的后端解释器就是将.class 文件一行一行翻译之后再运行,翻译就是转换成当前机器可以运行的机器码,它不会一次性把整个文件都翻译过来,而是翻译一句,执行一句,再翻译,再执行,所以解释器的程序运行起来会比较慢,每次都要解释之后再执行。所以有些时候,我们想是否可以把解释之后的内容缓存起来,这样不就可以直接运行了?但是,如果每段代码都要缓存起来,例如仅仅执行一次的代码也缓存起来,这样太浪费内存了。所以,引入一个新的运行时编译器,JIT 来解决这些问题,加速热点代码的执行。
引入 JIT 技术之后,代码的执行过程是怎样的?
在引入了 JIT 技术之后,一份 Java 程序的代码执行流程就会变成了下边这种类型。首先通过前端编译器转变为字节码文件,然后再判断对应的字节码文件是否有被提前处理好存放在 code cache 中。如果有则可以直接执行对应的机器代码,如果没有则需要进行判断是否有必要进行 JIT 技术优化(判断逻辑的细节后边会讲),如果有必要优化,则会将优化后的机器码也存放到 code cache 中,否则则是会一边执行一边翻译为机器代码。
怎样的代码才会被识别为热点代码呢?
在 JVM 中会设置一个阈值,当某段代码块在一定时间内被执行的次数超过了这个阈值,则会被存放进 code cache 中。
如何验证:
建立一个测试用的代码 Demo,然后设置 JVM 参数:
-XX:CompileThreshold=500 -XX:+PrintCompilation
public?class?TestCountDemo?{??
public?static?void?test()?{??
int?a?=?0;??
}??
public?static?void?main(String[]?args)?throws?InterruptedException?{??
for?(int?i?=?0;?i?<?600;?i++)?{??
test();??
}??
TimeUnit.SECONDS.sleep(1);??
}??
}?
接下来专心观察启动程序之后的编译信息记录:
截图解释:
第一列 693 表示系统启动到编译完成时的毫秒数。
第二列 43 表示编译任务的内部 ID,一般是一个自增的值。
第三列为空,描述代码状态的 5 个属性。
?%:是一个 OSR(栈上替换)。
?s:是一个同步方法。
?!:方法有异常处理块。
?b:阻塞模式编译。
?n:是本地方法的一个包装。
第四列 3 表示编译级别,0 表示没有编译而是使用解释器,1,2,3 表示使用 C1 编译器(client),4 表示使用 C2 编译器(server),级别越高编译生成的机器码质量越好,编译耗时也越长。
最后一列表示了方法的全限定名和方法的字节码长度。
从实验来看,当 for 循环的次数一旦超过了预期设置的阈值,则会提前使用后端编译器将代码缓存到 code cache 中。
即时编译极大地提高了 Java 程序的运行速度,而且跟静态编译相比,即时编译器可以选择性地编译热点代码,省去了很多编译时间,也节省很多的空间。目前,即时编译器已经非常成熟了,在性能层面甚至可以和编译型语言相比。不过在这个领域,大家依然在不断探索如何结合不同的编译方式,使用更加智能的手段来提升程序的运行速度。
还记得我在文章开头所提出的几个问题吗~~既然我们了解了 Jvm 底层具备了这些优化的技能,那么如何才能更加准确高效地去检测一段程序的性能呢?
基于 JMH 来实践代码基准测试
JMH 是 Java Microbenchmark Harness 的简称,一个针对 Java 做基准测试的工具,是由开发 JVM 的那群人开发的。想准确的对一段代码做基准性能测试并不容易,因为 JVM 层面在编译期、运行时对代码做很多优化,但是当代码块处于整个系统中运行时这些优化并不一定会生效,从而产生错误的基准测试结果,而这个问题就是 JMH 要解决的。
关于如何使用 JMH 在网上有很多的讲解案例,这些入门的资料大家可以自行去搜索。本文主要讲解在使用 JMH 测试的时候需要注意到的一些细节点:
常用的基本注解以及其具体含义
一般我们会将测试所使用的注解都标注在测试类的头部,常用到的测试注解有以下几种:
/**??
*?吞吐量测试?可以获取到指定时间内的吞吐量??
*??
*?Throughput?可以获取一秒内可以执行多少次调用??
*?AverageTime?可以获取每次调用所消耗的平均时间??
*?SampleTime?随机抽样,随机抽取结果的分布,最终是 99%%的请求在 xx 秒内??
*?SingleShotTime?只允许一次,一般用于测试冷启动的性能??
*/??
@BenchmarkMode(Mode.Throughput)?
/**??
*?如果一段程序被调用了好几次,那么机器就会对其进行预热操作,??
*?为什么需要预热?因为?JVM?的?JIT?机制的存在,如果某个函数被调用多次之后,JVM?会尝试将其编译成为机器码从而提高执行速度。所以为了让?benchmark?的结果更加接近真实情况就需要进行预热。??
*/??
@Warmup(iterations?=?3)??
/**??
*?iterations?每次测试的轮次??
*?time?每轮进行的时间长度??
*?timeUnit?时长单位??
*/??
@Measurement(iterations?=?10,?time?=?5,?timeUnit?=?TimeUnit.SECONDS)??
/**??
*?测试的线程数,一般是 cpu*2??
*/??
@Threads(8)??
/**??
*?fork 多少个进程出来测试??
*/??
@Fork(2)??
/**??
*?这个比较简单了,基准测试结果的时间类型。一般选择秒、毫秒、微秒。??
*/??
@OutputTimeUnit(TimeUnit.MILLISECONDS)?
如果不喜欢使用注解的方式也可以通过在启动入口中通过硬编码的形式设置:
public?static?void?main(String[]?args)?throws?RunnerException?{??
//配置进行 2 轮热数?测试 2 轮?1 个线程??
//预热的原因?是 JVM 在代码执行多次会有优化??
Options?options?=?new?OptionsBuilder().warmupIterations(2).measurementBatchSize(2)??
.forks(1).build();??
new?Runner(options).run();??
}?
如果要对某项方法进行 JMH 测试的话,通常会对该方法的头部加入 @Benchmark 注解。例如下边这段:
@Benchmark??
public?String?testJdkProxy()?throws?Throwable?{??
String?content?=?dataService.sendData("test");??
return?content;??
}?
JMH 的一些坑
所有方法都应该要有返回值
例如这么一段测试案例:
package?org.idea.qiyu.framework.jmh.demo;??
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;??
import?java.util.concurrent.TimeUnit;??
import?static?org.openjdk.jmh.annotations.Mode.AverageTime;??
import?static?org.openjdk.jmh.annotations.Mode.Throughput;??
/**??
*?JMH 基准测试??
*/??
@BenchmarkMode(Throughput)??
@Fork(2)??
@Warmup(iterations?=?4)??
@Threads(4)??
@OutputTimeUnit(TimeUnit.MILLISECONDS)??
public?class?JMHHelloWord?{?
@Benchmark??
public?void?baseMethod()?{??
}??
@Benchmark??
public?void?measureWrong()?{??
String?item?=?"";??
itemitem?=?item?+?"s";??
}??
@Benchmark??
public?Strin 《一线大厂 Java 面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义》无偿开源 威信搜索公众号【编程进阶路】 g?measureRight()?{??
String?item?=?"";??
itemitem?=?item?+?"s";??
return?item;??
}??
public?static?void?main(String[]?args)?throws?RunnerException?{??
Options?options?=?new?OptionsBuilder().??
include(JMHHelloWord.class.getName()).??
build();??
new?Runner(options).run();??
}??
}?
其实 baseMethod 和 measureWrong 两个方法从代码功能角度看来,并没有什么区别,因为调用它们两者对于调用方本身并没有造成什么影响,而且 measureWrong 函数中还存在着无用代码块,所以 JMH 会对内部的代码进行“死码消除”的处理。
通过测试会发现,其实 baseMethod 和 measureWrong 的吞吐性结果差别不大。反而再比对 measureWrong 和 measureRight 两个方法,后者只是加入了一个 return 关键字,JMH 就能很好地去测算它的整体性能。
关于什么是“死码消除”,我在这里贴出一段维基百科上的介绍,感兴趣的读者可以自行前往阅读:
[https://zh.wikipedia.org/wiki/%E6%AD%BB%E7%A2%BC%E5%88%AA%E9%99%A4](()
不要在 Benchmark 内部加入循环的代码
关于这一点我们可以通过一段案例来进行测试,代码如下:
评论