Skip to content

4 Java单机系统运行和观测

About 3794 wordsAbout 13 min

性能新书

2026-08-17

为了满足分布式系统的延迟或者吞吐量要求,需要将分布系统拆分逐个调优,最重要的就是单机系统性能调优和基础设施调优。本章目标为单机性能调优,相对于分布式系统性能调优,它要求程序员具备更为广泛和深入的Java知识。

在第一篇《质量驱动架构》中,提到单机性能瓶颈的根源包括是“组成计算机核心的的CPU,内存,磁盘的访问延迟不是一个数量级。性能较低的组件会降低计算机整体性能”,以及“JVM、工具、系统基础设施默认设置并非最优化”等。本章将会介绍一系列实战方法来解决这些根源问题。阅读完本章,读者不仅仅能成为一个高性能系统的建设者,也能进一步掌握Java系统高级知识。

4.1 JMH

衡量Java接口或者方法的性能最直接的办法是运行多次后取得平均响应时间,因此经常有程序员是main方法写一段测试代码,比如测试foo方法性能

public static void main(String[] args){
    int max = 100000;
    long start,end;
    start = System.nanoTime();
    for(int i=0;i=ma-;i++){
        foo();
    }
    end = System.nanoTime();
    //计算平均每次执行的纳秒数
    System.out.println("foo "+(end-time)/max)
}

这段程序非常简单,但不幸的是,这种方式不能统计出foo方法的性能。试着改写程序

public static void main(String[] args){
    int max = 100000;
    long start,end;
    start = System.nanoTime();
    for(int i=0;i=ma-;i++){
        foo();
    }
    end = System.nanoTime();
    //计算平均每次执行的纳秒数
    System.out.println("foo "+(end-time)/max);
    
    start = System.nanoTime();
    for(int i=0;i=ma-;i++){
        wrapFoo();
    }
    end = System.nanoTime();
    //计算平均每次执行的纳秒数
    System.out.println("wrapFoo "+(end-time)/max);
}

public static wrapFoo(){
    foo();
}

会发现wrapFoo的性能比foo方法性能更好,这是为什么,wrapFoo还调用了foo方法?这是因为通过手工编写一个性能压测程序有较多的问题

  • 虚拟机在执行代码过程中,会加载类,解释执行,以及有可能的优化编译(JIT)。需要确保虚拟机进行了一定预热运行,以保证测试的公平性,在压测foo方法的时候,此时执行的还较慢。当执行wrapFoo的时候,虚拟机预热完毕,因此显得wrapFoo方法性能更好。
  • 如果要评测那个方法性能更好,如果在同一个虚拟机里调用,有可能会互相影响, 比如第一个方法执行完毕,JVM做垃圾回收会影响第二个方法。 最好的办法是分成俩个独立的进程运行,确保俩个对比方法不相互影响。
  • 为了避免环境影响造成的对结果统计不准,我们需要运行多次,取出平均成绩
  • 需要从多个纬度统计方法的性能,比如统计冷启动需要消耗的时间,统计吞吐量,每次消耗时间,或者TP95等功能。

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

本章所有性能测试使用JMH工具,JMH(Java Microbenchmark Harness)是由 OpenJDK 团队官方开发的一款 Java 微基准测试框架,用于测试JDK性能。很多开源类库和工具库也广泛使用JMH。

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

<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>

${jmh.version} 为jmh最新版本,目前为1.37

我们编写一个JMH测试类,用来测试生成随机数的性能,使用传统的Random类生成和使用ThreadLocalRandom生成

@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 3)
@Measurement(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS)
@Threads(1)
@Fork(1)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class MyBenchmark {
    @Benchmark
    public  int  testRandom(){
        //Random是非线程安全类
        Random random = new Random();
        return random.nextInt(1000);

    }
    @Benchmark
    public  int  testNewRandom(){
        return  ThreadLocalRandom.current().nextInt(1000);
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(MyBenchmark.class.getSimpleName())
                .build();
        new Runner(opt).run();
    }
}

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

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

运行如上程序,最后的日志会得到大概如下输出

Benchmark                   Mode  Cnt       Score       Error   Units
MyBenchmark.testNewRandom  thrpt    3  398335.875 ± 25224.264  ops/ms
MyBenchmark.testRandom     thrpt    3   19192.690 ±  1541.974  ops/ms

输出包含了俩个被@Benchmark 标注的方法testFoo和testWrapFoo,输出解释如下表格

列名字说明
Mode对应BenchmarkMode
Cnt运行次数,同@Measurement的iterations设置
Units输出单元,同OutputTimeUnit的配置,本例子表示每毫秒的的操作次数(ops)
Score表示运行结果,与Units对应。 本例子表示403 ,表示每毫秒运行403次
Error表示测试结果的置信区间误差,误差值过大,表示当时测试环境可能不稳定,需要增加测试次数或者保证测试环境无干扰

很多开源框架都内置了JMH用于验证其服务的性能,或者内部函数的性能,比如FastJSON,Netty,Fory等,作者的Beetl和BeetlSQL也使用JMH验证工具的性能。本章后面的高性能战术都会使用JMH验证.

建议在工程里专门建立一个jmh模块,用来测试性能。

4.2 记录JMH测试结果

业务系统的性能优化也可能因为代码调整,JDK升级等原因性能会发生变化。因此可以使用JMH来性能测试并记录其结果,在业务代码调整后可以复测并对比之前的记录结果。

JMH测试结果可以导出*TEXT,*JSON,CSV等格式用于保存最近一次性能测试结果,通过调用

public static void main(String[] args) throws RunnerException {
    Options opt = new OptionsBuilder()
            .include(MyBenchmark.class.getSimpleName()).resultFormat(ResultFormatType.JSON)
            .build();
    new Runner(opt).run();
}

也可以直接将测试结果粘贴到性能测试到源码方便观察,比如

@Fork(0)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
/**
 * Benchmark                   Mode  Cnt       Score       Error   Units
 * MyBenchmark.testNewRandom  thrpt    3  322815.641 ± 36424.597  ops/ms
 * MyBenchmark.testRandom     thrpt    3   19331.700 ±  1159.759  ops/ms
 */
public class MyBenchmark {
}

4.3 JMH和Spring

通常在测试和优化的业务系统,是采用Spring框架。因此第一节的MyBenchmark需要改动。增加初始化方法setUp用于初始化Spring

@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 3)
@Measurement(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS)
@Threads(1)
@Fork(1)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark) /* JMHTest的局部变量context,creditService被所有测试现成共享*/
public class JMHSpringTest {

    ConfigurableApplicationContext context;
    CreditService creditService;
    @Benchmark
    public int call(){
       return  creditService.queryCredit(15L);
    }

    /**
     * 启动前初始化,Trial表示只运行一次
     */
    @Setup(Level.Trial)
    public void setUp() {
        //Main是此工程的Spring初始化类
        context = SpringApplication.run(Main.class);
        creditService = context.getBean(CreditService.class);

    }
    /**
     * 结束前运行,Trial表示只运行一次
     */
    @TearDown(Level.Trial)
    public void tearDown() {
        if (context != null) {
            context.close();
        }
    }
    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(JMHTest.class.getSimpleName())
                .build();
        new Runner(opt).run();
    }
}

这个例子多了3个注解,说明如下

注解说明
@Setup被此注解标注的方法,将在测试前运行,Level.Trial表示整个测试期间,无论运行多少次,此方法只运行一次
@TearDown被此注解标注的方法,将在测试结束后运行
@State用于指示JMHSpringTest的局部变量在多线程运行下的状态,Scope.Benchmark意味着整个测试中,共享变量。

在通过JMH测试和优化实际业务系统的性能时候,通常采用Spring框架,且需要Mock第三方依赖系统,因此建议实际项目里,再Junit环境中使用JMH以方便的集成Spring和Mock功能,读者可以参考junit-jmh-extension开源实现此功能,限于篇幅。不再具体说明

4.4 观察单机系统运行

当具体单机能运行业务系统,且能使用JMH进行性能测试的时候,我们就可以利用JDK提供的jvisualvm工具来观测系统的运行和寻找性能优化点。

通过jvisualvm,你可以连接到本地或者远程虚拟机,监控以下数据

  • 每个线程运行情况,如状态是运行,还是休眠,还是等待,监视,线程当时运行在的代码位置和线程栈
  • 通过CPU抽样器,可以获取到每个类的每个方法执行的总时间,分为自身执行时间,以及调用其他方法的时间,你可以保存一份快照,供事后进行性能分析,找到可能得性能改善点。
  • 通过内存抽样器,你可以获取到内存使用情况,内存有多少对象,你关心的类占据了多少内存,你可以保存一份快照,供事后通过OQL或者商业可视化工具进一步分析内存。

我们可以运行任何JMH程序(需要设置运行时间足够长,即注解Measurement的time足够长时间),然后打开jvisualvm,观测被我们@Benchmark标注的方法运行数据以及JVM的内存使用情况.

调整JMHSpringTest例子,做如下调整

调整描述
fork(0)fork会启动新的JVM运行JMHSpringTest,这里调整为0方便使用jvisualvm 连接上一个JVM就够了。如果填入一个非0,则会启动一个叫ForkedMain进程运行性能测试。
time=60设置运行较长时间以使得性能分析更为准确。
sleepCreditService.queryCredit方法里,增加一个sleep(10L), 模拟一个性能瓶颈

在控制台启用jvisualvm,并在左侧选择MyBenchmark进程,右侧选择Sampler(采用),并在sample中选择CPU采样

可以从http://visualvm.github.io/ 下载最新版本 jvisualvm

上图CPU采样,列出了JVM的所有线程,点击com.ibeetl.jmh.spring.jmh.JMHSpringTest.call-jmh-worker-1 显示了此线程的调用栈,可以看到,在JMHSpringTest.call是JMH的测试方法,queryCredit方法占用了较长时间,进一步分析发现是有一个sleep调用占用了较长时间。

较为快的找到执行瓶颈是通过工具栏,点击Hot Spot按钮,会直接列出CPU耗时,如下表格

表格的含义如下

列名
Name目标方法名,可以点击右键,选择Find in forward call显示调用栈,即如图一所示
Self Time(CPU)方法自身执行的CPU耗时,
Total Time(CPU)方法执行的总CPU耗时,即自身耗时+此方法调用其他方法的耗时
Total Time显示在调用栈中的方法执行总的耗时,包括了CPU耗时,以及休眠或者是锁等待耗时

有了Cpu采样,可以找到目标方法的调用耗时,分析此方法下所有方法调用耗时,以及锁等待(比如为了获取连接池等待时间)耗时,找到性能瓶颈并进行优化。

限于篇幅,本书简单介绍了 jvisualvm工具的CPU采样,可以通过Help菜单了解 jvisualvm所有用法。在后面的章节中,会陆续使用jvisualvm其他特性。除了jvisualvm外,还有JProfiler,YourKit等商业工具

jvisualvm是一个适合开发时期使用的Java性能观察调优工具,它也可以用于生产环境中,临时连接到生产JVM上,它的CPU 和Memory采样对目标JVM造成的影响极低,我的使用经验是对目标系统,降低10%的吞吐量。 在使用任何性能观测调优工具时候,都需要注意此工具对目标系统的影响,以判断是否能在生产上临时使用。

4.5 过时的优化策略

由于Java 语言和JVM不断演进,有些Java 性能优化策略回过时,有的一直是错误的但业界尚未去纠正。举一个例子,有种说法,循环中捕捉异常 的性能损耗较大,建议在循环体外捕捉异常.

可以用编写一个JMH用来验证,比较循环100万次的性能

@Benchmark
public int test(Blackhole hole) {
  int i = 0;
  for (; i < 100_000_00; i++) {
    try {
      if (i == -1) {
        throw new RuntimeException();
      }
      hole.consume(i);
    } catch (Exception exception) {
      continue;
    }
  }
  return i;
}

@Benchmark
public int test2(Blackhole hole) {
  int i = 0;
  try {
    for (; i < 100_000_00; i++) {
      if (i == -1) {
        throw new RuntimeException();
      }
      hole.consume(i);
    }
  } catch (Exception exception) {
    ;
  }
  return i;
}

这里使用了JMH提供的Blackhole来防止出现代码消除情况,Blackhole.consume 用来欺骗JIT 避免被消除,让其认为代码中的变量确实有用(作为参数传递给comsume) 这种测试最后证明,两种循环性能没有区别。

Benchmark                 Mode  Cnt  Score   Error  Units
CatchExceptionTest.test   avgt   10  1.375 ± 0.163  ms/op
CatchExceptionTest.test2  avgt   10  1.283 ± 0.059  ms/op

其他已知的不正确的优化策略还包括如下,关于这几个策略的JMH性能测试结果,参考本书提供的例子

  • 错误:使用final标识的方法能指示JIT完成内联。经过JMH测试,无论是否使用final,只要方法符合JIT对内联的设定,就能实现内联,JMH性能测试也能证明俩种是一样的。在JIT一节,我们将分析JIT日志判断方法是否被内联
  • 错误:使用StringBuilder 或者使用"+" 拼接字符串,两者性能是一样的。特定情况下,答案是“+” 性能有可能更好,从生成字节码能看到后者的的字节码指令会更少,这将在6.10中说明

知行合一