Back to Benchmarking & Load Testing
Q2Question 2 of 4

How to design a JVM benchmark?

Key Concepts
  • HotSpot interprets bytecode, then C1 compiles with profiling, then C2 heavily optimises hot methods.
  • Warmup must drive the hot path to C2 with realistic data mix before measuring.
  • Dead-code elimination can delete unused benchmark results; consume results via a blackhole or field.
  • GC must reach steady state; run long enough for several cycles and fix heap size with -Xms equal to -Xmx.
  • HdrHistogram records every latency losslessly; never report only averages.
  • Pre-initialise connection pools, caches, and TLS sessions before the measurement window.
  • JMH handles warmup, forking, and blackholes via annotations for micro-benchmarks.

Definition

A trustworthy JVM benchmark warms up until C2 has compiled the hot path and GC is steady, pre-initialises pools and caches, records every latency into HdrHistogram, and isolates the one component under test.
  • Warmup phase – drive the hot methods through C1 and into C2 compilation before recording any measurements.
  • GC steady state – run long enough for garbage collection to settle; fix heap size with -Xms equal to -Xmx.
  • HdrHistogram – record every latency value into logarithmically sized buckets for accurate high percentiles.
  • Isolation – pre-initialise connection pools, caches, and TLS sessions so cold-start costs do not pollute the histogram.
  • JMH – Java Microbenchmark Harness: handles warmup, forking, and dead-code prevention via annotations.

Imagine timing how fast a new teller at National Bank of India serves customers. On the first morning the teller is slow, reading the manual for every step (interpreting). After a few dozen customers they remember the common steps (C1). After a few hundred they are an expert on autopilot (C2). A good benchmark lets the teller become expert first (warmup), makes sure the cash drawer is already stocked (pool initialised), ignores the one coffee break (does not confuse a GC pause with normal work), and writes down every single service time (HdrHistogram) instead of just the average.

Understanding the Concepts

Why does the JVM need a warmup phase?

HotSpot runs tiered compilation. A method starts interpreted. As invocation counters cross thresholds, C1 compiles it with profiling counters. When proven hot, C2 recompiles with aggressive optimisations like inlining, escape analysis, and loop unrolling. Measuring before C2 kicks in benchmarks the interpreter, not the production hot path.

BASH
# Observe compilation tiers
java -XX:+PrintCompilation -jar benchmark.jar

How do you handle dead-code elimination?

If the benchmark does not use its result, C2 may delete the work entirely. The fix is to consume the result.

JAVA
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;

@Warmup(iterations = 5)
@Measurement(iterations = 10)
@BenchmarkMode(Mode.SampleTime)
public class InterestBenchmark {

    @Benchmark
    public void calculateInterest(Blackhole bh) {
        BigDecimal interest = principal.multiply(rate);
        bh.consume(interest);  // prevent dead-code elimination
    }
}

How do you account for garbage collection?

Early in a run the heap is small and collections are rare. As caches fill, behaviour changes. Run long enough to see several GC cycles. Fix the heap with -Xms4g -Xmx4g to remove resizing noise. Log GC with -Xlog:gc*.

How do you use HdrHistogram?

Averages hide the tail. HdrHistogram records every value into logarithmically sized buckets.

JAVA
import org.HdrHistogram.Histogram;

Histogram histogram = new Histogram(3_600_000_000L, 3);

for (int i = 0; i < 100_000; i++) {
    long start = System.nanoTime();
    service.calculateInterest("SAV2001");
    long elapsed = System.nanoTime() - start;
    histogram.recordValue(elapsed / 1000); // microseconds
}

System.out.println("p50: " + histogram.getValueAtPercentile(50.0) + " us");
System.out.println("p99: " + histogram.getValueAtPercentile(99.0) + " us");
System.out.println("p99.9: " + histogram.getValueAtPercentile(99.9) + " us");

How do you isolate the bottleneck?

Pre-initialise everything expensive before the measurement window: open the connection pool and run a warmup query, prime caches, establish TLS sessions.

JAVA
// Pre-initialise HikariCP before benchmark
HikariDataSource ds = new HikariDataSource(config);
try (Connection c = ds.getConnection()) {
    c.createStatement().execute("SELECT 1");  // warm the pool
}
// Now start measuring

Run the load generator on a separate host. Pin the test to avoid noisy neighbours. Change one variable at a time.

What are common mistakes?

The wrong approach measures a cold JVM.

JAVA
// WRONG: first iteration includes interpreter + C1 + cold pool
long start = System.nanoTime();
result = service.process(request);
long elapsed = System.nanoTime() - start;
// This number is meaningless for production performance

The correct approach uses JMH with warmup iterations.

JAVA
// CORRECT: JMH warms up, forks fresh JVM, uses blackhole
@Warmup(iterations = 5, time = 2)
@Measurement(iterations = 10, time = 5)
@Fork(2)
public class SettlementBenchmark { /* ... */ }

What should a senior engineer know?

Warmup must exercise the real code path with realistic data so the JIT profiles the branches production traffic takes. A benchmark answers exactly one question, so decide the question first and design isolation accordingly.

What are common interview follow-up questions?

  • How does GraalVM ahead-of-time compilation affect benchmark design?
  • How do you benchmark latency versus throughput?
  • When is JMH overkill and when is it necessary?

Visual Overview

DIAGRAM
  JVM Compilation Tiers:
  ┌──────────┐    ┌──────────┐    ┌──────────┐
  │Interpreter│──▶│   C1     │──▶│   C2     │
  │  (slow)   │    │ (fast-ish)│   │ (fast)   │
  │           │    │+profiling │   │+inlining │
  └──────────┘    └──────────┘    └──────────┘
       ▲               ▲               ▲
  First calls    ~100 calls       ~10K calls
  DO NOT         Still warming    NOW MEASURE
  MEASURE        up                HERE

  Benchmark Timeline:
  ┌────────────┐  ┌────────────┐  ┌────────────┐
  │  Warmup    │  │ GC Settle  │  │ Measurement│
  │  C1 → C2  │  │ Steady heap│  │ HdrHistogram│
  │  Pool init │  │ Fix -Xms/mx│  │ p50,p99,p999│
  └────────────┘  └────────────┘  └────────────┘

Key Takeaway

A reliable JVM benchmark respects how HotSpot actually runs code. It starts by interpreting, then C1 compiles with profiling, and finally C2 produces heavily optimised code. Measuring before C2 benchmarks the interpreter, not the production hot path.

I always run an explicit warmup with realistic data, fix the heap size, pre-initialise pools and caches, and record every latency into HdrHistogram. JMH handles all of this for micro-benchmarks through annotations.

At National Bank of India this discipline turned our benchmarks from misleading cold-JVM numbers into stable, trustworthy percentiles that we gate every release against.

Remember one sentence: warm up until C2 compiles the hot path, fix the heap, pre-initialise pools, record every latency into HdrHistogram, and never report only averages.

---

Share
Use ← → arrow keys to navigate