Skip to main content

Why JMH?

This guide uses JMH (Java Microbenchmark Harness), the standard benchmarking framework for the JVM. JMH is developed as part of the OpenJDK project by the same engineers who build the JVM itself, so it understands JVM internals like JIT compilation, dead code elimination, and constant folding that can silently invalidate naive benchmarks. It handles warmup, fork isolation, and statistical analysis out of the box so you can focus on writing the code you want to measure.
This guide covers Maven and Gradle. JMH also works with SBT.

Your First Benchmark

Let’s start with the simplest possible JMH benchmark: a single method that measures how fast a recursive Fibonacci function runs.

Project Setup

The recommended way to use JMH with Maven is through its archetype, which generates a project pre-configured with the annotation processor and uber-JAR packaging:
terminal
This creates a my-benchmarks/ directory with the following structure:
my-benchmarks
pom.xml
src/main/java/com/example
MyBenchmark.java
The generated pom.xml includes jmh-core (the runtime library), jmh-generator-annprocess (the annotation processor that generates benchmark harness code at compile time), and maven-shade-plugin (packages everything into a single executable benchmarks.jar).
Do not add jmh-core to an existing project without the annotation processor. JMH needs to generate synthetic benchmark code at compile time. The archetype (Maven) and plugin (Gradle) handle this correctly.

Writing the Benchmark

The archetype generates a stub MyBenchmark.java with an empty @Benchmark method. Open src/main/java/com/example/MyBenchmark.java and replace its contents with:
src/main/java/com/example/MyBenchmark.java
That’s it. @Benchmark is the only annotation you need. JMH generates the measurement harness around it. The method returns its result, which prevents the JVM from eliminating the computation as dead code (more on this in avoiding common pitfalls).

Building and Running

Build the uber-JAR and run the benchmark:
terminal
This will take about 8 minutes. JMH defaults are thorough: 5 forked JVMs, each running 5 warmup + 5 measurement iterations of 10 seconds. For a faster first run, add flags to reduce the iteration count:
terminal
These flags and annotations are explained in Configuring Your Benchmark.
You should see output like this:
terminal
Without any configuration, JMH automatically warmed up the JIT compiler across 5 separate JVM processes, collected 25 measurement iterations (5 per fork), and computed a tight 99.9% confidence interval. The default mode is Throughput (thrpt), measured in operations per second.
Understanding the results:
  • Mode: The benchmark mode (thrpt = throughput, operations per second).
  • Cnt: Total measurement iterations across all forks (5 forks x 5 iterations = 25).
  • Score: The measured value (higher is better for thrpt).
  • Error: The 99.9% confidence interval margin. The true value lies within Score ± Error with 99.9% confidence.
  • Units: ops/s = operations per second.

Configuring Your Benchmark

The previous benchmark used all JMH defaults. In practice, you want to embed settings into your benchmark class using annotations. This makes benchmarks self-describing and reproducible regardless of how they are invoked. Update MyBenchmark.java:
src/main/java/com/example/MyBenchmark.java
Rebuild and run. No flags needed, everything is in the annotations:
terminal
terminal
The output now shows avgt (average time) in ms/op. A single fork completed in seconds instead of minutes. Computing fibonacci(30) takes about 3.1 milliseconds. The following sections break down each annotation.

Benchmark Mode

@BenchmarkMode controls what JMH measures. It can be placed on a class (applies to all methods) or on individual methods.
Mode.Throughput
default
Measures operations per second. Use this to quantify system capacity and compare throughput across implementations.
Mode.AverageTime
mode
Measures average time per operation. The general-purpose choice for latency benchmarking when you care about typical performance.
Mode.SampleTime
mode
Samples individual operation times and reports percentiles (p50, p90, p99, p99.9). Use this to understand tail latency, not just the average. Particularly useful because it reports percentiles directly:
terminal
This reveals that while the median latency is 38ns, the p99.99 is 3.2 microseconds, an 84x spike. Percentile data like this is invaluable for understanding real-world latency characteristics.
Mode.SingleShotTime
mode
Measures the time for a single invocation with no warmup. Use this to benchmark cold-start performance and one-shot initialization costs.
You can pass an array to run multiple modes in one benchmark run, e.g., @BenchmarkMode({Mode.Throughput, Mode.AverageTime}). Use Mode.All to run every mode at once, which is useful for exploratory benchmarking.

State and Scope

@State marks a class as a holder for benchmark data. Without it, you cannot use instance fields in benchmark methods. The Scope parameter controls how state is shared:
Scope.Thread
default
Creates one state instance per thread with no sharing between threads. The default choice for most benchmarks.
Scope.Benchmark
scope
Shares one state instance across all threads. Use this when measuring contention and thread-safety overhead.
Scope.Group
scope
Shares one state instance per thread group. Use this for asymmetric benchmarks (e.g., producer/consumer patterns).
The benchmark class itself can be the state (as in our example), or you can define separate state classes:
Separate state class

Fork, Warmup, Measurement, and Output Unit

These annotations control the execution strategy and output formatting:
@Fork
annotation
Controls how many separate JVM processes to run. Forks run sequentially, not in parallel. Each fork starts a fresh JVM, isolating profile-guided optimizations and JIT compilation state. Use jvmArgs to control heap size, GC settings, and other JVM flags. Use jvmArgsPrepend or jvmArgsAppend to add flags without replacing defaults.
@Warmup
annotation
Controls how many iterations run before measurement begins, giving the JIT compiler time to optimize your code to steady state. Parameters: iterations, time, timeUnit.
@Measurement
annotation
Controls how many iterations are recorded and included in the results. Accepts the same parameters as @Warmup: iterations, time, timeUnit.
@OutputTimeUnit
annotation
Controls the time unit displayed in results. Accepts any java.util.concurrent.TimeUnit value (e.g., TimeUnit.NANOSECONDS, TimeUnit.MILLISECONDS).
Configuration examples

Threads

@Threads controls how many threads run the benchmark concurrently. The default is 1. Combined with Scope.Benchmark, this is how you measure contention:
Multi-threaded benchmark
Use @Threads(Threads.MAX) to use all available processors.

Benchmarking with Parameters

The previous examples all used a single input value (30). But what if you want to see how performance changes with different input sizes? This is where @Param comes in.

Single Parameter

Add a new benchmark class to test multiple input sizes:
src/main/java/com/example/FibonacciParameterized.java
@Param tells JMH to run the benchmark once for each value. Rebuild and run:
terminal
terminal
The results clearly show the exponential O(2^n) growth of recursive Fibonacci: going from n=5 (13 nanoseconds) to n=30 (3.1 milliseconds), a factor of 240,000x.
You can override @Param values from the command line without recompiling:

Multiple Parameters

Each @Param annotation applies to a single field, but you can use multiple @Param fields to benchmark across several dimensions. JMH runs all combinations automatically:
Multiple @Param fields
This produces four benchmark runs: 1000/ArrayList, 1000/LinkedList, 10000/ArrayList, 10000/LinkedList.

Comparing Algorithms

Parameters are powerful for comparing different implementations side-by-side. Let’s benchmark recursive vs. iterative Fibonacci:
src/main/java/com/example/AlgorithmComparison.java
terminal
The iterative version computes fibonacci(30) in 6 nanoseconds while the recursive version takes 3.1 milliseconds: over 500,000x faster. This is the power of parameterized benchmarks: they make algorithmic trade-offs visible at a glance.

Benchmarking Only What Matters

Sometimes you have expensive setup that should not be included in your benchmark measurements. For example, generating test data or loading files. JMH provides @Setup and @TearDown annotations with different Level options to control when fixture methods run.

Setup and Teardown

Let’s benchmark an outlier detection algorithm where the dataset generation is expensive but should not be measured:
src/main/java/com/example/OutlierDetection.java
The @Setup(Level.Trial) method runs once before all measurement iterations. Only the findOutliers() method is timed:
terminal

Fixture Levels

JMH offers three levels for @Setup and @TearDown:
Level.Trial
level
Runs once per benchmark fork. Use this for loading files and building large datasets that are reused across all iterations.
Level.Iteration
level
Runs before and after each measurement iteration. Use this to reset mutable state between iterations.
Level.Invocation
level
Runs before and after each individual method call. Use sparingly - this adds overhead on every invocation.
Level.Invocation adds timing overhead on every call. Only use it when the benchmark method is slow enough (milliseconds or more) that the fixture cost is negligible in comparison.
Here is an example using Level.Iteration to provide fresh unsorted data for each iteration of a sorting benchmark:
src/main/java/com/example/SortBenchmark.java

Running Benchmarks from the Command Line

The benchmarks.jar supports a rich set of command-line options. Here are the most useful ones:

Filtering Benchmarks

Run only benchmarks matching a regex:
terminal
Exclude benchmarks matching a pattern:
terminal

Overriding Parameters

Override @Param, @Fork, @Warmup, and @Measurement from the command line:
terminal
-f
int
Number of forks.
-t
int
Number of threads.
-wi
int
Warmup iterations.
-i
int
Measurement iterations.
-w
time
Warmup iteration time (e.g., 2s).
-r
time
Measurement iteration time.
-p
param=v1,v2
Override @Param values.
-bm
mode
Override benchmark mode (thrpt, avgt, sample, ss).
-tu
unit
Override time unit (ns, us, ms, s).

Exporting Results

JMH can export results in various formats for further analysis or visualization:
-rf
format
Result format. One of text, csv, scsv, json, latex.
-rff
path
Result file path. Where to write the output (e.g., results.json).
For example, to export JSON results:
terminal

Using Profilers

JMH ships with built-in profilers. List them with:
terminal
The most useful profilers:
-prof stack
string
Samples hot methods and thread states to show where time is being spent.
-prof gc
string
Reports allocation rate, GC pressure, and bytes allocated per operation.
-prof comp
string
Reports JIT compilation activity during the measurement window.
-prof perfnorm
string
Reports per-operation hardware counters: cache misses, branch mispredictions, and CPI. Linux only.
-prof async
string
Generates CPU flamegraphs using async-profiler.
For example, to measure allocation pressure:
terminal
This adds GC metrics to the output, showing bytes allocated per operation (gc.alloc.rate.norm) and GC event counts, essential for understanding allocation-heavy code.

Avoiding Common Pitfalls

The JVM is a sophisticated optimizing runtime. Without care, it can silently eliminate or transform the code you are trying to measure, producing misleading results. JMH is designed to help, but you still need to follow certain patterns.

Dead Code Elimination

If a computation’s result is never used, the JIT compiler may eliminate it entirely:
Dead code elimination
JMH automatically consumes the return value of @Benchmark methods through an internal Blackhole, preventing elimination. Always return your computed result.

Blackholes for Multiple Results

When you produce multiple results, you can only return one. Use Blackhole.consume() for the rest:
Blackhole usage
Import Blackhole from org.openjdk.jmh.infra.Blackhole. JMH injects it automatically as a method parameter.

Constant Folding

If the JVM can determine a computation’s inputs at compile time, it folds the entire computation into a constant:
Constant folding
Your IDE may suggest making x final. Do not. Non-final @State fields are essential for preventing constant folding in benchmarks.

Do Not Loop Manually

Never write manual loops inside benchmark methods. The JVM aggressively optimizes loops. It unrolls, pipelines, and hoists invariant computations out of them, producing unrealistically low per-operation numbers:
Manual loops
JMH handles iteration internally with proper safeguards. Trust the framework.

Best Practices

Use Multiple Forks

The JVM is non-deterministic. Profile-guided optimizations, garbage collection, and thread scheduling vary between runs. A single fork can give misleading results. Use multiple forks (see @Fork) to capture this variance:
Fork configuration
Each fork starts a fresh JVM, isolating profile-guided optimizations and giving JMH enough data points to compute meaningful confidence intervals.

Keep Benchmarks Deterministic

Use fixed seeds in your @Setup methods for random number generators:
Deterministic setup

Verify Correctness Alongside Performance

Include assertions in your setup or dedicated test methods to ensure you are benchmarking correct code, not broken code that happens to be fast:
Correctness check

Use Realistic Data

Sorted or regular data can exploit hardware optimizations like branch prediction and cache prefetching, giving misleadingly good results. Use representative data that matches your production workload.

Benchmark Your Own Code

In real projects, organize your benchmarks alongside your source code:
my-project
pom.xml
src
main/java/com/example
MyAlgorithm.java
benchmarks
pom.xml
src/main/java/com/example
MyAlgorithmBenchmark.java
The benchmark submodule depends on your library and uses the JMH archetype setup. This keeps benchmark infrastructure separate from production code.

Running Benchmarks Continuously with CodSpeed

So far, you’ve been running benchmarks locally. But local benchmarking has limitations:
  • Inconsistent hardware: Different developers get different results
  • Manual process: Easy to forget to run benchmarks before merging
  • No historical tracking: Hard to spot gradual performance degradation
  • No PR context: Can’t see performance impact during code review
This is where CodSpeed comes in. It runs your benchmarks automatically in CI and provides:
  • Automated performance regression detection in PRs
  • Consistent metrics with reliable measurements across all runs
  • Historical tracking to see performance over time with detailed charts
  • Flamegraph profiles to see exactly what changed in your code’s execution

How to Set Up CodSpeed

Here’s how to integrate CodSpeed with your JMH benchmarks:
CodSpeed integrates with JMH through a custom fork. Before configuring CI, follow the Java integration reference to add the fork as a Maven or Gradle dependency.
1

Set Up GitHub Actions

Create a workflow file to run benchmarks on every push and pull request.
2

Check the Results

Once the workflow runs, your pull requests will receive a performance report comment:Pull Request ResultPull Request Result
3

Access Detailed Reports and Flamegraphs

After your benchmarks run in CI, head over to your CodSpeed dashboard to see detailed performance reports, historical trends, and flamegraph profiles for deeper analysis.
Profiling Report on CodSpeed

Profiling Report on CodSpeed

Profiling works out of the box, no extra configuration needed!Learn more about flamegraphs and how to use them to optimize your code.

Next Steps

Check out these resources to continue your Java benchmarking journey:

Get Started with CodSpeed

Sign up and start tracking your Java performance in CI

Java Integration Reference

Set up the CodSpeed JMH fork in your Maven or Gradle project

Performance Profiling

Learn how to use flamegraphs to optimize your code

JMH Source Code

Dive into the JMH source and annotation reference