最近在研究一些基础组件的实现时,遇到一个问题,如何实现不同技术的性能对比。什么是性能比较?举个简单的栗子🌰举个例子:假设我们需要验证String、StringBuffer、StringBuilder的使用,希望通过一些测试来比较它们的性能开销。下面我列出一个最简单的测试思路:for循环比较这个测试思路的特点:简单直接;i++){itemitem=item+"-";}longend=System.currentTimeMillis();System.out.println("StringBuffer需要时间:"+(end-begin)+"ms");}publicstaticvoidtestStringBufferAdd(){longbegin=System.currentTimeMillis();StringBufferitem=newStringBuffer();for(inti=0;i<100000;i++){itemitem=item.append("-");}longend=System.currentTimeMillis();System.out。println("StringBuffer耗时:"+(end-begin)+"ms");}publicstaticvoidtestStringBuilderAdd(){longbegin=System.currentTimeMillis();StringBuilderitem=newStringBuilder();for(inti=0;i<100000;i++){itemitem=item.append("-");}longend=System.currentTimeMillis();System.out.println("StringBuilder耗时:"+(end-begin)+"ms");}publicstaticvoidmain(String[]args){testStringAdd();testStringBufferAdd();testStringBuilderAdd();}}不知道大家在日常工作中是否经常这样做,虽然我们确实可以通过简单的for循环执行来更好的判断谁强谁弱,但是比较结果不准确,因为Java程序的运行时间可能会越来越快!代码运行的越来越快看到这里,你可能有点懵了。Java程序在启动前不是编译成统一的字节码吗?字节码翻译成机器码的过程中有什么不为人知的地方吗?最佳处理方式?我们观察这样一个测试程序:currentTimeMillis();System.out.println("String耗时:"+(end-begin)+"ms");}//循环20次执行同一个方法publicstaticvoidmain(String[]args){for(inti=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:IntermediateRepresentation)。此时的编译结果就是我们常见的xxx.class文件。后端编译器在程序运行过程中将字节码转换为机器码,通过前端编译器和后端编译器的组合使用,也就是通常我们所说的混合模式,比如解释器HotSpot虚拟机附带的。有JIT(JustInTimeCompiler)编译器(细分为client和server),JIT也会对中间表达式进行优化。所以一个xxx.java文件在执行过程中实际上会按照下面的流程执行。首先,它会被前端解释器转换成.class格式的字节码,然后被后端编译器解释为机器可以识别的机器码。.最后由机器进行计算。真的那么简单吗?还记得我上面贴的测试代码吗?第一次执行和最后一次执行之间的性能差异是如此巨大。其实就是在后端编译器的处理过程中加入的一种优化方法。编译时,主要是将java源代码文件编译成统一的字节码,但是编译后的字节码不能直接运行,需要JVM读取运行。JVM中的后端解释器就是把.class文件逐行翻译后运行。翻译就是将当前机器可以运行的机器码进行转换。它不会一次翻译整个文件,而是翻译一个句子,执行一个句子,然后翻译,然后执行,所以解释器的程序运行起来会比较慢,而且每次都要先解释再执行。所以有的时候,我们会想,能不能把解释完后的内容缓存起来,让我们直接运行呢?但是,如果每段代码都要缓存,比如把只执行一次的代码也缓存起来,这是一种内存浪费。因此,引入了一种新的运行时编译器JIT来解决这些问题并加快热代码的执行速度。引入JIT技术后,代码执行过程是怎样的呢?引入JIT技术后,一个Java程序的代码执行流程会变成下面这种类型。首先由前端编译器转换成字节码文件,然后判断对应的字节码文件是否已经预先处理过并存入代码缓存中。如果是,则直接执行对应的机器码即可。如果不是,则需要判断是否需要优化JIT技术(具体判断逻辑后面会讲到)。如果需要优化,优化后的机器码也会存储在代码缓存中,否则会在执行时翻译成机器码。什么样的代码会被识别为热点代码?JVM中设置了一个阈值。当某个代码块在一定时间内被执行超过这个阈值时,就会被存入代码缓存中。验证方法:创建代码demo进行测试,然后设置JVM参数:-XX:CompileThreshold=500-XX:+PrintCompilationpublicclassTestCountDemo{publicstaticvoidtest(){inta=0;}publicstaticvoidmain(String[]args)throwsInterruptedException{for(inti=0;i<600;i++){test();}TimeUnit.SECONDS.sleep(1);}}接下来集中观察程序启动后的编译信息记录:截图说明:第一列693表示系统在几毫秒内开始编译。第二列43表示编译任务的内部ID,一般为自增值。第三列为空,描述了代码状态的5个属性。%:是一个OSR(堆栈替换)。s:是一种同步方法。!:该方法有一个异常处理块。b:以阻塞模式编译。n:是本地方法的包装器。第四列3表示编译级别,0表示不编译但使用解释器,1、2、3表示使用C1编译器(客户端),4表示使用C2编译器(服务器),越高level是,编译生成的机器码质量越好,编译时间越长。最后一列表示方法的完全限定名称和方法的字节码长度。从实验来看,一旦for循环次数超过预期的阈值,后端编译器会提前将代码缓存到codecache中。即时编译大大提高了Java程序的运行速度。与静态编译相比,即时编译器可以选择性地编译热点代码,节省大量的编译时间和空间。目前,即时编译器已经非常成熟,在性能上甚至可以与编译型语言相媲美。但是,在这个领域里,大家还在探索如何结合不同的编译方式,用更智能的手段来提高程序的运行速度。还记得我在文章开头提出的几个问题吗~~既然了解了Jvm底层有这些优化技巧,那么如何才能更准确、更高效地检测程序的性能呢?基于JMH实践代码基准测试JMH是JavaMicrobenchmarkHarness的缩写,是Java基准测试工具,由开发JVM的一群人开发。准确地对一段代码进行基准测试并不容易,因为JVM级别在编译时和运行时对代码进行了优化,但是这些优化并不一定在代码块在整个系统运行时生效,从而导致错误的基准测试结果的,而这个问题就是要JMH来解决的。关于如何使用JMH,网上有很多讲解案例。您可以自行搜索这些介绍资料。本文主要讲解使用JMH测试时需要注意的一些细节:常用的基本注解及其具体含义。一般我们会在测试类的头部标记测试中使用的注解。常用的测试注解有以下几种:/***Throughput测试可以得到指定时间内的吞吐量**Throughput可以得到一秒内可以执行多少次调用*AverageTime可以得到每次调用平均消耗的时间*SampleTime随机采样,随机抽取结果的分布,最终99%%的请求都在xx秒内*SingleShotTime只允许一次,一般用来测试冷启动的性能*/@BenchmarkMode(Mode.Throughput)/***如果一个程序被多次调用,那么机器会预热它,*为什么要预热?由于JVM的JIT机制,如果一个函数被多次调用,JVM会尝试将其编译成机器码,以提高执行速度。因此,为了让基准测试结果更接近真实情况,需要进行预热。*/@Warmup(iterations=3)/***每轮测试的迭代轮次*每轮的时间长度*timeUnit持续时间单位*/@Measurement(iterations=10,time=5,timeUnit=TimeUnit.SECONDS)/***测试的线程数一般是cpu*2*/@Threads(8)/***fork出多少个进程来测试*/@Fork(2)/***这个比较简单,benchmark测试结果的时间类型。一般选择秒、毫秒、微秒。*/@OutputTimeUnit(TimeUnit.MILLISECONDS)如果不喜欢使用注解,也可以在启动项中以硬编码的形式设置:publicstaticvoidmain(String[]args)throwsRunnerException{//配置为2roundsofheattest2rounds1thread//预热的原因是JVM会在代码执行多次时进行优化。Optionsoptions=newOptionsBuilder().warmupIterations(2).measurementBatchSize(2).forks(1).build();newRunner(options).run();}如果你想对一个方法进行JMH测试,通常添加@Benchmark注释到方法的头部。比如下面这段:@BenchmarkpublicStringtestJdkProxy()throwsThrowable{Stringcontent=dataService.sendData("test");returncontent;}JMH的一些陷阱的所有方法都应该有返回值。比如这样一个测试用例:packageorg.idea.qiyu.framework.jmh.demo;importorg.openjdk.jmh.annotations.*;importorg.openjdk.jmh.runner.Runner;importorg.openjdk.jmh.runner.RunnerException;importorg.openjdk.jmh.runner.options.Options;importorg.openjdk.jmh.runner.options.OptionsBuilder;importjava.util.concurrent.TimeUnit;importstaticorg.openjdk.jmh.annotations.Mode.AverageTime;importstaticorg.openjdk.jmh。annotations.Mode.Throughput;/***JMHBenchmark*/@BenchmarkMode(Throughput)@Fork(2)@Warmup(iterations=4)@Threads(4)@OutputTimeUnit(TimeUnit.MILLISECONDS)publicclassJMHHelloWord{@BenchmarkpublicvoidbaseMethod(){}@BenchmarkpublicvoidmeasureWrong(){Stringitem="";itemitem=item+"s";}@BenchmarkpublicStringmeasureRight(){Stringitem="";itemitem=item+"s";returnitem;}publicstaticvoidmain(String[]args)throwsRunnerException{Optionsoptions=newOptionsBuilder().include(JMHHelloWord.class.getName()).build();newRunner(options).run();}}其实baseMethod和measureWrong这两个方法从code函数有什么区别,因为调用它们对调用者本身没有影响,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中添加循环代码。我们可以通过一个案例来验证这一点。代码如下:openjdk.jmh.runner.options.Options;importorg.openjdk.jmh.runner.options.OptionsBuilder;importjava.util.concurrent.TimeUnit;/***@Authorlinhao*@Datecreatedin10:20AM2021/12/19*/@BenchmarkMode(Mode.AverageTime)@Fork(1)@Threads(4)@Warmup(iterations=1)@OutputTimeUnit(TimeUnit.MILLISECONDS)publicclassForLoopDemo{publicintreps(intcount){intsum=0;for(inti=0;i
