Skip to content

使用JMH编写Java基准测试

最近因为项目需要重拾Java,项目涉及到很多日期时间相关的计算和存储,所以打算用极其便捷且线程安全的LocalDateTime类型代替古老的Date类型,那么问题来了:Date类型和LocalDateTime类型的JSON解析的性能如何呢

OpenJDK JMH

在准备开始做性能基准测试之前,突然想到Golang开箱即用的精确到纳秒级别的Benchmark功能,Java有没有类似的工具或者类库呢?于是搜索一下,找到了JMH。

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

JMH是OpenJDK开发的,然而OpenJDK并没有自带该类库,Oracle的也没有,需要额外引入依赖并配置IDE。详细文档和示例参考文档: //openjdk.java.net/projects/code-tools/jmh/

IDEA下Gradle项目的配置

Maven与Gradle配置JMH类似,都是添加测试Scope的依赖即可,以Gradle为例:

groovy
testCompile("org.openjdk.jmh:jmh-core:1.21")
testCompile("org.openjdk.jmh:jmh-generator-annprocess:1.21")

然后IDEA安装JMH插件方便生成和运行JMH测试用例,在IDEA里面直接搜索安装即可(下图不小心暴露了一些其他非常好用的IDEA插件)

插件装好并重启IDEA后,开启IDEA的Annotation Processing功能,就可以在项目的test目录下新建一些Benchmark测试代码了

注:笔者还遇到了这个报错,mvn/gradle clean一下,再刷新一下依赖,重启IDEA解决

Can not found /META-INF/BenchmarkList (JMH Unable to find the resource: /META-INF/BenchmarkList)

Benchmark测试用例的编写和执行

编写方法和常用注解

在开始编码之前,我们需要了解一下JMH的常用注解的含义,使用这些注解和在main函数中创建org.openjdk.jmh.runner.options.OptionsBuilder,通过链式调用构建Options等价,也和直接通过java -jar benchmark.jar 添加参数等价,但这里只讲解通过注解来配置测试参数,不因为别的,就因为注解更酷炫。

  • @Benchmark:每个带Benchmark注解的都是一个微基准测试用例
  • @BenchmarkMode:吞吐量或者平均时间等等,配合@OutputTimeUnit作为计量指标
  • @Fork:每个测试Fork进程执行,指定Fork的数量,这是为了防止JVM PGO(Profile-Guided Optimization)影响测试结果
  • @Threads:每个基准测试启动的并发线程数量
  • @State:State有3个Scope,分别是Benchmark,Group,Thread
    • Thread表示变量只在同一个线程同步,一般用Thread,用到的成员变量相当于是ThreadLocal的
    • Benchmark表示变量整个测试的进程同步,比如方法A用到了变量B,线程C和线程D访问B是同一个变量需要加锁,而不是ThreadLocal的
    • Group则是同一组的测试使用同样的变量,配合@Group @GroupThreads 给关联的测试分组和同步使用
  • @Warmup @Measurement:预热和正式开始的轮数,每次迭代时间和循环次数都在这里配置
  • @Setup @TearDown:这俩相当与JUnit4的@Before @After,做一些初始化和资源回收的事情,有3个Level
    • Trial 是默认值相当于 @BeforeClass @AfterClass
    • Iteration 表示每一轮预热或者正式测试都执行一次
    • Invocation 相当于 @Before @After,每次调用@Benchmark的函数都会执行一次,这个一般不要用,会干扰测试结果
  • @Param:轮换指定不同的调用参数测试

除了这些还有一些不常用的注解此处不展开解释,除了注解以外,还有一个非常重要的JMH的类:Blackhole,在编写基准测试的时候,很容易上编译器或者JIT的当,造成不准确的结果

  • 某些数值计算在编译阶段就被算成常数
  • 循环展开机制,编译器可能把循环300次自动优化成循环100次,每次循环递增调用3次等等
  • 一些没有副作用,返回值又没有赋值的函数,编译器或者JIT可能直接会整个函数调用都给直接省掉

因此,在用JMH写Java基准测试的时候,尽量不要自己写for循环,通过注解或者构建参数告诉Runner循环迭代的配置即可,@Benchmark函数调用的结果,最好是return掉,或者用Blackhole消费掉,Blackhole的consume方法就是接收函数返回值,防止调用被JIT优化掉的。

代码实例

了解了JMH的概念和使用之后,开始撸代码,这里写了两个@Benchmark,分别是反序列化同一个常量JSON字符串,到两个不同的Class,一个用LocalDateTime接收时间戳,另一个用Date接收,用的是业界最成熟的JSON库Jackson

java
package code2life.benchmark;

import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import lombok.Data;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
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.io.IOException;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.TimeUnit;

@BenchmarkMode({Mode.Throughput})
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 1)
@Measurement(iterations = 2)
@Threads(1)
@Fork(1)
@State(Scope.Thread)
public class Jackson2UtilBenchmark {

    private static String raw = "[{\"id\":1,\"name\":\"name1\",\"data\":[\"a\",\"b\",\"c\"]," +
            "\"createTime\":\"2019-05-09T01:53:13.396Z\",\"modifyTime\":\"2019-05-10T01:53:13.396Z\"}," +
            "{\"id\":2,\"name\":\"name2\",\"data\":[\"d\",\"e\",\"f\"],\"createTime\":\"2019-05-01T01:53:13.396Z\"," +
            "\"modifyTime\":\"2019-05-02T01:53:13.396Z\"}]";

    private ObjectMapper objectMapper = new ObjectMapper();

    private JavaType javaType = objectMapper.getTypeFactory().constructParametricType(ArrayList.class, SimplePojo.class);

    private JavaType javaTypeDate = objectMapper.getTypeFactory().constructParametricType(ArrayList.class, SimplePojoDate.class);

    @Setup(Level.Trial)
    public void setup() {
        objectMapper.findAndRegisterModules();
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    }

    @Benchmark
    public void parseLocalDateTime(Blackhole bh) throws IOException {
        bh.consume(objectMapper.readValue(raw, javaType));
    }

    @Benchmark
    public void parseDate(Blackhole bh) throws IOException {
        bh.consume(objectMapper.readValue(raw, javaTypeDate));
    }

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

@Data
class SimplePojo {
    private Integer id;
    private String name;
    private List<String> data;
    private LocalDateTime createTime;
    private LocalDateTime modifyTime;
}

@Data
class SimplePojoDate {
    private Integer id;
    private String name;
    private List<String> data;
    private Date createTime;
    private Date modifyTime;
}

运行结果

下面是笔者本机单线程反序列化常量JSON数据的基准性能测试结果

  • 运行环境: Intel i7-8550U CPU @ 1.80GHz
  • 操作系统: Ubuntu 18.04 64 Bit
  • JRE: HotSpot 64-Bit JVM (1.8.0 191-b12)

示例代码运行结果:

BenchmarkModeCntScoreErrorUnits
parseDatethrpt2351196.715ops/s
parseLocalDateTimethrpt2208930.702ops/s

只有单个Date/LocalDateTime属性的JSON:

BenchmarkModeCntScoreErrorUnits
onlyDateFieldthrpt21514370.638ops/s
onlyLocalDateTimethrpt2925243.048ops/s

只接收String和Integer类型,不做Date的转换

BenchmarkModeCntScoreErrorUnits
parseStringOnlythrpt21014225.819ops/s

结果不出意料,LocalDateTime的数据结构更复杂,而Date只有一个毫秒数的时间戳(对应更简单的java.time.Instant类),因此用它接收反序列化的JSON数据效率更低,性能大约损失了三四成的样子。对于只有一个时间属性的JSON数据差距更大,但相对于更耗时的业务来说,性能瓶颈不可能在这里,这一点性能差异可以忽略不计,因此笔者还是决定继续使用LocalDateTime,摒弃Date。

另外,在Windows 10 i7 7700 3.6GHz上单线程测试结果类似

  • 对于单个Date/LocalDateTime属性的吞吐量分别是每秒 170万,102万次
  • 包含Integer String List Date等复杂一点的数据,分别是每秒37万,25万次
  • Thread调成2或4,多线程并行解析会有很大的提高,但低于100%,线程越多上下文切换也越多,收益也越少,物理核数的线程数性能达到最大值

其他语言的横向对比

JavaScript的JSON反序列化性能

说完了Java,笔者脑袋一拍,对于JSON的祖宗JS,反序列化性能会不会更好呢?我们来比比看。继续上面的例子,把LocalDateTime改成String,这样数据中只有Integer和String两种类型,在JS中直接调用JSON.parse。NPM中找到了Benchmark.js这个库,但是为了能与Deno横向对比,没有使用该库,直接用循环计算:

js
const json = "[{\"id\":1,\"name\":\"name1\",\"data\":[\"a\",\"b\",\"c\"]," +
"\"createTime\":\"2019-05-09T01:53:13.396Z\",\"modifyTime\":\"2019-05-10T01:53:13.396Z\"}," +
"{\"id\":2,\"name\":\"name2\",\"data\":[\"d\",\"e\",\"f\"],\"createTime\":\"2019-05-01T01:53:13.396Z\"," +
"\"modifyTime\":\"2019-05-02T01:53:13.396Z\"}]";

// warmup
for (let i = 0; i < 1000000;i++) {
  JSON.parse(json);
}

// parse
const start = new Date().valueOf()
for (let i = 0; i < 5000000;i++) {
  JSON.parse(json);
}
for (let i = 0; i < 5000000;i++) {
  JSON.parse(json);
}
console.log(10000000 / (new Date().valueOf() - start) * 1000);
bash
node json-benchmark.js
deno json-benchmark.ts

执行结果有些令人吃惊:

  • NodeJS执行两次: 531985 ops/s, 542387 ops/s
  • Deno执行两次:514959 ops/s, 488472 ops/s

而Java在同样数据的情况下,两次执行分别是: 987421 ops/s, 1011526 ops/s。足足比Java差了一倍!

但细细一想确实是符合逻辑的,NodeJS和Deno的JSON.parse函数虽然都是基于V8引擎的Native代码,代码在这里:https://github.com/v8/v8/blob/master/src/json/json-parser.cc. 可见JSON.parse转换成的是V8的数据类型,给JS解释器使用的,相对于静态类型的Java直接定义POJO必然需要多做一层处理。NodeJS的Native Code基于C++,而Deno是Rust,源代码或者编译器可能并没有C++成熟,也可以解释Deno的性能比NodeJS稍差,但二者同样基于V8没有本质区别

C#的JSON反序列化性能

比完了ECMAScript,一不做二不休,继续来和笔者最喜欢的C#比一比JSON反序列化。测试环境是Ubuntu Dotnet Core 2.2.204,Benchmark和JMH异曲同工,只不过Annotation在C#中叫Attribute。

dotnet add package Newtonsoft.json
dotnet add package BenchmarkDotNet
csharp
using System;
using System.Collections.Generic;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace benchmark
{
  public class Program
  {
    public static void Main(string[] args)
    {
      var summary = BenchmarkRunner.Run<JsonBenchmark>();
    }
  }

  [CoreJob(baseline: true)]
  [RPlotExporter, RankColumn]
  public class JsonBenchmark
  {

    private string raw = "... json data ...";

    [Benchmark]
    public void JsonParseStatic()
    {
      JsonConvert.DeserializeObject<List<SimpleData>>(raw);
    }
  }

  class SimpleData
  {
    private int id { get; set; }
    private string name { get; set; }
    private List<string> data { get; set; }
    private string createTime { get; set; }
    private string modifyTime { get; set; }
  }
}

dotnet run -c release

测试结果如下,平均 4.118 s/op,吞吐量大约 242836 ops/s

MethodMeanErrorStdDevRatioRank
JsonParseStatic4.118 us0.0165 us0.0147 us1.001

彩蛋

出于好奇,笔者本机上使用Golang的encoding/json又做了一次测试:

  • 小写(Private)struct成员变量 310007 ops/s
  • 大写(Public)struct成员变量 163345 ops/s
  • 使用Tag指定对应关系(如下面的代码):165207 ops/s
go
package main

import (
	"encoding/json"
	"testing"
)

var raw = []byte(".... json data...")

type SimpleData struct {
	ID         int      `json:"id"`
	Name       string   `json:"name"`
	Data       []string `json:"data"`
	CreateTime string   `json:"createTime"`
	ModifyTime string   `json:"modifyTime"`
}

func BenchmarkJSONParse(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var data []SimpleData
		json.Unmarshal(raw, &data)
	}
}

go test -bench=. --benchtime=20s

Golang在成员变量名与JSON属性不一样的情况下,性能测试结果比Java(Jackson) .Net Core(Newtonsoft.Json),Node/Deno(V8 JSON.parse)都要差,和Python的测试结果竟然几乎一样。使用同样的属性名时快一倍,略高于.Net Core,但实际上由于Golang通过变量大小写代替public/private的机制,造成了实际场景中,JSON解析往往需要指定Tag,多出来的这一层映射关系直接造成了损失一半性能

JSON解析的性能很大程度上受所使用的库的影响,比如Golang有号称比encoding/json库快10倍的JSON-parser,Java也有号称最快JSON解析的阿里fastjson,JVM的字符串常量池机制也是单一数据情况下解析飞快的原因之一,因此这些数字并不代表什么,仅从这一点也并不能说明编程语言的优势劣势,毕竟PHP才是最好的编程语言