测试小站: 处理网 回收帮 培训网 富贵论坛 老富贵论坛

《Java性能优化-掌握JMH》

  1.3 JMH

  1.3.1 使用JMH

  通过手工编写一个性能压测程序有较多的问题

  不同需要性能比较方法放到一个虚拟机里调用,有可能会互相影响。最好的办法是分成俩个独立的进程运行,确保俩个对比方法不相互影响。PerformaceAreaTest启动后直接运行, 缺少预热代过程。虚拟机在执行代码过程中,会加载类,解释执行,以及有可能的优化编译。需要确保虚拟机进行了一定预热运行,以保证测试的公平性,我们在运行PerformaceAreaTest2的时候,能看到第一次循环执行时间总是较长。可以参考第8章了解JIT为了避免环境影响造成的对结果统计不准,我们需要运行多次,取出平均成绩需要从多个纬度统计方法的性能,统计冷启动需要消耗的时间,统计OPS,TP99的功能。

  JMH使用OPS来表示吞吐量,OPS,Opeartion Per Second,是衡量性能的重要指标,指得是每秒操作量。数值越大,性能越好。类似的概念还有TPS,表示每秒的事务完成量,QPS,每秒的查询量。 如果对每次执行时间进行升序排序,取出总数的99%的最大执行时间作为TP99的值,TP99通常是衡量系统性能重要指标,他表示99%的请求的响应时间不超过某个值。比TP99更严格的事TP999,要求99.9%的请求不超过某个值

  有什么工具能帮助我们统计性能优化后的效果,比如更方便的统计OPS,TP99等。同时,我们为了做调优,不必每次都自己写一个测试程序

  JMH,即Java Microbenchmark Harness,是专门用于代码微基准测试的工具套件。主要是基于方法层面的基准测试,精度可以达到纳秒级。当你定位到热点方法,希望进一步优化方法性能的时候,就可以使用JMH对优化的结果进行量化的分析。

  JMH 实现了JSR269规范,即注解处理器,能在编译Java源码的时候,识别的到需要处理的注解,如@Beanmark,JMH能根据@Beanmark的配置生成一系列测试辅助类。关于JSR269,本书11章详细介绍. 流行开源Lombok 基于JSR269规范

  开始是使用JMH,可以在工程里添加对JMH的依赖,添加如下

  

  org.openjdk.jmh

  jmh-core

  ${jmh.version}

  

  

  org.openjdk.jmh

  jmh-generator-annprocess

  ${jmh.version}

  provided

  

  ${jmh.version} 为jmh最新版本,为1.0

  我们编写一个JMH测试类

  @BenchmarkMode(Mode.Throughput)

  @Warmup(iterations=3)

  @Measurement(iterations=3, time=5, timeUnit=TimeUnit.SECONDS)

  @Threads(1)

  @Fork(1)

  @OutputTimeUnit(TimeUnit.SECONDS)

  public class MyBenchmark {

  @Benchmark

  public static void testStringKey(){

  //优化前的代码

  }

  @Benchmark

  public static void testObjectKey(){

  //要测试的优化后代码

  }

  public static void main(String[] args) throws RunnerException {

  Options opt=new OptionsBuilder()

  .include(MyBenchmark.class.getSimpleName())

  .build();

  new Runner(opt)();

  }

  }

  MyBenchmark 有俩个需要比较的方法,都用 @Benchmark注解标识,MyBenchmark用了一系列注解,解释如下

  BenchmarkMode,使用模式,默认是Mode.Throughput,表示吞吐量,其他参数还有AverageTime,表示每次执行时间,SampleTime表示采样时间,SingleShotTime表示只运行一次,用于测试冷启动消耗时间,All表示统计前面的所有指标Warmup 配置预热次数,默认是每次运行1秒,运行10次,我们的例子是运行3次Measurement 配置执行次数,本例是一次运行5秒,总共运行3次。在性能对比时候,采用默认1秒即可,如果我们用jvisualvm做性能监控,我们可以指定一个较长时间运行。Threads 配置同时起多少个线程执行,默认值世 Runtime.getRuntime().availableProcessors(),本例启动1个线程同时执行Fork,代表启动多个单独的进程分别测试每个方法,我们这里指定为每个方法启动一个进程。OutputTimeUnit 统计结果的时间单元,这个例子TimeUnit.SECONDS,我们在运行后会看到输出结果是统计每秒的吞吐量

  我们在MyBenchmark添加需要的测试方法,如下

  static AreaService areaService=new AreaService();

  static PreferAreaService perferAreaService=new PreferAreaService();

  static List data=buildData(20);

  @Benchmark

  public static void testStringKey(){

  areaService.buildArea(data);

  }

  @Benchmark

  public static void testObjectKey(){

  perferAreaService.buildArea(data);

  }

  private static List buildData(int count){

  List list=new ArrayList<>(count);

  for(int i=0;i<count;i++){< p="">

  Area area=new Area(i,i*10);

  list.add(area);

  }

  return list;

  }

  因为MyBenchmark包含了一个main方法,我们可以直接在IDE里直接运行这个方法,有如下输出

  # Warmup: 3 iterations, 1 s each

  # Measurement: 3 iterations, 5 s each

  # Threads: 1 threads, will synchronize iterations

  # Benchmark mode: Throughput, ops/time

  以上输出来自于我们的配置,第一行表示预热3次,每次执行1秒,第二行表示运行3次,每次运行5秒,这部分的运行结果计入统计。第三行表示1个线程执行,第四行统计性能数据纬度是Throughput,吞吐量

  紧接着会运行testObjectKey方法,有如下输出

  # Benchmark: com.ibeetl.code.ch01.test.MyBenchmark.testObjectKey

  # Run progress: 0.00% complete, ETA 00:00:36

  # Fork: 1 of 1

  objc[68658]: Class JavaLaunchHelper is implemented in both /Library/Java/JavaVirtualMachines/jdk1.8.0_45.jdk/Contents/Home/jre/bin/java and /Library/Java/JavaVirtualMachines/jdk1.8.0_45.jdk/Contents/Home/jre/lib/libinstrument.dylib. One of the two will be used. Which one is undefined.

  # Warmup Iteration 1: 1288302.671 ops/s

  # Warmup Iteration 2: 3061587.202 ops/s

  # Warmup Iteration 3: 1094970.828 ops/s

  Iteration 1: 2491836.097 ops/s

  Iteration 2: 2780362.118 ops/s

  Iteration 3: 3621313.883 ops/s

  这里的Fork表示子进程,我们只配置里一个,因此只有一个进程的执行结果,该进程包含预热3次,每次1秒,以及运行3次,每次运行5秒,执行完testObjectKey方法后,会自动打印一个汇总信息

  Result: 939996.216 ±(99.9%) 2012646.237 ops/s [Average]

  Statistics: (min, avg, max)=(813154.364, 939996.216, 1013607.616), stdev=110319.932

  Confidence interval (99.9%): [-1072650.021, 2952642.453]

  统计结果给出了多次测试后的最小值,最大值和均值,以及标准差 (stdev),置信区间(Confidence interval)

  标准差(stdev)反映了数值相对于平均值得离散程度,置信区间是指由样本统计量所构造的总体参数的估计区间。在统计学中,一个概率样本的置信区间(Confidence interval)是对这个样本的某个总体参数的区间估计

  testStringKey的输出与上面类似,这俩个比较方法执行完毕,会自动打印出一个性能对比数据表格

  Benchmark Mode Samples Score Score error Units

  c.i.c.c.t.MyBenchmark.testObjectKey thrpt 3 1976766.072 408421.217 ops/s

  c.i.c.c.t.MyBenchmark.testStringKey thrpt 3 423788.869 222139.136 ops/s

  Benchmark列表示这次测试对比的方法,Mode列表上结果的统计纬度,Samples列表示采样次数,Samples=Fork*Iteration。Score是对这次评测的打分,对于testObjectKey,意味着他的OPS为每秒1976766,大约4倍testStringKey方法

  Score Error 这里表示性能统计上的误差,我们不需要关心这个数据,主要查看Score

  可以修改统计纬度,比如修改为Mode.SampleTime,时间按照纳秒统计

  @BenchmarkMode(Mode.SampleTime)

  @OutputTimeUnit(TimeUnit.NANOSECONDS)

  ......

  public class MyBenchmark {}

  可以看到有一组如下统计

  p( 0.0000)=1992.000 ns/op

  p(50.0000)=2084.000 ns/op

  p(90.0000)=2464.000 ns/op

  p(95.0000)=3472.000 ns/op

  p(99.0000)=4272.000 ns/op

  p(99.9000)=17481.920 ns/op

  p(99.9900)=80659.840 ns/op

  p(99.9990)=562593.690 ns/op

  p(99.9999)=745472.000 ns/op

  可以看到90%的调用,是在2464纳秒内完成,99%的调用都是在4272纳秒完成的.

  1.3.2 JMH常用设置

  在这个例子,我们性能测试所依赖的对象areaService,perferAreaService 恰好是线程安全的,大多数时候性能测试方法都会引用一些外部实例对象,考虑到多线程测试访问这些实例对象,JMH要求必须为这些变量申明是Thread 内生效,还是整个BeanMark使用。如果是前者,JMH会为每个线程构建一个新的实例,后者则所有测试都共享这个变量,JMH用@State注解来说明对象的生命周期,@State注解作用在类上,比如,在MyBenchmark例子里,我们可以改成如下例子

  @State(Scope.Benchmark)

  public static class SharedPara{

  AreaService areaService=new AreaService();

  PreferAreaService perferAreaService=new PreferAreaService();

  List data=buildData(20);

  private List buildData(int count){

  //忽略其他代码

  }

  }

  @Benchmark

  public void testStringKey(SharedPara para){

  para.areaService.buildArea(para.data);

  }

  @Benchmark

  public void testObjectKey(SharedPara para){

  para.perferAreaService.buildArea(para.data);

  }

  必须申明一公共静态内部类,该类包含了我们需要使用的实例对象,并在该类用@State注解表明这个对象是Thread的还是BeanchMark范围内使用。在这个例子里,因为配置为Scope.Benchmark,JMH在整个性能测试过程中,只构造一个SharedPara实例,SharedPara 作为参数传入每个待测试的方法。

  也可以不使用内部类,直接使用申明性能测试的类,在类上使用@State注解

  @State(Scope.Benchmark)

  public class MyBenchmarkStateSimple {

  AreaService areaService=new AreaService();

  PreferAreaService perferAreaService=new PreferAreaService();

  List data=buildData(20);

  //忽略其他代码

  }

  @Setup 和 @TearDown 是一对注解,作用于方法上,前者用于测试前的初始化工作,后者用于回收某些资源,比如压测前需要准备一些数据

  @State(Scope.Benchmark)

  public class ScriptEngineBeanchmrk {

  String script=null;

  @Benchmark

  public void nashornTest(){

  // ... 测试方法

  }

  @Setup

  public void loadScriptFromFile(){

  //加载一个测试脚本

  }

  }

  @Level 用于控制 @Setup,@TearDown 的调用时机,有如下含义

  Level.Tiral: 运行每个性能测试的时候执行,推荐的方式。Level.Iteration, 每次迭代的时候执行Level.Invocation,每次调用方法的时候执行,这个选项需要谨慎使用。

  JMH提供了Runner类能运行Benchmark类

  public static void main(String[] args) throws RunnerException {

  Options opt=new OptionsBuilder()

  .include(MyBenchmark.class.getSimpleName())

  .build();

  new Runner(opt)();

  }

  include接受一个字符串表达式,表示需要测试的类和方法,如上例子测试所有方法MyBenchmark。如下例子则只测试方法名字包含“testObjectKey“的方法

  include(MyBenchmark.class.getSimpleName()+".*testObjectKey*")

  OptionsBuilder包含了多个方法用于配置性能测试,可以指定循环次数,预热次数等,如下例子会用4个子进程做性能测试,每个进程预热一次,执行5次迭代

  public static void main(String[] args) throws RunnerException {

  Options opt=new OptionsBuilder()

  .include(MyBenchmark.class.getSimpleName())

  .forks(4)

  .warmupIterations(1)

  .measurementIterations(5)

  .build();

  new Runner(opt)();

  }

  截至到目前为止,JMH都是通过一个main方法在IDE里执行,更为通常情况,JMH推荐使用单独的一个Maven工程来执行性能测试而不要放到业务工程里。可以通过maven archetype:generate 命令来生成一个心得JMH Maven工程。

  mvn archetype:generate

  -DinteractiveMode=false

  -DarchetypeGroupId=org.openjdk.jmh

  -DarchetypeArtifactId=jmh-java-benchmark-archetype

  -DgroupId=code.ibeetl

  -DartifactId=first-benchmark

  -Dversion=1.0

  为了阅读方便,分成几行,如上命令行应该放到一行执行,执行完毕后,生成了一个maven工程,maven工程仅仅包含了一个 MyBenchmark 例子。

  package org.sample;

  import org.openjdk.jmh.annotations.Benchmark;

  public class MyBenchmark {

  @Benchmark

  public void testMethod() {

  // place your benchmarked code here

  }

  }

  我们可以修改MyBenchmark,添加我们需要测试的代码, 现在,可以创建一个性能测试的jar文件,通过运行如下maven命令

  mvn clean install

  命令会在target目录下生成一个benchmarks.jar,包含了运行性能测试所需的任何东西,在命令行运行如下命令

  java -jar target/benchmarks.jar MyBenchmark

  JMH将会被启动,默认情况下运行MyBenchmark类里的所有被@Benchmark标注方法

  有些性能测试需要了解不同输入参数的性能,比如对于模板引擎的性能测试中,考虑到字节流输出和字符流输出

  @Param({"1","2","3"})

  int outputType;

  @Benchmark

  public String benchmark() throws TemplateException, IOException {

  if(outputType==3){

  return doStream();

  }else if(outputType==2) {

  return doCharStream()

  }else{

  return doString();

  }

  }

  JMH会分别赋值outpuType为1,2,3后,在各自测试一次,会输出如下

  Benchmark (outputType) Score Units

  Beetl.benchmark 1 44977.421 ops/s

  Beetl.benchmark 2 34931.724 ops/s

  Beetl.benchmark 3 59175.106 ops/s

  1.3.3 注意事项

  编写JHM代码,需要考虑到虚拟机的优化,而使得测试失真,如下measureWrong代码就是所谓的Dead-Code代码

  @State(Scope.Thread)

  @BenchmarkMode(Mode.AverageTime)

  @OutputTimeUnit(TimeUnit.NANOSECONDS)

  public class JMHSample_08_DeadCode {

  private double x=Math.PI;

  @Benchmark

  public void baseline() {

  //基准

  }

  @Benchmark

  public void measureWrong() {

  //虚拟机会优化掉这部分,性能同baseline

  Math.log(x);

  }

  @Benchmark

  public double measureRight() {

  // 真正的性能测试

  return Math.log(x);

  }

  }

  测试结果如下

  Benchmark Mode Score Units

  c.i.c.c.c.i.c.c.j.JMHSample_08_DeadCode.baseline avgt 0.358 ns/op

  c.i.c.c.c.i.c.c.j.JMHSample_08_DeadCode.measureRight avgt 24.605 ns/op

  c.i.c.c.c.i.c.c.j.JMHSample_08_DeadCode.measureWrong avgt 0.366 ns/op

  在测试measureWrong方法,JIT能推测出方法体可以被优化调而不影响系统,measureRight因为定义了返回值,JIT不会优化。

  下一个是关于常量折叠,JIT认为方法计算结果为常量,从而优化直接返回常量给调用者

  private double x=Math.PI;

  private final double wrongX=Math.PI;

  @Benchmark

  public double baseline() {

  // 基准测试

  return Math.PI;

  }

  @Benchmark

  public double measureWrong_1() {

  // JIT认为是个常量

  return Math.log(Math.PI);

  }

  @Benchmark

  public double measureWrong_2() {

  // JIT认为方法调用结果是个常量.

  return Math.log(wrongX);

  }

  @Benchmark

  public double measureRight() {

  // 正确的测试

  return Math.log(x);

  }

  如下是测试结果

  Benchmark Mode Score Units

  c.i.c.c.c.i.c.c.j.JMHSample_10_ConstantFold.baseline avgt 1.175 ns/op

  c.i.c.c.c.i.c.c.j.JMHSample_10_ConstantFold.measureRight avgt 25.805 ns/op

  c.i.c.c.c.i.c.c.j.JMHSample_10_ConstantFold.measureWrong_1 avgt 1.116 ns/op

  c.i.c.c.c.i.c.c.j.JMHSample_10_ConstantFold.measureWrong_2 avgt 1.031 ns/op

  考虑到inline对性能影响很大,JMH支持 @CompilerControl来控制是否允许内联

  public class Inline {

  int x=0,y=0;

  @Benchmark

  @CompilerControl(CompilerControl.Mode.DONT_INLINE)

  public int add(){

  return dataAdd(x,y);

  }

  @Benchmark

  public int addInline(){

  return dataAdd(x,y);

  }

  private int dataAdd(int x,int y){

  return x+y;

  }

  @Setup

  public void init() {

  x=1;

  y=2;

  }

  }

  add和addInline方法都会调用dataAdd方法,前者使用CompilerControl类,可以用在方法或者类上,来提供编译选项

  DONT_INLINE,调用方法不内联INLINE,调用方法内联BREAK,插入一个调试断点(TODO,如何调试,参考11章)PRINT,打印方法被JIT编译后的机器码信息

  开发人员可能觉得上面的测试,add方法太简单,会习惯性的在add方法里方一个循环,以减少JMH调用add方法的成本。JMH不建议这么做,因为JIT会实际上对这种循环会做优化,以消除循环调用成本。如下是个例子可以看到循环测试结果不准确

  int x=1;

  int y=2;

  /** 正确测试

  */

  @Benchmark

  public int measureRight() {

  return (x + y);

  }

  private int reps(int reps) {

  int s=0;

  for (int i=0; i < reps; i++) {

  s +=(x + y);

  }

  return s;

  }

  @Benchmark

  @OperationsPerInvocation(1)

  public int measureWrong_1() {

  return reps(1);

  }

  @Benchmark

  @OperationsPerInvocation(10)

  public int measureWrong_10() {

  return reps(10);

  }

  @Benchmark

  @OperationsPerInvocation(100)

  public int measureWrong_100() {

  return reps(100);

  }

  @Benchmark

  @OperationsPerInvocation(1000)

  public int measureWrong_1000() {

  return reps(1000);

  }

  注解OperationsPerInvocation 告诉JMH统计性能的时候需要做修正,比如@OperationsPerInvocation(10)调用了10次。

  性能测试结果如下

  编写性能测试的一个好习惯是先编写一个单元测试用例,以确保性能测试准确性,x Benchmark Mode Score Units c.i.c.c.c.i.c.c.j.JMHSample_11_Loops.measureRight avgt 1.114 ns/op c.i.c.c.c.i.c.c.j.JMHSample_11_oops.measureWrong_1 avgt 1.057 ns/op c.i.c.c.c.i.c.c.j.JMHSample_11_Loops.measureWrong_10 avgt 0.139 ns/op c.i.c.c.c.i.c.c.j.JMHSample_11_Loops.measureWrong_100 avgt 0.018 ns/op c.i.c.c.c.i.c.c.j.JMHSample_11_Loops.measureWrong_1000 avgt 0.035 ns/op java

  可以看到,测试方法里使用循环,会促使JIT进行优化,做循环消除(参考第8章JIT TODO)

  1.3.4 单元测试

  无论是编写JMH,或者其他性能测试程序,好习惯是先编写一个单元测试用例,以确保性能测试方法的准确性,对于1.3.4的Inline类,可以先编写一个单元测试用例,确保add和addInline返回正确结果

  public class InLineTestJunit {

  @Test

  public void test(){

  Inline inline=new Inline();

  inline.init();

  //期望结果

  int expectd=inline.x+inline.y;

  int ret=inline.add();

  int ret2=inline.addInline();

  Assert.assertEquals(expectd,ret);

  Assert.assertEquals(expectd,ret2);

  }

  }

  在JMH工程调用maven install 生成测试代码的时候,会进行单元测试,从而保证测试结果的准确

  想免费学习(Java工程化、分布式架构、高并发、高性能、深入浅出、微服务架构、Spring、MyBatis、Netty、源码分析)等技术的朋友,可以加群:834962734,群里有阿里大牛直播讲解技术,以及Java大型互联网技术的视频免费分享给大家,欢迎进群一起深入交流学习,不管你是转行,还是工作中想提升自己能力都可以!

posted @ 2021-12-09 13:43  linjingyg  阅读(423)  评论(0)    收藏  举报