JMH Java Microbenchmark Harness: Setup, Blackhole, Pitfalls

Learn the JMH Java Microbenchmark Harness with the Maven archetype, the Java 23+ processor fix, @State, @Param, Blackhole, real results and pitfalls.

JMH run lifecycle with forks, warmup iterations, measurement iterations and the final score with error

JMH (Java Microbenchmark Harness) is the OpenJDK tool for measuring how long a small piece of Java code takes, such as one method, while it controls JIT warmup, separate JVM forks and the compiler optimizations that make hand-written timing loops wrong. The OpenJDK developers use JMH to benchmark the JDK itself.

We use JMH to compare two implementations of the same task, such as StringBuilder against + in a loop, to check that a change in a hot method did not make it slower, and to answer “which one is faster” questions with numbers instead of guesses.

The following example is a complete JMH benchmark that measures String.join() for an array of 1,000 words.

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
public class JoinBenchmark {

    String[] words = new String[1000];

    @Setup
    public void fill() {
        Arrays.setAll(words, i -> "word" + i);
    }

    @Benchmark
    public String join() {
        return String.join("", words);     // returned, so the JIT cannot remove it
    }
}
mvn clean verify
java -jar target/benchmarks.jar JoinBenchmark

Notice that the method returns its result, and that the input array lives in a state object filled by @Setup. JMH generates the timing loop around join(), so we never write System.nanoTime() ourselves. We set up a project with the Maven archetype, fix the annotation processing step that Java 23 and later need, read a real result table, and go through modes, forks, state scopes and the pitfalls that make benchmarks lie.

1. Why a Timing Loop with System.nanoTime() Gives Wrong Numbers

A naive benchmark calls a method a million times between two System.nanoTime() calls and divides the time. The number it prints is rarely the time the method takes in production, because the JVM keeps changing the code while the loop runs.

  • The first calls run in the bytecode interpreter, and the JIT compilers (C1 first, C2 later) compile the method only after it becomes hot, so early calls are far slower than later ones.
  • The JIT removes code whose result is never used, which is called dead code elimination, so a loop that ignores the result can measure nothing at all.
  • The JIT computes expressions on constants at compile time, which is called constant folding, so Math.log(42) may become a fixed number.
  • Garbage collection, CPU frequency changes and other processes add noise, and one run on one JVM shows only one sample of that noise.

JMH handles these problems for us. It runs warmup iterations before measuring, starts fresh JVM forks so that one run’s JIT decisions do not leak into the next, consumes results so the JIT cannot drop them, and reports an error margin for every score.

JMH run lifecycle with forks, warmup iterations, measurement iterations and the final score with error
Each fork is a new JVM; only measurement iterations count, and the score comes with a 99.9% confidence error

2. Creating a JMH Project with the Maven Archetype

The JMH project recommends a separate Maven project for benchmarks, generated from its archetype. The project builds one executable benchmarks.jar that contains the benchmarks and the generated measurement code. The current version of jmh-core and jmh-generator-annprocess is 1.37.

mvn archetype:generate \
  -DinteractiveMode=false \
  -DarchetypeGroupId=org.openjdk.jmh \
  -DarchetypeArtifactId=jmh-java-benchmark-archetype \
  -DarchetypeVersion=1.37 \
  -DgroupId=com.howtodoinjava \
  -DartifactId=jmh-demo \
  -Dversion=1.0

2.1. Enabling the Annotation Processor on Java 23 and Later

JMH generates the measurement code from the @Benchmark annotations with an annotation processor. Since JDK 23, javac no longer runs annotation processors that it finds on the classpath unless the build asks for them, and the archetype’s pom.xml (which also targets Java 8) does not ask. The build succeeds, but running the jar fails.

$ java -jar target/benchmarks.jar -l
Exception in thread "main" java.lang.RuntimeException: ERROR: Unable to find the resource: /META-INF/BenchmarkList
	at org.openjdk.jmh.runner.AbstractResourceReader.getReaders(AbstractResourceReader.java:98)
	at org.openjdk.jmh.runner.BenchmarkList.find(BenchmarkList.java:124)

We fix it in the maven-compiler-plugin section of the generated pom.xml. The annotationProcessorPaths element names the JMH processor explicitly, and release sets the Java version. The same error and other causes are covered in Unable to find the resource /META-INF/BenchmarkList.

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.16.0</version>
    <configuration>
        <release>25</release>
        <annotationProcessorPaths>
            <path>
                <groupId>org.openjdk.jmh</groupId>
                <artifactId>jmh-generator-annprocess</artifactId>
                <version>1.37</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

After mvn clean verify, the command java -jar target/benchmarks.jar -l lists every benchmark it found. Gradle users get the same setup from the community jmh-gradle-plugin, which adds a src/jmh/java source set and a jmh task.

3. Writing a Benchmark with @State, @Param and @Setup

Say a reporting service builds a CSV line from hundreds of values, and a code review asks whether the += loop in that code is a real problem or a style comment. A benchmark answers it for the sizes the service sees. The following class compares three ways to join an array of words, for 10 and for 1,000 words.

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
@State(Scope.Thread)
public class ConcatBenchmark {

    @Param({"10", "1000"})
    int size;

    String[] words;

    @Setup
    public void setUp() {
        words = new String[size];
        for (int i = 0; i < size; i++) {
            words[i] = "word" + i;
        }
    }

    @Benchmark
    public String plusInLoop() {
        String result = "";
        for (String word : words) {
            result += word;
        }
        return result;
    }

    @Benchmark
    public String stringBuilder() {
        StringBuilder sb = new StringBuilder();
        for (String word : words) {
            sb.append(word);
        }
        return sb.toString();
    }

    @Benchmark
    public String stringJoin() {
        return String.join("", words);
    }
}

Each annotation has one job.

  • The @Benchmark annotation marks a public method that JMH measures.
  • The @State(Scope.Thread) annotation makes the class a state object, so JMH creates one instance per benchmark thread and keeps its fields out of the measured code.
  • The @Param annotation runs every benchmark once per listed value, and JMH converts the strings to the field type.
  • The @Setup method runs before the measurement (once per trial by default), so building the input array is not part of the score.
  • The @Warmup, @Measurement and @Fork annotations shorten the run for this article; the defaults are covered in section 5.

4. Reading the Result Table

JMH prints the progress of every iteration and ends with a summary table. The following output comes from a real run of ConcatBenchmark on JDK 25.0.4.1 with JMH 1.37, on a shared cloud VM with 2 virtual CPUs, so other processes added noise.

Benchmark                      (size)  Mode  Cnt    Score    Error  Units
ConcatBenchmark.plusInLoop         10  avgt   10    0.170 +-  0.014  us/op
ConcatBenchmark.plusInLoop       1000  avgt   10  530.689 +- 53.728  us/op
ConcatBenchmark.stringBuilder      10  avgt   10    0.535 +-  1.896  us/op
ConcatBenchmark.stringBuilder    1000  avgt   10    8.456 +-  1.222  us/op
ConcatBenchmark.stringJoin         10  avgt   10    0.110 +-  0.051  us/op
ConcatBenchmark.stringJoin       1000  avgt   10    9.141 +-  1.553  us/op

The columns tell us how to read each row.

ColumnMeaning
(size)The @Param value of this row
Modeavgt is average time per operation, thrpt is operations per time unit
CntNumber of measurement iterations in all forks, here 2 forks times 5 iterations
ScoreThe average result, here microseconds per call
ErrorHalf-width of the 99.9% confidence interval around the score
UnitsThe unit set by @OutputTimeUnit and the mode

For 1,000 words, the += loop took about 530 microseconds per call against about 8 to 9 microseconds for StringBuilder and String.join(), because every += copies the whole string built so far. For 10 words, all three finish in well under a microsecond, so the review comment matters only for long inputs.

We compare scores only when their error ranges do not overlap. The stringBuilder row for 10 words has an error of 1.896 against a score of 0.535, which means the iterations varied wildly, most likely because of the shared VM. Such a row needs a rerun on a quiet machine, more iterations or more forks before anyone quotes it.

5. Warmup, Measurement and Forks

A JMH run of one benchmark is a trial. JMH starts a new JVM for each fork, runs the warmup iterations whose results it throws away, and runs the measurement iterations that make up the score. Each iteration calls the benchmark method in a loop for a fixed time.

AnnotationWhat it setsDefault in JMH 1.37Command-line option
@Fork(value, warmups, jvmArgs)Number of separate JVM processes5 forks-f 5
@Warmup(iterations, time, timeUnit)Warmup iterations and their length5 iterations of 10 s-wi 5 -w 10s
@Measurement(iterations, time, timeUnit)Measured iterations and their length5 iterations of 10 s-i 5 -r 10s
@ThreadsThreads calling the benchmark at the same time1-t 4

With the defaults, one benchmark runs for about 8 minutes and 20 seconds (5 forks times 100 seconds), so a class with six parameter combinations takes close to an hour. Short settings such as those in section 3 are fine while writing a benchmark, and the published numbers come from a longer run. Command-line options override the annotations, as in java -jar target/benchmarks.jar ConcatBenchmark -f 3 -wi 5 -i 10.

Forks are separate processes, not threads. Each fork starts with a cold JIT and a new heap, so the result does not depend on what an earlier benchmark compiled. The jvmArgs element passes options such as -Xmx2g to the forked JVMs, and @Fork(0) runs inside the current JVM, which is useful for debugging and wrong for measuring.

6. Benchmark Modes and Time Units

The mode decides what JMH reports. We set it with @BenchmarkMode on the class or the method, and the unit with @OutputTimeUnit.

ModeReportsUse it for
Mode.Throughput (default)Operations per time unit, higher is betterMethods called very often, such as a parser
Mode.AverageTimeAverage time per operation, lower is betterComparing implementations
Mode.SampleTimeTime of sampled calls with percentiles such as p99Spotting slow outliers
Mode.SingleShotTimeTime of one call without a measurement loopCold start cost, such as the first call after startup
Mode.AllAll of the aboveExploring a new benchmark

The default unit for throughput is operations per second. For average and sample time, we pick a unit that keeps the numbers readable, such as NANOSECONDS for a getter and MILLISECONDS for parsing a large file.

7. State Scopes and Setup Levels

State objects hold the input data and any objects the benchmark needs. The scope decides how many instances JMH creates when a benchmark runs with several threads.

ScopeInstancesTypical use
Scope.ThreadOne per benchmark threadUnshared inputs, the safe default
Scope.BenchmarkOne shared by all threadsMeasuring contention on a shared object, such as a ConcurrentHashMap
Scope.GroupOne per thread groupProducer and consumer benchmarks with @Group

Methods annotated with @Setup and @TearDown take a Level that says when they run. Level.Trial (the default) runs before and after each run of the benchmark in a fork, Level.Iteration runs before or after every iteration, and Level.Invocation runs around every single call. With Level.Invocation, JMH has to timestamp each call to subtract the setup time, which distorts the result of a small method, so we keep Level.Invocation for benchmark methods that take milliseconds, not nanoseconds.

8. Pitfalls That Make a Benchmark Lie

JMH removes the measurement problems, but the code inside a benchmark method can still let the JIT optimize away the work we want to measure. The JMH samples show each of the following pitfalls with code.

8.1. Dead Code Elimination

If the result of a computation is never used, the JIT may remove the computation. The first method in the following benchmark may measure an empty method, while the other two keep the work alive, either by returning the value, which JMH consumes for us, or by passing it to a Blackhole.

@State(Scope.Thread)
public class LogBenchmark {

    double x = Math.PI;

    @Benchmark
    public void wrong() {
        Math.log(x);                       // result unused, may be removed
    }

    @Benchmark
    public double returned() {
        return Math.log(x);
    }

    @Benchmark
    public void consumed(Blackhole bh) {
        bh.consume(Math.log(x));
        bh.consume(Math.log(x + 1));       // several results need a Blackhole
    }
}

Whether the JIT removes a particular call depends on the JDK version and the CPU, so we never rely on it either way. A score close to an empty @Benchmark method (well under a nanosecond) is the usual sign that the work was removed.

8.2. Constant Folding

If the inputs are constants, the JIT can compute the result once at compile time. A literal such as Math.log(42) or a final field initialized with a constant gives the JIT that chance, so we read inputs from non-final fields of a state object, as x is read in LogBenchmark.

8.3. Loops Inside the Benchmark Method

A loop such as for (int i = 0; i < 1000; i++) sum += i * factor; inside a benchmark lets the JIT unroll or vectorize it, so the time per element shrinks in ways that never happen with real data. We let JMH call the method repeatedly, and when a loop over input data is part of the task, as in ConcatBenchmark, we vary the input size with @Param instead of dividing by the loop count.

8.4. A Noisy Machine

Browsers, IDE indexing, other builds and laptop power saving all change the results. We run benchmarks on a quiet machine with the power profile set to performance, compare only results from the same machine, and watch the error column, as section 4 showed with the shared VM.

9. Running from Code, Profilers and Result Files

The jar is the recommended way to run benchmarks, and the command-line options cover most needs. For IDE runs, the Runner API starts the same runner from a main() method.

public class BenchmarkMain {

    public static void main(String[] args) throws RunnerException {
        Options options = new OptionsBuilder()
                .include(ConcatBenchmark.class.getSimpleName())
                .forks(1)
                .resultFormat(ResultFormatType.JSON)
                .result("concat.json")
                .build();
        new Runner(options).run();
    }
}

Profilers explain why one version is faster. The option -prof gc adds allocation rates and garbage collection counts to the table, which shows the extra garbage of the += loop, and java -jar target/benchmarks.jar -lprof lists all available profilers. The -rf json option writes a result file that tools such as JMH Visualizer can chart.

10. Java Microbenchmark Harness FAQs

Setup errors and odd numbers cause most JMH questions, from the missing BenchmarkList to scores below one nanosecond.

10.1. What is the difference between JMH and System.nanoTime()?

System.nanoTime() only reads a clock. JMH uses the clock too, but it adds warmup, forks, result consumption, statistics and an error margin, which a hand-written loop does not have, as section 1 explained.

10.2. Why does JMH report Unable to find the resource /META-INF/BenchmarkList?

The annotation processor did not run, so the generated benchmark list is missing from the jar. On JDK 23 and later, add jmh-generator-annprocess to annotationProcessorPaths, as shown in section 2.1, and rebuild.

10.3. Can I run JMH benchmarks in JUnit tests?

We can call the Runner API from a test, but the OpenJDK team recommends a separate benchmark project. Unit test runs are short, share the JVM with other tests and often run on busy CI agents, so their numbers are not reliable enough to fail a build on.

10.4. Why is my benchmark faster than 1 nanosecond?

The JIT most likely removed the work through dead code elimination or constant folding. Return the result or pass it to a Blackhole, and read inputs from a @State field.

10.5. How many forks and iterations should a benchmark use?

The defaults of 5 forks with 5 warmup and 5 measurement iterations of 10 seconds are a good start for published numbers. If the error stays large, add forks before adding iterations, because the variation between JVM runs is often bigger than the variation inside one run.

11. Conclusion

JMH measures small pieces of Java code with warmup iterations, separate forks and an error margin, which a System.nanoTime() loop cannot do. A project from the 1.37 archetype needs one change on Java 23 and later, namely the annotation processor in annotationProcessorPaths.

A good benchmark keeps inputs in @State objects, varies sizes with @Param, returns or consumes every result, and avoids its own loops. The result table is only as good as the machine it ran on, so we trust scores whose error ranges do not overlap and rerun the rest.

12. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.