Java - Java Performance Benchmarking with JMH (Java Microbenchmark Harness)
Introduction
Performance is an important aspect of software development. As Java applications become larger and more complex, developers often need to determine which parts of their code execute efficiently and which parts require optimization. Measuring performance accurately is not as simple as recording execution time using System.currentTimeMillis() or System.nanoTime(). The Java Virtual Machine (JVM) performs various optimizations such as Just-In-Time (JIT) compilation, garbage collection, and dead code elimination that can significantly influence benchmark results.
To solve this problem, Java provides the Java Microbenchmark Harness (JMH). JMH is a benchmarking framework developed by the OpenJDK team specifically for measuring the performance of small pieces of Java code, known as microbenchmarks. It automatically handles JVM warm-up, multiple iterations, thread management, and statistical analysis to produce reliable and repeatable benchmark results.
What is JMH?
Java Microbenchmark Harness (JMH) is a tool designed to accurately measure the performance of Java methods and algorithms. Instead of relying on manual timing techniques, JMH provides a structured environment that eliminates common benchmarking mistakes.
JMH helps developers answer questions such as:
-
Which algorithm is faster?
-
Does a new implementation improve performance?
-
How much memory does a method allocate?
-
How does performance change with multiple threads?
-
Does compiler optimization affect execution time?
It is widely used by Java developers, library authors, and JVM engineers.
Why Normal Benchmarking is Inaccurate
Many beginners write benchmarks like this:
long start = System.nanoTime();
for(int i = 0; i < 1000000; i++) {
calculate();
}
long end = System.nanoTime();
System.out.println(end - start);
Although this appears correct, the result is often misleading because:
-
JVM may compile code during execution.
-
Garbage collection may occur.
-
CPU scheduling changes over time.
-
Dead code elimination removes unused computations.
-
CPU cache effects influence execution.
-
The first execution is usually slower.
These factors make manual benchmarks unreliable.
Why JMH is Preferred
JMH automatically manages several performance-related tasks:
-
JVM warm-up
-
Multiple benchmark iterations
-
Statistical averaging
-
Thread synchronization
-
Preventing compiler optimizations
-
Measuring execution throughput
-
Memory allocation measurement
-
Forking multiple JVM instances
Because of these features, benchmark results become much more reliable.
Features of JMH
Automatic Warm-up
When Java code runs for the first time, the JVM has not yet optimized it.
JMH executes several warm-up iterations before actual measurement begins.
Example:
Warm-up
Iteration 1
Iteration 2
Iteration 3
↓
JIT Compiler Optimizes Code
↓
Actual Benchmark Starts
This ensures optimized code is measured instead of startup performance.
Multiple Measurement Iterations
Instead of executing a benchmark once, JMH runs it repeatedly.
Example:
Iteration 1
Iteration 2
Iteration 3
Iteration 4
Iteration 5
The average execution time provides more consistent results.
JVM Forking
JMH can launch completely new JVM processes.
Example:
Fork 1
Run Benchmark
Fork 2
Run Benchmark
Fork 3
Run Benchmark
Each benchmark runs in a clean environment, reducing interference from previous executions.
Statistical Analysis
JMH calculates:
-
Average execution time
-
Minimum time
-
Maximum time
-
Standard deviation
-
Confidence intervals
This helps developers understand whether performance differences are significant or caused by random variation.
Adding JMH to a Maven Project
To use JMH, add the required dependencies.
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.37</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.37</version>
</dependency>
These dependencies provide the benchmarking framework and annotation processing.
Structure of a JMH Benchmark
A typical benchmark contains:
public class MyBenchmark {
@Benchmark
public void testMethod() {
}
}
The @Benchmark annotation tells JMH that the method should be measured.
Example Benchmark
import org.openjdk.jmh.annotations.Benchmark;
public class StringBenchmark {
@Benchmark
public String concatenate() {
String s = "";
for(int i = 0; i < 100; i++) {
s += i;
}
return s;
}
}
JMH repeatedly executes the concatenate() method and records its performance.
Running a Benchmark
JMH generates benchmark reports similar to:
Benchmark Mode Score Error Units
concatenate avgt 5.12 0.11 ms/op
Explanation:
-
Benchmark = Method tested
-
Mode = Average Time
-
Score = Average execution time
-
Error = Statistical error
-
Units = Milliseconds per operation
Benchmark Modes
JMH supports several benchmark modes.
Average Time
Measures average execution time.
Mode.AverageTime
Output:
Average = 2.5 ms
Useful when comparing algorithms.
Throughput
Measures how many operations execute every second.
Mode.Throughput
Output:
12000 operations/second
Useful for servers and APIs.
Sample Time
Randomly samples execution time.
Mode.SampleTime
Useful for long-running operations.
Single Shot Time
Measures only one execution.
Mode.SingleShotTime
Useful for startup benchmarks.
All Modes
Mode.All
Runs every benchmark mode.
Warm-up Configuration
Developers can customize warm-up.
@Warmup(iterations = 5)
Meaning:
Iteration 1
Iteration 2
Iteration 3
Iteration 4
Iteration 5
↓
Start Measurement
More warm-up iterations generally produce more stable results.
Measurement Configuration
Specify the number of measurement iterations.
@Measurement(iterations = 10)
JMH performs:
Warm-up
↓
10 Benchmark Iterations
↓
Average Result
Fork Configuration
Specify JVM forks.
@Fork(3)
JMH launches:
JVM 1
↓
JVM 2
↓
JVM 3
The final result is averaged across all JVMs.
Thread Benchmarking
JMH measures multi-threaded performance.
@Threads(4)
Benchmark executes using four concurrent threads.
Useful for:
-
Concurrent collections
-
Synchronization
-
Parallel algorithms
-
Thread-safe classes
Measuring Memory Allocation
JMH can report memory allocation.
Example output:
Allocation Rate
150 MB/sec
This helps identify methods that create excessive objects.
Comparing Two Algorithms
Example:
Algorithm A
Collections.sort(list);
Algorithm B
list.stream().sorted().toList();
JMH measures:
-
Execution time
-
Throughput
-
Memory allocation
Developers can determine which approach is more efficient for a given workload.
Avoiding Dead Code Elimination
If the result of a benchmark is never used, the JVM may optimize away the entire computation.
Example:
int sum = a + b;
If sum is ignored, the JVM might remove the calculation.
JMH prevents this by encouraging the use of returned values or helper classes such as Blackhole, ensuring the code being benchmarked is actually executed.
Common Benchmark Mistakes
Developers often make mistakes that lead to inaccurate results:
-
Using
System.currentTimeMillis()for benchmarking. -
Running only a single test iteration.
-
Ignoring JVM warm-up.
-
Comparing code without multiple benchmark runs.
-
Benchmarking code that performs file or network I/O.
-
Forgetting that garbage collection can influence timing.
-
Ignoring memory allocation during performance analysis.
JMH helps avoid these problems by providing a standardized benchmarking environment.
Best Practices for Using JMH
-
Always allow sufficient warm-up before measuring performance.
-
Run benchmarks multiple times and compare averages rather than a single execution.
-
Use multiple JVM forks to reduce the impact of previous benchmark runs.
-
Benchmark only the code you want to measure, keeping setup work separate.
-
Compare implementations under the same hardware and JVM configuration.
-
Consider both execution speed and memory allocation when evaluating performance.
-
Interpret results in the context of real-world usage instead of relying only on synthetic benchmarks.
Advantages of JMH
-
Produces accurate and repeatable performance measurements.
-
Handles JVM optimizations automatically.
-
Supports multiple benchmarking modes.
-
Measures both execution time and throughput.
-
Supports multi-threaded benchmarking.
-
Reduces errors caused by JVM warm-up and compiler optimizations.
-
Provides statistical reports for informed decision-making.
-
Widely adopted by the Java community and OpenJDK developers.
Limitations of JMH
-
It measures small units of code (microbenchmarks) and may not represent the performance of an entire application.
-
Writing meaningful benchmarks requires understanding what code should be measured.
-
Results can still vary across different hardware, operating systems, and JVM versions.
-
It cannot replace full application profiling tools when diagnosing production performance issues.
Applications of JMH
JMH is commonly used in:
-
Comparing different algorithms and data structures.
-
Optimizing utility libraries and frameworks.
-
Evaluating the impact of code changes on performance.
-
Measuring the efficiency of concurrent and parallel code.
-
Testing memory allocation patterns.
-
Benchmarking APIs, collections, and string manipulation techniques.
-
Validating performance improvements before deploying applications to production.
Conclusion
Java Microbenchmark Harness (JMH) is the standard framework for accurately measuring the performance of Java code. Unlike manual timing methods, it accounts for JVM warm-up, compiler optimizations, garbage collection, and other factors that can distort results. By providing configurable benchmark modes, statistical analysis, and support for multi-threaded testing, JMH enables developers to make informed decisions about performance optimization. It is an essential tool for anyone developing high-performance Java applications or evaluating the efficiency of different coding approaches.