前些天看到了一个同事写的问题分析分享,他从服务的性能打点中得出Jackson性能比较差,并自己做实验得出了Jackson比org.json效率更低的结论。对他的代码稍做分析发现同事对写Benchmark还是有一些误解,结论分析也还是太过于草率。
其实做出有说服力的Benchmark还是需要一些工作的,也是比较严肃的。
本文仅限函数级的Benchmark。
什么是Benchmarking?Benchmarking是有别于Testing的:
影响程序性能的因素有很多,比如:
前三条并不是关注的重点,一般在评测的时候交待一下机器配置即可。4是在特殊的缓存访问模式下会有不同表现,比如经常需要刷新cache的值(cache失效)。5是程序启动时的额外开销,以及Full/Young GC带来的程序性能损耗,一般需要避开这些对Benchmark的影响。
怎样更好地做Benchmark呢?更详细的信息可以参考文献Statistically Rigorous Java Performance Evaluation。简单一点,首先要把性能指标当作随机变量,再用上一些简单的统计方法:
为什么要预热Warmup?因为JVM程序在启动后需要经历一段时间的预热步骤,可能包括:
这些步骤会显著提升首次执行的时间,需要排除掉。
上面的基本道理还是有必要了解的,从理念上提升理解,然后再借助一些实践这些方法论的工具,做Benchmark这件事就能比较好地完成了。
回到Json序列化和反序列化的问题,有几点应该关注:
合理使用测试框架可以轻松完成2.1中提到的点,只是一点参数设置而已。
JMH
JMH is a Java harness for building, running, and analysing nano/micro/milli/macro benchmarks written in Java and other languages targetting the JVM.
OpenJDK发布的代码工具,不需要使用OpenJDK,加入下面的依赖即可使用。
"org.openjdk.jmh" % "jmh-generator-annprocess" % "1.21"
"org.openjdk.jmh" % "jmh-core" % "1.21"
ScalaMeter
如果是Scala的代码,也可以使用ScalaMeter。
ScalaMeter is a microbenchmarking and performance regression testing framework for the JVM platform that allows expressing performance tests in a way which is both simple and concise.
It can be used both from Scala and Java.
- write performance tests in a DSL similar to ScalaTest and ScalaCheck
- specify test input data
- specify how test results are collected
- organize performance tests hierarchically
两个包使用起来都不难。
本来想做一个示例项目,看了fabienrenaud/java-json-benchmark这个项目后就放弃了,代码、数据图表和结论都有,需要自取。
向流量低头,想想CSDN上大量的低水平入门代码也能有很高访问量,我觉得还是得提供那么一两个例子的。
前面提到了Json解析应该分为解析到内部表示和加上databind到Java Bean的两种,下面以只转换到中间表示为例,说明简单的JMH和ScalaMeter如何写Benchmark。
package json;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import org.json.JSONObject;
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;
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 2)
@Measurement(iterations = 3) // 10 iterations, 10s each
@Fork(1)
@State(Scope.Benchmark)
public class JsonStream {
static ObjectMapper mapper = new ObjectMapper();
final static String s = "{\"type\":\"duration\",\"sub_type\":\"concrete\",\"start\":\"2019-09-10T05:00:00\",\"end\":\"2019-09-10T17:00:00\",\"grain\":\"arbitrary\",\"festival_flag\":false,\"token\":\"明天上午五点到下午五点\",\"start_timestamp\":1568062800000,\"end_timestamp\":1568106000000}";
@Benchmark
public JsonNode jackson() throws IOException {
return mapper.reader().readTree(s);
}
@Benchmark
public JSONObject orgJson() {
JSONObject node = new JSONObject(s);
return node;
}
@Benchmark
public JsonElement gson() {
return JsonParser.parseString(s);
}
public static void main(String[] args) throws RunnerException {
final Options options = new OptionsBuilder()
.include(".*" + JsonStream.class.getSimpleName() + ".*")
.forks(1)
.build();
new Runner(options).run();
}
}
时间和迭代次数都比较小,跑10轮每轮1分钟,半个小时就过去了。
结果如下,仅供参考
# JMH version: 1.21
# VM version: JDK 1.8.0_152, Java HotSpot(TM) 64-Bit Server VM, 25.152-b16
# VM invoker: /Library/Java/JavaVirtualMachines/jdk1.8.0_152.jdk/Contents/Home/jre/bin/java
# VM options: ...
# Warmup: 2 iterations, 10 s each
# Measurement: 3 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
...
Benchmark Mode Cnt Score Error Units
JsonStream.gson thrpt 3 697.574 ± 193.477 ops/ms
JsonStream.jackson thrpt 3 1238.795 ± 108.857 ops/ms
JsonStream.orgJson thrpt 3 392.496 ± 24.216 ops/ms
别看成Error了,看Score。
不是Scala用户的话,可以忽略ScalaMeter。(个人理解,参考就好)ScalaMeter提供了一些其它的度量,各种类和方法调用次数、内存消耗以及忽略GC等。但是ScalaMeter相对JMH少了一些比较实用的Throughput之类的直接计算模式,也是让人头疼。JMH设计上更简单,更容易理解也是个比较大的优点。
package json
import java.io.IOException
import org.json.JSONObject
import org.scalameter.{Bench, Gen}
import org.scalameter.api._
import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
import com.google.gson.{JsonElement, JsonParser}
object JsonParseScalaMeter extends Bench.OfflineReport {
val mapper = new ObjectMapper
val s =
"{\"type\":\"duration\",\"sub_type\":\"concrete\",\"start\":\"2019-09-10T05:00:00\",\"end\":\"2019-09-10T17:00:00\",\"grain\":\"arbitrary\",\"festival_flag\":false,\"token\":\"明天上午五点到下午五点\",\"start_timestamp\":1568062800000,\"end_timestamp\":1568106000000}"
@throws[IOException]
def jackson(): JsonNode = mapper.reader.readTree(s)
def orgJson(): JSONObject = {
val node = new JSONObject(s)
node
}
def gson(): JsonElement = JsonParser.parseString(s)
// Generator
val gen = Gen.unit("fixed")
//DSL - group
performance of "Json parse (non-databind)" in {
measure method "jackson" in {
using(gen) config (exec.benchRuns -> 10) in { _ =>
for (_ <- 1 to 10000) jackson()
}
}
measure method "gson" in {
using(gen) config (exec.benchRuns -> 10) in { _ =>
for (_ <- 1 to 10000) gson()
}
}
measure method "org.json" in {
using(gen) config (exec.benchRuns -> 10) in { _ =>
for (_ <- 1 to 10000) orgJson()
}
}
}
}
输出示例
:::Summary of regression test results - Accepter():::
Test group: Json parse (non-databind).jackson
- Json parse (non-databind).jackson.Test-0 measurements:
- at fixed -> (): passed
(mean = 12.32 ms, ci = <-2.29 ms, 26.92 ms>, significance = 1.0E-10)
Test group: Json parse (non-databind).gson
- Json parse (non-databind).gson.Test-1 measurements:
- at fixed -> (): passed
(mean = 17.48 ms, ci = <-30.37 ms, 65.33 ms>, significance = 1.0E-10)
Test group: Json parse (non-databind).org.json
- Json parse (non-databind).org.json.Test-2 measurements:
- at fixed -> (): passed
(mean = 54.94 ms, ci = <28.10 ms, 81.78 ms>, significance = 1.0E-10)
Summary: 3 tests passed, 0 tests failed.
上面的代码示例可以在du00/mini-jvm-bencnmark找到,对于做Benchmark还是不够严谨的,范围也太小。对更广泛的java json包的表现可以在移步项目fabienrenaud/java-json-benchmark,代码、数据图表和结论都有,需要自取这里就不剧透了。但是这个项目里面的代码太复杂,作为测试代码用例还是不太行,所以上面的简单代码还是有那么一点点意义。
最后,开头提到的同事分析的Jackon的问题成立吗?不一定,真实环境总是复杂的,比如在Full GC发生的时候,所有操作的时间都会拉长。严谨一点应该是定位到“有问题”的点,消除环境上的差异,单纯地再做一次这一条耗时长的数据的重复十万次解析,问题自然明了。