简介
JMH:Java MicroBenchmark Harness,Java语言的微基准测试框架,用于测量Java代码的性能,由openJDK团队开发。jmh的作用,就是测量代码在真实环境的下性能,并且尽量排除外部干扰。
普通的Java性能测试,通常是获取执行前和执行后的毫秒数,然后打印执行时间,但是这种测量方式会受很多影响,例如JIT编译、类加载等,不够准确。
jmh的性能测试,整个过程分为两部分,第一部分是预热,预热会提前执行目标代码,让jvm进行类加载、JIT编译等操作,第二部分是性能测量,第二部分的运行结果才会作为测量结果。预热和测量,都分为多个迭代,在一次迭代内,会尽可能多的执行目标代码,默认一次迭代是1秒钟的时间,默认预热和测量都会进行5次迭代。
jmh会单起一个进程来执行目标代码,避免外部环境的影响。
性能测试应该在固定的环境运行,因为不同环境对于性能测试的结果影响很大,不同环境之间的测试结果没有可比性。
JVM在运行时做的优化点
只有了解这些优化点,才能理解jmh在性能测量的过程中每一步的含义。
当基准测试单独执行组件时,JVM或底层硬件可能会对组件应用许多优化。当组件作为大型应用程序的一部分运行时,这些优化可能无法应用。因此,实施不当的微基准测试可能会让用户相信组件的性能比实际情况更好。编写正确的Java微基准测试通常需要防止JVM和硬件在微基准测试执行期间应用的优化,而这些优化在实际生产系统中是无法应用的。这就是JMH可以实现的功能。
应当使用JMH,尽量还原软件实际运行时的情况,避免某些实际不会发生的优化,以测试关键代码的真实性能表现。
1、JIT编译:Just In Time,即时编译,HotSpot会监控热点代码,在运行时将其编译为机器码,从而获得更快的执行速度,这是jvm的核优化点。没有被JIT编译的代码,则是解释执行,逐行将字节码解释为机器码,相对而言执行速度比较低。
2、方法内联:JVM会将短小、频繁调用的方法,内联到目标方法中,消除方法调用时的栈帧创建、销毁的开销。内联会让小方法调用的性能结果原优于实际。
jmh避免方法内联的方式:
java
@Benchmark
@CompilerControl(CompilerControl.Mode.DONT_INLINE) // 增加一个注解,阻止方法内联
public int testMethodCall() {
return add(1,2);
}
3、死码消除:如果jvm检测到代码运行的结果实际上没有被使用到,会把这段代码消除。如果用户的代码发生了死码消除,会导致测试结果完全失真,看起来会获得一个很高的性能。
防止死码消除的方式:
- 方式1:避免写void类型的测试方法,方法的计算结果要返回
- 方式2:使用BlackHole消费代码的计算结果
3、常量折叠和常量传播: 常量折叠是编译器计算常量表达式的值,例如,int a = 1 + 2,会被优化为 int a = 3;常量传播是将常量值替换到变量处,消除变量引用。
防止常量折叠、常量消除的方式:使用@Param、@State来管理变量。
4、伪共享与缓存行:缓存行是CPU缓存的最小单位,通常是64字节,多个线程修改同一缓存行的不同变量,会导致缓存行频繁失效,因为缓存一致性协议,性能大幅下降。伪共享是指看似独立的变量,因内存地址相邻被放入到同一缓存行,引发无意义的缓存竞争。
5、分支预测:CPU会预测if else语句的执行走向,提前加载指令,若预测准确,性能接近无分支,若预测失败,会触发流水线回滚,导致性能下降。
防止分支预测的方法:真实的分支走向通常是随机的,所以测试的时候尽量模拟随机分支,例如使用随机数决定走哪个分支。
6、循环展开:JIT编译器的优化手段,核心目的是减少循环迭代的控制开销,通过将多次循环合并为单次执行,提高代码执行效率。jvm会把小次数、逻辑简单的循环拆开来执行,超大次数循环可能会部分展开,避免代码体积超标。循环展开是jvm真实运行后的优化,通常预热后的代码中会包含该优化,如果用户测试的是业务逻辑的性能,不用关心循环展开,如果测试的是循环控制的性能,那么在循环中写一些复杂的逻辑,可以避免,jvm没有直接的参数来控制循环展开。
7、多线程场景下的特殊优化:
- 锁消除:如果jvm检测到锁对象仅被单线程使用,会删除同步代码块,例如
synchronized (new Object()) - 锁粗化:将多次连续的锁获取 / 释放合并为一次(比如循环内的 synchronized);
锁消除、锁粗化,更多的是锁使用不当。
入门案例
1、添加依赖
xml
<!-- JMH核心代码 -->
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.35</version>
</dependency>
<!-- JMH注解相关依赖 -->
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.35</version>
<scope>provided</scope>
</dependency>
2、需求:测试1000个随机数求和算法的性能
java
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(1)
@State(Scope.Thread)
public class SumBenchmark {
private int[] data;
// 初始化测试数据
@Setup
public void setup() {
data = new int[1000];
for (int i = 0; i < data.length; i++) {
data[i] = ThreadLocalRandom.current().nextInt();
}
}
// 测量求和算法
@Benchmark
public int sum() {
int sum = 0;
for (int i = 0; i < data.length; i++) {
sum += data[i];
}
return sum;
}
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(SumBenchmark.class.getSimpleName())
.build();
new Runner(options).run();
}
}
这个案例中,被@Benchmark标注的方法就是要被测试的方法,这个方法中会对1000个随机数进行累加求和,@Setup方法会在@Benchmark方法之前执行,用于准备数据。
日志解读:运行过程中会产生大量日志,这些日志是理解基准测试的关键
第一部分日志: 环境信息,jmh的版本号、jvm的版本号和运行时参数。这里的参数需要解释一下,
text
# JMH version: 1.37
# VM version: JDK 1.8.0_202, Java HotSpot(TM) 64-Bit Server VM, 25.202-b08
# VM invoker: C:\Program Files\Java\jdk1.8.0_202\jre\bin\java.exe
# VM options: -javaagent:C:\Program Files\JetBrains\IntelliJ IDEA Community Edition 2024.3.5\lib\idea_rt.jar=60572 -Dfile.encoding=UTF-8
# Blackhole mode: full + dont-inline hint (auto-detected, use -Djmh.blackhole.autoDetect=false to disable)
# Warmup: 3 iterations, 1 s each
# Measurement: 5 iterations, 1 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: org.example.benchmark.SumBenchmark.sum
相关参数:
- Blackhole mode:黑洞模式,jvm会进行死代码消除,所谓死代码,就是jvm检测到一段代码的返回值实际上没有被使用,就会把这段代码消除掉,为了避免死代码消除,编写性能测试案例时,可以把返回值投入到Blackhole对象中,这就是黑洞模式
- Warmup:预热,这里配置了预热3次,就是预热过程包含3次迭代,每次迭代1秒
- Measurement:测量,包含5次迭代,每次迭代1秒
- Timeout:超时时间,这里使用的默认值,1次迭代10分钟过后还没有完成,算超时,避免单线程阻塞
- Threads:有几个线程进行测试,这里是1个,将会同步进行迭代
- Benchmark mode:测试模式,这里是平均时间模式,计算目标方法执行一次平均会花费多次时间
第二部分日志: 预热和测量的执行日志,这里配置了3次预热,5次测量,ns/op,"/"是每的一次,这里表示纳秒每操作,表示每次操作花费多少纳秒,纳秒是在测试类上使用OutputTimeUnit注解配置的,还可以支持微妙、毫秒等。
text
# Run progress: 0.00% complete, ETA 00:00:08
# Fork: 1 of 1
# Warmup Iteration 1: 200.884 ns/op
# Warmup Iteration 2: 196.089 ns/op
# Warmup Iteration 3: 197.521 ns/op
Iteration 1: 195.668 ns/op
Iteration 2: 197.721 ns/op
Iteration 3: 199.112 ns/op
Iteration 4: 197.185 ns/op
Iteration 5: 197.200 ns/op
第三部分日志: 结果统计
text
Result "org.example.benchmark.SumBenchmark.sum":
197.377 ±(99.9%) 4.762 ns/op [Average]
(min, avg, max) = (195.668, 197.377, 199.112), stdev = 1.237
CI (99.9%): [192.615, 202.139] (assumes normal distribution)
# Run complete. Total time: 00:00:08
197.377 ±(99.9%) 4.762 ns/op [Average]: 每次操作平均耗时 197.377 纳秒,有99.9%的概率真实值落在这个范围内,误差为 ±4.762 纳秒(min, avg, max) = (195.668, 197.377, 199.112), stdev = 1.237:最小值、平均值、最大值, stdev是标准差,衡量数据波动程度,标准差越小,值越稳定CI (99.9%): [192.615, 202.139] (assumes normal distribution):CI,Confidence Interval(置信区间),置信水平(99.9%的概率真实值落在此区间),后面是区间范围# Run complete. Total time: 00:00:08:测试运行了8秒钟
第四部分日志:
text
REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on
why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial
experiments, perform baseline and negative tests that provide experimental control, make sure
the benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts.
Do not assume the numbers tell you what you want them to tell.
记住:以下数字仅为数据。要获得可重复使用的见解,你需要进一步探究这些数字背后的原因。使用性能分析工具(如-prof、-lprof),设计因子实验,执行基线和负面测试以提供实验对照,确保基准测试环境在JVM/操作系统/硬件层面都是安全的,并请领域专家进行评审。不要以为数字会告诉你你想听到的信息。
第五部分日志:结果统计
text
Benchmark Mode Cnt Score Error Units
SumBenchmark.sum avgt 5 197.377 ± 4.762 ns/op
每一列的含义:
- Mode:执行模式,这里是平均时间模式,
- Cnt:测量次数
- Score:结果,和后面的Unit一起看,这里表示每操作1次花费197.377纳秒
- Error:统计误差
- Units:单位
总结:入门案例中展示了一个最简单的性能统计案例,并且介绍了执行过程中的相关日志,接下来详细介绍jmh的相关特性。
基本概念
预热:由于jvm中有jit编译器,同一个方法,经过编译后,执行时间会有所提升,通常只考虑jit编译后的性能,预热是为了让jvm对被测试代码做足够多的优化,预热不会被放到统计结果中。
测量:在预热之后进行,是实际测量性能的阶段
迭代:JMH的一次测量单位,例如,1次测量过程中会进行5次迭代,jmh会在一个迭代内尽可能多的运行目标方法,最后统计它的性能
相关API
目标方法 @Benchmark
注解在方法上,表示需要被测试的方法,每个benchMark方法都运行在独立的进程中,互不干涉
压测选项 OptionsBuilder
对测试进行配置,主要是配置主类、结果报告格式等,上面的注解也可以使用Builder类中提供的选项来配置
配置案例:
java
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
// 配置运行时的主类,注意这里配置的是模式,测试类的名称不要类似。如果当前名称可以
// 匹配到多个测试类,多个测试类都会运行
.include(SumBenchmark.class.getSimpleName())
.build();
new Runner(options).run();
}
测试模式 @BenchmarkMode
基准测试的模式,可选的模式有:
- 平均时间:Node.AverageTime,1次操作的平均时间,时间单位由用户指定
- 吞吐量:Node.Throughput,1个单位时间内的操作次数,和平均时间正好相反,平均时间是1次操作耗费多久的时间,吞吐量是单位时间内能执行多少次操作
- 随机取样:Node.SampleTime,它不会测量每次执行的时间,而是在测试时间内,随机采用操作的执行时间,适合观察性能分布和长尾效应
- 单次运行:Node.SingleShotTime,只运行一次,往往同时把 Warmup 次数设为 0,用于测试冷启动时的性能
- all:以上全部,程序会执行多次,每次执行不同的模式。
什么时候选平均时间模式?什么时候选吞吐量模式?
- 需要知道操作延迟,用平均时间模式
- 需要知道处理能力,用吞吐量模式
- 需要测量稳定性,用随机取样模式
- 需要测试冷启动时间,用单次运行模式
案例1:把入门案例改为随机取样模式,打印的日志如下
text
Benchmark Mode Cnt Score Error Units
SumBenchmark.sum sample 192727 237.251 ± 9.027 ns/op
SumBenchmark.sum:p0.00 sample 200.000 ns/op
SumBenchmark.sum:p0.50 sample 200.000 ns/op
SumBenchmark.sum:p0.90 sample 300.000 ns/op
SumBenchmark.sum:p0.95 sample 300.000 ns/op
SumBenchmark.sum:p0.99 sample 300.000 ns/op
SumBenchmark.sum:p0.999 sample 1700.000 ns/op
SumBenchmark.sum:p0.9999 sample 46294.938 ns/op
SumBenchmark.sum:p1.00 sample 236800.000 ns/op
日志内容解读:
- Benchmark:结果区间分布,例如,SumBenchmark.sum:p0.99,表示99%的操作都在300毫秒内。
- Cnt:采样次数
- score:平均耗时,和后面的units一起看
- error:误差范围
日志中展出出的信息,表示99%的执行都可以在300纳秒内完成,但是最慢的有236800纳秒,可能这段时间内有gc等操作,需要进一步分析。
案例2:把入门案例改为只运行一次,打印的日志如下
text
Benchmark Mode Cnt Score Error Units
SumBenchmark.sum ss 5 9440.000 ± 11152.252 ns/op
cnt,表示它只运行了5次,平均执行时间是9440纳秒,误差范围也很大,这里测试的是冷启动的性能。
时间单位 @OutputTimeUnit
结果报告中的时间单位,可以配置毫秒、微妙、纳秒等,例如入门案例中,配置的时间单位是纳秒,而且模式是平均时间模式,就表示一次操作需要花费多少纳秒
时间单位介绍:
- 1秒 = 1000毫秒 milli seconds
- 1毫秒 = 1000微秒 micro seconds
- 1微妙 = 1000纳秒 nano seconds
预热 @Warmup
配置案例:@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) , 这里是迭代5次,每次1秒
测量 @Measurement
配置案例:@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) ,和预热一样的参数
进程 @Fork
表示启动几个进程进行测试,如果配置多个,程序会多次运行,并且每次都运行在独立的进程中,这么做可以消除JVM状态的影响,获得更稳定的结果。它还可以指定jvm参数,保证测试环境的一致性
配置案例:@Fork(value = 3, jvmArgsAppend = {"-Xmx2048m", "-server", "-XX:+AggressiveOpts"}),配置启动3个进程测试,同时支持jvm启动时的参数
线程 @Threads
启动几个线程测试
线程组 @Group 和 @GroupThreads
更进一步的归类测试案例,把某些测试案例放到一个线程组内运行并且指定线程数
状态 @State
指定了在类中变量的作用范围。它有三个取值:
- Benchmark:表示变量的作用范围是某个基准测试类
- Thread:每个线程一份副本,它们互不影响
- Group:线程组
参数 @Param
@Param 注解只能修饰字段,用来测试不同的参数,对程序性能的影响。配合@State注解,可以同时制定这些参数的执行范围。对每个参数值运行完整的基准测试,便于分析算法在不同规模下的表现。
配置案例:
java
@Param({"100", "1000", "10000"})
private Integer limit;
预处理和销毁 @Setup和@TearDown
@Setup用于基准测试前的初始化动作, @TearDown用于基准测试后的动作,来做一些全局的配置。
它们支持配置不同的参数来决定初始化和销毁的运行时机:
- Trial:默认的级别。也就是Benchmark级别。
- Iteration:每次迭代都会运行。
- Invocation:每次方法调用都会运行,这个是粒度最细的。
循环处理 @OperationsPerInvocation
注解在包含循环的方法上,指明循环次数,这样的话,jmh就会测试循环中单次操作的性能,而不是整个循环体的性能。
案例:入门案例的优化
java
@Benchmark
// 数组的长度是1000,所以这里@OperationsPerInvocation的参数是1000
@OperationsPerInvocation(1000)
public long sum() {
long sum = 0;
for (int i = 0; i < data.length; i++) {
sum += data[i];
}
return sum;
}
测量结果中的单位
通常是 xx/yy 的格式,这里已ns/op为例讲解。
ns/op:代表 纳秒每操作 nanoseconds per operation,"/"表示"每",表示在当前的测试运行中,执行一次目标操作(op)的平均耗时,耗时单位是纳秒。一次操作通常对应一次对基准方法的调用
- ns/op = 纳秒 / 操作 = 每个操作消耗的纳秒数(时间)
- op/ns = 操作 / 纳秒 = 每纳秒完成的操作数(速率)
默认值
如果不指定@Fork、@Warmup、@Measurement的值,那么他们的默认值是多少?
- @BenchmarkMode:默认吞吐量模式
- @OutputTimeUnit:默认秒
- @Fork:默认开启5个进程测试
- @Warmup:默认预热5次迭代,每次10秒
- @Measurement:同上
实战案例
测试单次字符串拼接的性能
java
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 10, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS)
@Fork(1)
@State(Scope.Benchmark)
public class StringSingleConcatBenchmarkTest {
@Param("str1")
private String str1;
@Param("str2")
private String str2;
@Param("str3")
private String str3;
@Benchmark
public String testStringBuilder() {
StringBuilder builder = new StringBuilder();
builder.append(str1).append(str2).append(str3);
return builder.toString();
}
@Benchmark
public String testStringConcat() {
return str1 + str2 + str3;
}
@Benchmark
public String testStringConcatMethod() {
return str1.concat(str2).concat(str3);
}
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(StringSingleConcatBenchmarkTest.class.getSimpleName())
.build();
new Runner(options).run();
}
}
测试结果:
text
Benchmark (str1) (str2) (str3) Mode Cnt Score Error Units
StringSingleConcatBenchmarkTest.testStringBuilder str1 str2 str3 avgt 5 23.418 ± 32.468 ns/op
StringSingleConcatBenchmarkTest.testStringConcat str1 str2 str3 avgt 5 17.541 ± 12.663 ns/op
StringSingleConcatBenchmarkTest.testStringConcatMethod str1 str2 str3 avgt 5 38.573 ± 35.002 ns/op
从结果中可以看出,字符串直接相加的效率是最高的。
测试批量字符串拼接的性能
java
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 10, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS)
@Fork(1)
@State(Scope.Benchmark)
public class StringConcatBenchmarkTest {
@Param({"1000"})
private int limit;
@Benchmark
public String testStringBuilder() {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < limit; i++) {
builder.append("a");
}
return builder.toString();
}
@Benchmark
public String testStringConcat() {
String s = "";
for (int i = 0; i < limit; i++) {
s += "a";
}
return s;
}
@Benchmark
public String testStringConcatMethod() {
String s = "";
for (int i = 0; i < limit; i++) {
s = s.concat("a");
}
return s;
}
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(StringConcatBenchmarkTest.class.getSimpleName())
.build();
new Runner(options).run();
}
}
结果:
text
Benchmark (limit) Mode Cnt Score Error Units
StringConcatBenchmarkTest.testStringBuilder 1000 avgt 5 6006.248 ± 1673.073 ns/op
StringConcatBenchmarkTest.testStringConcat 1000 avgt 5 118461.190 ± 19836.092 ns/op
StringConcatBenchmarkTest.testStringConcatMethod 1000 avgt 5 126326.434 ± 8414.700 ns/op
从结果中看,批量拼接的情况下,StringBuilder的性能最高
测试ConcurrentHashmap的性能
需求:测试 ConcurrentHashMap 和 Collections.synchronizedMap(new HashMap<>()) 的性能对比。
java
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS)
@Fork(1)
@State(Scope.Group)
public class ConcurrentHashMapBenchmarkTest {
@Param({"sync", "concurrent"})
private String mode;
private Map<String, String> map;
private List<String> keyList;
@Param({"100", "10000"})
private Integer keyListLen;
// 每次迭代前执行
@Setup(Level.Iteration)
public void setupIteration() {
if ("sync".equals(mode)) {
map = Collections.synchronizedMap(new HashMap<>());
} else {
map = new ConcurrentHashMap<>();
}
keyList = new ArrayList<>();
for (int i = 0; i < keyListLen; i++) {
keyList.add(String.valueOf((int)(Math.random() * 10000)));
}
}
// 每次迭代后执行
@TearDown(Level.Iteration)
public void tearDown() {
map = null;
keyList = null;
}
@Benchmark
@Group("mixed")
@GroupThreads(4)
public void writer() {
String key = keyList.get((int) ((Math.random() * 10000)) % keyListLen);
map.put(key, "value");
}
@Benchmark
@Group("mixed")
@GroupThreads(4)
public String reader() {
String key = keyList.get((int) ((Math.random() * 10000)) % keyListLen);
return map.get(key);
}
// 衡量生成随机数的性能
@Benchmark
@Group("baseLine")
@GroupThreads(1)
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
public String baseLine() {
return keyList.get((int) ((Math.random() * 10000)) % keyListLen);
}
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(ConcurrentHashMapBenchmarkTest.class.getSimpleName())
.build();
new Runner(options).run();
}
}
结果:
text
Benchmark (keyListLen) (mode) Mode Cnt Score Error Units
ConcurrentHashMapBenchmarkTest.baseLine 100 sync avgt 5 20.412 ± 0.323 ns/op
ConcurrentHashMapBenchmarkTest.baseLine 100 concurrent avgt 5 20.368 ± 0.346 ns/op
ConcurrentHashMapBenchmarkTest.baseLine 10000 sync avgt 5 24.405 ± 0.284 ns/op
ConcurrentHashMapBenchmarkTest.baseLine 10000 concurrent avgt 5 24.468 ± 0.261 ns/op
ConcurrentHashMapBenchmarkTest.mixed 100 sync avgt 5 1175.707 ± 310.686 ns/op
ConcurrentHashMapBenchmarkTest.mixed:reader 100 sync avgt 5 1148.910 ± 351.959 ns/op
ConcurrentHashMapBenchmarkTest.mixed:writer 100 sync avgt 5 1202.503 ± 270.637 ns/op
ConcurrentHashMapBenchmarkTest.mixed 100 concurrent avgt 5 692.956 ± 132.756 ns/op
ConcurrentHashMapBenchmarkTest.mixed:reader 100 concurrent avgt 5 641.630 ± 162.887 ns/op
ConcurrentHashMapBenchmarkTest.mixed:writer 100 concurrent avgt 5 744.282 ± 114.159 ns/op
ConcurrentHashMapBenchmarkTest.mixed 10000 sync avgt 5 1672.630 ± 166.980 ns/op
ConcurrentHashMapBenchmarkTest.mixed:reader 10000 sync avgt 5 1612.522 ± 201.286 ns/op
ConcurrentHashMapBenchmarkTest.mixed:writer 10000 sync avgt 5 1732.738 ± 152.184 ns/op
ConcurrentHashMapBenchmarkTest.mixed 10000 concurrent avgt 5 1037.654 ± 224.151 ns/op
ConcurrentHashMapBenchmarkTest.mixed:reader 10000 concurrent avgt 5 988.915 ± 410.019 ns/op
ConcurrentHashMapBenchmarkTest.mixed:writer 10000 concurrent avgt 5 1086.393 ± 76.069 ns/op
结果分析:这里对比了ConcurrentHashMap 和 Collections.synchronizedMap(new HashMap<>())的性能,无论读或写,前者的性能均要优于后者。
性能测试的目的,在于模拟真实场景,分析代码在不同场景下的性能,为优化提供指导,所以需要设计可靠的测试流程,模拟真实场景,关键在于分析真实情况下会出现的场景。
这里测试的是这两种map在并发读写下的性能,@Group相同的测试方法会一起运行,同时@GroupThreads指定了他们在多少线程下并发运行。这里的测试数据中,如果keyList比较大,那么并发读写时出现冲突的概率就比较小,反之亦然,所以这里测试了,key冲突比较大和key冲突比较小的情况下,两种map的性能表现,此外,还可以测试一下map中有数据时的性能表现,或者在纯粹读、纯粹写场景下的性能表现,这里不演示了。
因为测试方法中需要额外生成一个随机数,所以增加一个baseLine方法,测试生成随机数的性能,减掉生成随机数的时间,就是map.get或map.put的真正执行时间。
测试归并排序
java
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 10, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS)
@Fork(value = 1, jvmArgs = {"-server", "-Xms2g", "-Xmx2g"})
@State(Scope.Benchmark)
public class MergeSortBenchmarkTest {
// 测试的不同数组大小
@Param({"10000"})
private int arraySize;
// 在@Setup中添加不同数据生成策略
@Param({"RANDOM", "SORTED", "REVERSE_SORTED", "NEARLY_SORTED"})
private String dataType;
// 原始数组(未排序),用于每次测试
private Integer[] originalArray;
// 用于排序的工作数组
private Integer[] workArray;
// 随机数生成器
private final Random random = new Random(42); // 固定种子保证可重复性
@Setup(Level.Invocation)
public void setup() {
originalArray = new Integer[arraySize];
switch (dataType) {
case "SORTED":
for (int i = 0; i < arraySize; i++) originalArray[i] = i;
break;
case "REVERSE_SORTED":
for (int i = 0; i < arraySize; i++) originalArray[i] = arraySize - i;
break;
case "NEARLY_SORTED":
for (int i = 0; i < arraySize; i++) originalArray[i] = i;
// 随机交换10%的元素
for (int i = 0; i < arraySize / 10; i++) {
int a = random.nextInt(arraySize);
int b = random.nextInt(arraySize);
Integer temp = originalArray[a];
originalArray[a] = originalArray[b];
originalArray[b] = temp;
}
break;
default: // RANDOM
for (int i = 0; i < arraySize; i++) {
originalArray[i] = random.nextInt(arraySize * 10);
}
}
workArray = Arrays.copyOf(originalArray, originalArray.length);
}
@TearDown(Level.Invocation)
public void tearDown() {
// 重置工作数组
System.arraycopy(originalArray, 0, workArray, 0, originalArray.length);
}
@Benchmark
public void mergeSortBenchmark(Blackhole bh) {
Integer[] copy = Arrays.copyOf(workArray, workArray.length);
// 执行归并排序
MergeSort.mergeSort(copy, 0, copy.length - 1);
// 使用Blackhole消费排序结果,防止死代码消除
bh.consume(workArray);
}
@Benchmark
public void arraysSortBenchmark(Blackhole bh) {
Integer[] copy = Arrays.copyOf(workArray, workArray.length);
// 作为对比:测试Java标准库的排序性能
Arrays.sort(copy);
bh.consume(copy);
}
@Benchmark
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
public void baseLine(Blackhole bh) {
Integer[] copy = Arrays.copyOf(workArray, workArray.length);
bh.consume(copy);
}
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(MergeSortBenchmarkTest.class.getSimpleName())
.build();
new Runner(options).run();
}
}
结果:
text
Benchmark (arraySize) (dataType) Mode Cnt Score Error Units
MergeSortBenchmarkTest.arraysSortBenchmark 10000 RANDOM avgt 5 845877.785 ± 139488.719 ns/op
MergeSortBenchmarkTest.arraysSortBenchmark 10000 SORTED avgt 5 21376.182 ± 828.385 ns/op
MergeSortBenchmarkTest.arraysSortBenchmark 10000 REVERSE_SORTED avgt 5 35127.219 ± 4101.048 ns/op
MergeSortBenchmarkTest.arraysSortBenchmark 10000 NEARLY_SORTED avgt 5 568896.141 ± 151698.628 ns/op
MergeSortBenchmarkTest.baseLine 10000 RANDOM avgt 5 3522.896 ± 1701.201 ns/op
MergeSortBenchmarkTest.baseLine 10000 SORTED avgt 5 3380.262 ± 905.378 ns/op
MergeSortBenchmarkTest.baseLine 10000 REVERSE_SORTED avgt 5 3353.667 ± 697.466 ns/op
MergeSortBenchmarkTest.baseLine 10000 NEARLY_SORTED avgt 5 3644.086 ± 1006.559 ns/op
MergeSortBenchmarkTest.mergeSortBenchmark 10000 RANDOM avgt 5 1118970.188 ± 73475.251 ns/op
MergeSortBenchmarkTest.mergeSortBenchmark 10000 SORTED avgt 5 352875.918 ± 125084.586 ns/op
MergeSortBenchmarkTest.mergeSortBenchmark 10000 REVERSE_SORTED avgt 5 421989.538 ± 391632.784 ns/op
MergeSortBenchmarkTest.mergeSortBenchmark 10000 NEARLY_SORTED avgt 5 706078.545 ± 260808.508 ns/op
这个案例是deepseek给写的,比较值得学习的是测试数据的构造,同时指定了jvm的运行参数,保证测试环境的稳定。同样的,因为benchmark方法中额外有数组复制的逻辑,所以单独测一下数组复制的耗时,总耗时 - 数组复制耗时,就是排序方法的耗时。
springboot整合jmh
java
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 10, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS)
@Fork(value = 1, jvmArgs = {"-server", "-Xms2g", "-Xmx2g"})
@State(Scope.Benchmark)
public class SpringBootBenchmarkTest {
private UserService userService;
@Setup(Level.Trial)
public void setup() {
ConfigurableApplicationContext context = SpringApplication.run(App.class);
userService = context.getBean(UserService.class);
}
@Benchmark
public void userServiceTest(Blackhole bh) {
List<User> all = userService.findAll();
// 使用Blackhole消费排序结果,防止死代码消除
bh.consume(all);
}
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(SpringBootBenchmarkTest.class.getSimpleName())
.build();
new Runner(options).run();
}
}
原理很简单,在性能测试中启动spring容器即可。
测试结果:
text
Benchmark Mode Cnt Score Error Units
SpringBootBenchmarkTest.userServiceTest avgt 5 5867.639 ± 3211.161 ns/op
生成json报告 图形化结果
编写测试案例时指定报告的结果:1、是json格式,2、指定结果文件的名称
java
public static void main(String[] args) throws RunnerException {
String report = System.currentTimeMillis() + "-jmhReport.json";
Options opt = new OptionsBuilder()
.include(StringSingleOperationBenchmark.class.getSimpleName())
.result(report)
.resultFormat(ResultFormatType.JSON)
.build();
new Runner(opt).run();
}
图形化工具: https://deepoove.com/jmh-visual-chart/
把结果文件上传到这个网址,就可以查看图形化结果
问题
测试代码有异常会怎么样?
会打断迭代的执行,然后继续进行下一次迭代,所以迭代都执行完成之后,没有性能统计结果,注意从堆栈信息中分析报错位置,测试类_测试方法_jmhTest.测试方法_测试模式_jmhStub,报错的类名的模式。
在测试案例中打印hello world
这会导致测试过程中的运行日志完全无法查看,但不影响最终结果的查看
什么时候适合使用 @Setup(Level.Invocation)
这个配置的含义是,在每次调用目标方法前都执行setUp方法,很多时候都不推荐使用这个方法,但具体原因是什么?这个配置什么时候使用比较合适?
首先说一下这个配置的作用:它会在每次调用测试方法前都执行,此时,jmh测量的是"setup + benchmark"的总时间
为什么@Setup(Level.Invocation)通常不应该使用?
- 测量失真:包含setup开销,不是纯算法时间
- 优化破坏:阻止JIT编译器进行关键优化
- 缓存效应:每次重置状态破坏缓存局部性
- 不现实:真实场景很少需要每次操作前完全重置状态
- Invocation级别的setup会显著增加开销,可能掩盖被测试代码的真实性能,产生误导性的基准测试结果
如果基准测试是为了测量算法本身的性能(如排序、搜索、计算),那么不要使用@Setup(Level.Invocation)。如果基准测试是为了测量包含初始化的完整操作流程,那么考虑使用它,但要明确知道在测量什么。基准测试的目标不是测量最快可能的时间,而是获得可重复、有意义、能指导优化的数据。@Setup(Level.Invocation)通常违背这些原则。
测试方法的执行机制
java
启动JMH Runner、解析注解和配置
for (每个@Param值) {
for (每个Fork进程) {
启动独立JVM进程
for (每次Warmup迭代) {
执行预热(结果不计入统计)
}
for (每次Measurement迭代) {
@Setup(Level.Iteration)
执行@Benchmark方法多次(由JMH控制)
@TearDown(Level.Iteration)
}
收集统计结果
关闭JVM进程
}
}
汇总所有结果,生成报告