JMH的使用
1. 与JMeter的区别
1.1 核心区别和选择
| 特性 | JMH | JMeter |
|---|---|---|
| 测试类型 | 微基准测试(Microbenchmark) | 负载测试、压力测试(Load/Stress Test) |
| 测试粒度 | 方法级别,代码级 | 系统级别,接口/服务级 |
| 测试精度 | 毫秒级/微秒级 | 秒级 |
| 主要用途 | 测试和优化代码片段的性能 | 评估系统在高并发下的整体性能 |
| 执行方式 | 在JVM内部运行,需要编写Java代码 | 从外部模拟用户请求,通过GUI(图形用户界面)配置 |
| 典型问题 | ArrayList和LinkedList的遍历哪个更快 | 我的网站能否承受 1000 个用户同时访问? |
1.2 核心用途的区别
-
JMH:是由 OpenJDK 团队开发的一款专门用于 Java 代码微基准测试 的工具,用于测量和比较一小段代码/片段的性能,通过一系列复杂的机制(如预热、多线程隔离等)等手段来避免JVM的即时编译(JIT)优化、GC垃圾回收等因素对测试结果的干扰,从而保证测试结果的精确。
-
JMeter :是Apache组织开发的一款开源的负载和性能测试工具 ,通过模拟大规模用户的访问,对服务器、网络或对象施加压力,从而测试在搞负载下系统的强度、稳定性和整体性能。它关注的是系统级别的指标,如吞吐量、响应时间和错误率等。
1.3 适用场景的区别
1.3.1 JMH的适用场景
-
热点代码的优化 :如可通过JMH进行性能测试,选择性能优秀的方案优化热点代码(利用StringBuffer.append()还是String之间用"+“来拼接字符串)。
-
算法比较 :比较不同算法(排序算法、数据结构)在相同输入下的性能表现。
-
库/框架的选择 在多个功能相似的库/框架(如JSON序列化)中进行选择时,可通过JVM来进行性能测试并比较。
-
JVM特性分析:分析JIT编译、方法内敛等JVM优化策略对代码性能的影响。
1.3.2 JMeter的使用场景
-
Web应用/API性能测试 :对网站、RESTFUL API等服务进行压力测试,确定其能承受的最大并发数,找出其性能瓶颈。
-
数据库性能测试 :通过JMeter模拟大量用户,访问JDBC连接器相关的接口,从而模拟大量数据库增删改查操作,评估数据库性能。
-
分布式测试 :在多台机器上分别部署JMeter对同一个接口进行测试,从而产生远超单机能力的并发负载,模拟更真实的用户场景。
-
功能与回归测试 :虽然主要用于性能测试,但JMeter也支持通过断言等功能进行接口的功能验证和回归测试。
1.3.3 JMH和JMeter的关系
JMH和JMeter是互补而非竞争的工具,在开发阶段可以使用JMH来优化关键代码的性能;在系统集成或上线前,则可以使用JMeter来验证整个系统在高负载下的表现是否达标。
2. JMH的基本使用
2.1 测试类(必须)
2.1.1 测试类的注解
首先需要编写用于JMH测试的测试类,并在该类上加上JMH相关的注解,常见注解有:
-
@BenchmarkMode :用于指定JMH测试的性能,主要有四种:
-
Throughput (吞吐量),即单位时间内执行操作的次数。
-
AverageTime (平均时间),即执行一次操作使用的平均时间 。
-
SampleTime (采样时间),采用随机采样的方式,输出性能数据的分布情况,可得出有百分之多少的样本的执行时间在百分之多少之内。
-
SingleShotTime (单次执行时间),只进行一次测试(与AverageTime 不同,它虽然也是测量一次操作,但却是多次测量 ),通常用于测试冷启动的性能,不进行预热(warmup次数设置为0)。
-
-
@OutputTimeUnit :输出结果的时间单位。
-
@Fork :启用多少个独立线程进行测试(默认是串行执行,而不是同时并行执行。即上一个Fork执行完,才会继续执行下一个Fork)。
-
@Measurement:测试进行的次数,其中:
-
iterations :表示整个测试阶段(如测量或预热)的“轮数”
-
batchSize :表示每轮迭代中连续调用基准方法的次数
-
-
@warmup :预热的轮数,如指定(iterators = 3)。与 @Measurement类似,也可指定batchSize
-
@State :用于管理测试实例的生命周期,常见属性有:
-
Scope.Thread :表示每个测试用的线程共享同一个实例,线程之间互不影响(@State的默认值)
-
@Scope.Benchmark:所有测试用的线程共享一个实例。适用于测试有状态实例在多线程共享下的性能。
-
@Scope.Group:每个线程组共享一个实例,常用于分组测试。
-
此外,使用 @State注解的类必须满足两个条件:类必须是public的,必须有无参构造方法 。
-
-
@Setup :用于定义初始化方法,常见属性包含:
-
Level.Trial (默认值) :在每次试验 的开始前执行。一个“试验”即一个Fork进程的完整运行。
-
Level.Iteration :在每次迭代 的开始前执行。包括预热迭代和测量迭代。
-
Level.Invocation :在每次
@Benchmark方法调用 之前执行。
-
例子,如:
@BenchmarkMode(Mode.Throughput) //设置模式为测试吞吐量(单位时间执行多少次操作)
@OutputTimeUnit(TimeUnit.SECONDS) //测试结果的时间单位
@Fork(3) //fork多少个线程用于测试
@State(Scope.Thread) //一个线程共享一个实例
@Warmup(iterations = 3) //预热轮数
@Measurement(iterations = 3) //测试的次数
2.1.2 注解的注意点
-
实际总测试次数的公式为:fork × (measurement.iterations × batchSize)
-
结果显示的测试次数Cnt 等于:fork × measurement.iterations
-
如果
batchSize > 1,JMH 会在单次迭代中执行多次方法调用,但仍将整批调用的聚合结果(如平均时间)作为一个数据点。因为batchSize不直接增加Cnt,而是提高单个数据点的精度。
2.1.3为什么 Cnt ≠ fork × iterations × batchSize?
-
batchSize的作用是减少短耗时方法的测量误差(如通过批量调用取平均值),但不增加统计样本量。 -
fork和iterations 决定了数据点的数量,而batchSize决定了每个数据点的计算方式。
2.2 初始化方法(可选)
如果需要在测试之前先做一些初始化操作,则可以使用 @Setup注解使用在初始化方法上,如:
@Setup
public void init() {
//二者不是数组,初始化不一定要指定长度
arrayList = new ArrayList<>(0);
linkedList = new LinkedList<>();
for (int i=1;i<=n;i++) {
arrayList.add(i);
linkedList.add(i);
}
}
2.3 执行基准测试方法(必须)
在需要测试的方法上加上 @Benchmark注解,一个测试类当中可以有多个方法使用该注解,即可以有多个测试的方法,如:
@Benchmark
public void testArrayList() {
for (int i=0;i<n;i++) {
arrayList.get(i);
}
}
@Benchmark
public void testLinkedList() {
for (int i=0;i<n;i++) {
linkedList.get(i);
}
}
2.4 执行结束方法(可选)
在JMH中,@TearDown 注解用于标记在基准测试执行之后需要运行的方法,主要用于资源的回收与清理工作,通常与 @SetUp 注解配对使用 ,@SetUp 用于测试前的初始化,@TearDwon 用于测试之后的清理。@TearDown 注解的方法执行时机由Level参数控制,支持以下三种粒度:
-
Level.Trial :(Tiral中文翻译为试验)表示在所有测试迭代结束之后执行一次,适合做全局资源释放等一次性操作。
-
Level.Iteration :在每次迭代开始前和结束后执行 ,适合每次迭代的准备和清理工作
-
Level.Invocation :(Invocation中文翻译为调用)在每次方法调用前后执行 ,适合做方法粒度的资源管理。
2.5 完整例子
package jmh;
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;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.concurrent.TimeUnit;
/**
* JMH体验:通过JMH来证明ArrayList的遍历效率高于LinkedList
*/
@BenchmarkMode(Mode.Throughput) //设置模式为测试吞吐量(单位时间执行多少次操作)
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(3)
@State(Scope.Thread)
@Warmup(iterations = 3)
@Measurement(iterations = 3,batchSize = 2)
public class JMHTest {
@Param({"10","20","50","100"})
private int n;
private ArrayList<Integer> arrayList;
private LinkedList<Integer> linkedList;
@Setup
public void init() {
//二者不是数组,初始化不一定要指定长度
arrayList = new ArrayList<>(0);
linkedList = new LinkedList<>();
for (int i=1;i<=n;i++) {
arrayList.add(i);
linkedList.add(i);
}
}
@Benchmark
public void testArrayList() {
for (int i=0;i<n;i++) {
arrayList.get(i);
}
}
@Benchmark
public void testLinkedList() {
for (int i=0;i<n;i++) {
linkedList.get(i);
}
}
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder().include(JMHTest.class.getSimpleName()).build();
new Runner(options).run();
}
}
3. setup(初始化方法)的执行时机和次数
MH中有几个关键的参数和注解会直接影响 @Setup方法的执行时机和次数。其核心逻辑是:@Setup方法的执行频率由其 level参数决定,而执行次数则受 Fork、线程数等参数影响。
以下是详细的分解说明:
3.1 最核心的参数:@Setup注解的 level
这是决定 setup()方法执行时机的最直接、最根本的因素。它定义了“一次性准备”的粒度。
| Level 级别 | 执行时机 | 受影响的其他JMH参数 | 原因 |
|---|---|---|---|
Level.Trial (默认值) |
在每次试验 的开始前执行。一个“试验”即一个Fork进程的完整运行。 | **@Fork** |
因为每个Fork都是一个独立的JVM进程,代表一次全新的试验。Trial级别的Setup需要为这次完整的试验做准备。 |
Level.Iteration |
在每次迭代 的开始前执行。包括预热迭代和测量迭代。 | @Warmup、**@Measurement** |
因为Iteration级别的Setup定义于“每次迭代前”,所以迭代的总次数(预热+测量)直接决定了它的执行次数。 |
Level.Invocation |
在每次@Benchmark方法调用 之前执行。 |
**(基准方法调用次数)** | 这是最细的粒度,旨在为每次方法调用都提供一个全新的状态。但由于Setup本身有开销,会严重干扰性能测量,故极少使用。 |
简单比喻:
-
Trial: 在整场考试开始前,一次性发完所有草稿纸。 -
Iteration: 在每道大题开始前,发一张新的草稿纸。 -
Invocation: 在计算每个小题前,都换一张新草稿纸。
3.2 影响执行次数的JMH测试参数
以下参数会与 level相互作用,共同决定 setup()的总执行次数。
a. @Fork (最重要的参数之一)
-
作用: 指定要启动的独立JVM进程的数量。每个Fork都是一个全新的基准测试运行,拥有全新的状态实例。
-
影响:
-
对于
Level.Trial: 每个Fork都会执行一次setup()。总次数 =value参数(默认1)。 -
对于
Level.Iteration和Level.Invocation: Fork数会乘以该Level对应的执行次数。
-
原因: Fork的目的是消除JVM的JIT编译、类加载等因素对前一次运行的影响,确保每次试验都在“冷”状态下开始。因此,每个Fork都需要独立进行初始化。
b. @Threads (或 -t命令行参数)
-
作用: 指定每个Fork中并发执行的线程数。
-
影响: 仅当
@State(Scope.Thread)时才有影响。-
对于
Scope.Thread状态: JMH会为每个线程创建一份状态的独立副本。因此,setup()会在每个线程中独立执行。总次数 =@Fork值 * @Threads值。 -
对于
Scope.Benchmark状态: 整个Fork共享一个状态实例,因此无论多少线程,setup()每个Fork只执行一次。
-
原因: Scope.Thread保证了线程安全,每个线程需要自己的初始化数据,避免竞争条件。
c. @Warmup和 @Measurement
-
作用:
iterations参数分别指定了预热迭代和测量迭代的次数。 -
影响: 主要影响
Level.Iteration。-
对于
Level.Iteration:setup()的总执行次数 =@Fork值 * @Threads值 * (@Warmup迭代数 + @Measurement迭代数)。 -
对其他Level无直接影响。
-
原因: Iteration级别的Setup需要在每次迭代(无论是预热还是测量)前都重置状态。
d. @State的 Scope
-
作用: 定义状态实例的作用域。
-
影响:
-
Scope.Thread: 状态是线程本地的。setup()的执行次数与线程数相关(见上文)。 -
Scope.Benchmark: 状态在所有线程间共享。每个Fork只会有一个状态实例,因此setup()每个Fork只执行一次,无论有多少线程。 -
Scope.Group: 状态在线程组内共享,情况更复杂,但不常用。
-
