Performance ยท 02

Benchmarking & Load Testing

Two instruments, two ways to be fooled. A real JMH 1.37 run (dead code, constant folding, warm-up, forks, error bars), a hand-rolled timer that lies, and a plain-Java open-model load generator that measures a real 1.5 s freeze correctly and finds an instance's capacity with a step test - plus an honest audit of how noisy a shared machine makes all of it.

11
Chapters
9
JMH benchmarks
12
Diagrams
3
Real labs
01
Chapter One - The Problem

Two Ways to Fool Yourself

The symptom. A pull request on OrderService claims "string building is 30 % faster, measured". Two weeks later the load-test report says the new build sustains 2x the traffic - and production p99 is unchanged. Both numbers were honest measurements. Both were wrong in a way the tool made easy to miss.

Hero - why hand-rolled timing is not a benchmark (real NaiveBench output)
Same two methods, same machine, four "benchmarks" - the verdict changes with the order you ran them 0.001 0.01 0.1 1 10 100 1000 cold call: plus 128.8 ms cold call: builder 0.63 ms loop, plus (ran 1st) 0.002376 ms loop, plus (ran 2nd) 0.01776 ms Log axis in ms (ticks 0.001 .. 1000). First cold plus() call: 128.8 ms (string-concat bootstrap). Same method: 2.4 us/op in one ordering, 17.8 us/op in the other. Hand-rolled timers measure warm-up.

Benchmarking has two failure modes. A microbenchmark measures a few lines of code in isolation and is easily fooled by the JIT compiler (dead-code elimination, constant folding, warm-up, profile pollution). A load test measures a whole system and is easily fooled by its own methodology (closed loops, coordinated omission, unrealistic data, a noisy machine). This page runs real experiments on both: a genuine JMH 1.37 run, a hand-rolled timer that lies, and a plain-Java open-model load generator against a local HTTP endpoint.

What you will run
  • NaiveBench - the hand-rolled timer, and exactly how it lies.
  • JMH 1.37 pulled from Maven Central: nine benchmarks, three forks, with real dead-code and constant-folding evidence and real error bars.
  • LoadGen - a ~100-line open-model generator (JDK HttpClient + virtual threads) against a com.sun.net.httpserver endpoint: a closed-vs-open comparison under a real 1.5 s stall and a step test that draws the hockey stick.
  • A noise audit: this sandbox was shared with other builds (load average routinely 15-38 on 12 cores), so the "same" benchmark moved by up to 3x between runs - a free lesson in baseline discipline.
Key insight: A microbenchmark is a stopwatch on a sprinter's first ten metres; a load test is the whole marathon. Neither tells you how the runner does in a crowd, in the rain, with a stitch - you need the right instrument for the question, and you must know what each one cannot see.
02
Chapter Two - Mental Model

The Benchmark Ladder and What JMH Actually Does

Choose the level of benchmark by the question. Never use a microbenchmark to predict system throughput, and never use a system load test to choose between two string-building idioms.

The benchmark ladder
Microbenchmark (JMH) one method, ns-us Is A faster than B in isolation? Component test a service + stub deps Where does one request spend its time? Load test whole deployed stack How much traffic until the SLO breaks? Production canary / A-B real users Does it still hold with real data and mix? cheap, fast, precise, easy to fool --> expensive, slow, realistic, hard to repeat Use the lowest level that can answer the question, and the highest level before you declare victory.
How JMH structures a run (defaults used on this page: 3 warm-up + 5 measurement x 1 s)
Fork 1: a fresh JVM (own JIT state, own profile) start warm-up 1 warm-up 2 warm-up 3 measure 1 measure 2 measure 3 measure 4 measure 5 Fork 2: a fresh JVM (own JIT state, own profile) start warm-up 1 warm-up 2 warm-up 3 measure 1 measure 2 measure 3 measure 4 measure 5 discarded: JIT compiles, caches fill recorded: 5 x 1 s each, per fork Result = mean over all measurement iterations of all forks (10 for 2 forks) with a 99.9 % confidence interval (the "+/-" column). More forks average out unlucky JIT decisions; warm-up lets C2 finish; the error bar says if you may trust it.
JMH generates a harness per @Benchmark at compile time (annotation processor)
โ–ถ
It produces synthetic classes that call your method in a measured loop, so the JIT sees code shaped like a real hot loop - but under JMH's control.
Each fork starts a new JVM
โ–ถ
A fresh JVM has a fresh JIT profile. Forks average out unlucky inlining decisions (and expose them: two forks disagreeing is information).
Warm-up iterations run first and are discarded
โ–ถ
The interpreter, C1 and C2 compile the hot loop; caches and branch predictors settle. Skipping this measures start-up.
Measurement iterations produce samples; JMH reports mean and a 99.9 % confidence interval
โ–ถ
The "+/-" is not decoration. If two scores' intervals overlap, you have not shown a difference.
Blackhole / return values consume results
โ–ถ
This is what prevents the JIT from deleting the work you wanted to measure.
ConceptJMH annotation / optionPurpose
What to report@BenchmarkMode(Mode.AverageTime), Throughput, SampleTimeavg ns/op, ops/s, or a percentile distribution of single-op time
State@State(Scope.Thread|Benchmark), @Setup, @Paraminputs live in fields (not constants) so they cannot be folded; params sweep sizes
Warm-up / measure@Warmup(iterations, time), @Measurementdiscard JIT ramp-up; collect stable samples
Isolation@Fork(n)n fresh JVMs; default JMH forks 5 when unspecified
Consume resultsreturn value, Blackhole.consume()defeat dead-code elimination
Profilers-prof gc, -prof stackallocation rate per op, sampled stacks (built in)
03
Chapter Three - Lab A: The Hand-Rolled Timer

Why System.nanoTime() Around a Loop Lies

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
1 ยท Problem โ€” a 30 % claim

The PR compares building an order-line string with += in a loop against a StringBuilder. The author timed one call of each with System.nanoTime() and also a 20,000-iteration loop. Reviewers ask: can we believe it?

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
2 ยท Hypothesis โ€” what we expect

A hand-rolled timer measures whatever the JVM is doing at that moment: class loading, interpreter, C1, C2, and whichever method happened to run first. If true, merely swapping the order of the two measurements will change the answer.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
3 ยท Measurement โ€” measure the measurer

NaiveBench times each variant once (cold), then times 20,000 calls of each in both orders. Run three times:

Output - java NaiveBench x 3 (real runs)
one cold call each : plus=128,758,409 ns builder=634,643 ns builder first -> plus=17,758 ns/op builder=9,400 ns/op plus first -> plus=2,376 ns/op builder=1,470 ns/op one cold call each : plus=52,093,389 ns builder=900,734 ns builder first -> plus=14,494 ns/op builder=3,859 ns/op plus first -> plus=5,407 ns/op builder=2,827 ns/op one cold call each : plus=180,892,525 ns builder=334,311 ns builder first -> plus=15,486 ns/op builder=8,425 ns/op plus first -> plus=9,598 ns/op builder=2,629 ns/op
Warm-up is visible: the first iterations measure the interpreter and C1, not C2
0 1000 2000 3000 1 2923 2 1313 3 1700 4 1411 5 1756 6 1117 7 1033 8 991 9 959 10 947 11 775 12 1218 iteration (50 ms each, NO warm-up: -wi 0) concatBuilder, ns/op, real run: iteration 1 is 3x steady state
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
4 ยท Diagnosis โ€” what the data showed
  • The first plus() call cost 52-181 ms - not string work but the one-off bootstrap of the invokedynamic string-concat machinery. A single cold timing measures class loading.
  • The loop result for plus ranged from 2.4 us to 17.8 us per call depending only on whether it ran first or second, and across identical runs (14.5 us vs 17.8 us). Whoever runs first pays for compilation of shared code and for a cold CPU.
  • The ranking stayed plus > builder here, but the "30 %" is unsupported: the ratio swung from 1.6x to 3.8x (e.g. 2,376 vs 1,470 and 14,494 vs 3,859).
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
5 ยท Optimisation โ€” change one variable: the instrument

Replace the hand-rolled timer by JMH (next chapter). Everything else - the two methods, the 50 order lines, the machine - stays the same.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
6 ยท Verification โ€” same test, compare

Chapter 04 gives the JMH answer, with confidence intervals. The verification here is negative: the old method could not have produced a repeatable number, as the three runs show.

โš  Common bugs & edge cases:
  • Timing one invocation. It is dominated by class loading and JIT. Diagnose: run the same program three times; if the numbers differ by > 20 %, the instrument is the problem.
  • Ordering effects. Whatever runs first absorbs warm-up. Always randomise or isolate in separate forks.
  • Using the result as a rate. Time per call in a hot loop is not time per call in a request that executes this code once per 10 ms (cold caches, interpreter).
04
Chapter Four - Lab B: JMH Done Properly

Setup, Benchmarks, Dead Code and Constant Folding

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
1 ยท Problem โ€” which implementation, and are my benchmarks measuring anything?

We now want trustworthy answers to four questions about OrderService internals: (a) how expensive are += and StringBuilder for 50 lines, (b) loop vs IntStream for summing line totals, and - to see the traps - (c) what does a benchmark report when the JIT deletes its work, and (d) when it folds the work into a constant.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
2 ยท Hypothesis โ€” what we expect
  • StringBuilder beats += by 2-3x at 50 appends (+= allocates a new String each iteration).
  • A plain loop beats a stream for a 50-element sum.
  • A benchmark whose result is unused will report ~1 ns; one that computes Math.sqrt of a constant will report ~1 ns too. Both numbers will be meaningless.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
3 ยท Measurement โ€” set up and run

JMH is a Maven Central library with an annotation processor. A pom is enough to download it; the commands below (plain javac, no shading) are what produced the numbers on this page. JMH needs the benchmark in a named package.

XML - pom.xml (only used to fetch jars)
<project xmlns="http://maven.apache.org/POM/4.0.0"> <modelVersion>4.0.0</modelVersion> <groupId>lab</groupId><artifactId>jmhlab</artifactId><version>1</version> <properties><maven.compiler.source>21</maven.compiler.source><maven.compiler.target>21</maven.compiler.target><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties> <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> </dependencies> </project>
Shell - build and run (about 4 minutes for 9 benchmarks x 3 forks)
mvn -q dependency:copy-dependencies -DoutputDirectory=lib CP="lib/jmh-core-1.37.jar:lib/jopt-simple-5.0.4.jar:lib/commons-math3-3.6.1.jar" mkdir out javac -cp "$CP" -processorpath "lib/jmh-generator-annprocess-1.37.jar:$CP" -d out src/lab/OrderBench.java java -cp "out:$CP" org.openjdk.jmh.Main -f 3
Java - src/lab/OrderBench.java (the complete benchmark class)
package lab; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole; import java.util.concurrent.TimeUnit; import java.util.stream.IntStream; @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @Warmup(iterations = 3, time = 1) @Measurement(iterations = 5, time = 1) @Fork(2) @State(Scope.Thread) public class OrderBench { double x = 12345.678; // non-final field: JIT cannot constant-fold it static final double CONST = 144.0; // compile-time constant: JIT can fold Math.sqrt(CONST) int[] lineTotals; int lines = 50; @Setup public void setup() { lineTotals = new int[lines]; for (int i = 0; i < lines; i++) lineTotals[i] = 100 + i; } // ---- pitfall 1: dead-code elimination -------------------------------------------- @Benchmark public void deadCode() { double acc = 0; for (int i = 0; i < lineTotals.length; i++) acc += Math.sqrt(lineTotals[i] * x); // acc never used -> loop can be deleted } @Benchmark public double returned() { double acc = 0; for (int i = 0; i < lineTotals.length; i++) acc += Math.sqrt(lineTotals[i] * x); return acc; // JMH consumes the return value } @Benchmark public void blackholed(Blackhole bh) { double acc = 0; for (int i = 0; i < lineTotals.length; i++) acc += Math.sqrt(lineTotals[i] * x); bh.consume(acc); } // ---- pitfall 2: constant folding --------------------------------------------------- @Benchmark public double constantFolded() { return Math.sqrt(CONST); // folded to 12.0 at compile time } @Benchmark public double notFolded() { return Math.sqrt(x); // depends on state } // ---- a real question: summing order lines ------------------------------------------- @Benchmark public int sumLoop() { int s = 0; for (int v : lineTotals) s += v; return s; } @Benchmark public int sumStream() { return IntStream.of(lineTotals).sum(); } // ---- building a log/JSON line for each order line --------------------------------- @Benchmark public String concatPlus() { String s = ""; for (int i = 0; i < lines; i++) s += lineTotals[i] + ","; return s; } @Benchmark public String concatBuilder() { StringBuilder sb = new StringBuilder(); for (int i = 0; i < lines; i++) sb.append(lineTotals[i]).append(','); return sb.toString(); } }
Output - JMH header (real)
# JMH version: 1.37 # VM version: JDK 21.0.2, OpenJDK 64-Bit Server VM, 21.0.2+13-LTS # VM invoker: /home/vinay-kumar-k/.sdkman/candidates/java/21.0.2-tem/bin/java # VM options: <none> # Blackhole mode: compiler (auto-detected, use -Djmh.blackhole.autoDetect=false to disable) # Warmup: 3 iterations, 1 s each # Measurement: 5 iterations, 1 s each # Timeout: 10 min per iteration # Threads: 1 thread, will synchronize iterations # Benchmark mode: Average time, time/op ...
Output - JMH summary, 3 forks x (3 warm-up + 5 measurement) x 1 s, machine load average ~21 on 12 cores (real)
Benchmark Mode Cnt Score Error Units OrderBench.blackholed avgt 15 408.469 ยฑ 159.502 ns/op OrderBench.concatBuilder avgt 15 1148.384 ยฑ 321.057 ns/op OrderBench.concatPlus avgt 15 3112.977 ยฑ 1460.212 ns/op OrderBench.constantFolded avgt 15 1.088 ยฑ 0.517 ns/op OrderBench.deadCode avgt 15 1.679 ยฑ 0.509 ns/op OrderBench.notFolded avgt 15 6.387 ยฑ 3.176 ns/op OrderBench.returned avgt 15 247.325 ยฑ 163.199 ns/op OrderBench.sumLoop avgt 15 32.653 ยฑ 9.605 ns/op OrderBench.sumStream avgt 15 79.802 ยฑ 35.315 ns/op
JMH result, average time per op (log axis) - run on a loaded machine, error bars are wide
1 ns 10 ns 100 ns 1000 ns 10000 ns deadCode 1.68 ns +/- 0.5 constantFolded 1.09 ns +/- 0.5 notFolded 6.39 ns +/- 3.2 sumLoop 32.65 ns +/- 9.6 sumStream 79.8 ns +/- 35 returned (50 sqrt) 247.3 ns +/- 163 blackholed (50 sqrt) 408.5 ns +/- 160 concatBuilder 1148 ns +/- 321 concatPlus 3113 ns +/- 1460Red = benchmarks that measured nothing (dead code / folded constant). 3 forks, 3 warm-up + 5 measurement iterations.
The three classic microbenchmark lies
Dead-code elimination loop result never used JIT deletes the loop 1.7 ns for 50 sqrt = lie fix: return it / Blackhole Constant folding Math.sqrt(144.0) is a constant folded at JIT time 1.1 ns vs 6.4 ns fix: read from @State field Loop / inlining artefacts manual for-loops in @Benchmark unrolled and hoisted per-iteration cost drops fix: one op per call
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
4 ยท Diagnosis โ€” what the data showed
  • deadCode = 1.68 ns vs returned = 247 ns for the same 50-element sqrt loop: with the result unused the JIT removed the loop. A 150x "speed-up" that is really "nothing was measured".
  • constantFolded = 1.09 ns vs notFolded = 6.39 ns: Math.sqrt(144.0) was folded into the constant 12.0; reading x from a state field forces the real operation.
  • concatPlus 3,113 +/- 1,460 vs concatBuilder 1,148 +/- 321 ns: intervals do not overlap, so builder is faster (about 2.7x). The wide intervals say the machine was noisy.
  • sumLoop 32.7 +/- 9.6 vs sumStream 79.8 +/- 35.3 ns: non-overlapping again; the stream pays set-up cost that dominates for 50 elements.
  • returned (247 +/- 163) vs blackholed (408 +/- 160) are statistically the same code; the gap is noise. This is exactly why you read the error column before telling a story.
  • In fork 1 of blackholed the iterations read 93, 121, then ~500 ns: with no code change, a competing process stole the cores. Evidence is in the raw log, so keep it.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
5 ยท Optimisation โ€” change one variable

Within OrderService, the single decision these results support is: build per-order strings with a StringBuilder (or String.join/a streaming JSON writer) rather than += in a loop. Note what it supports not: it does not say the request will be faster, only that this operation is cheaper. Whether 2 us of savings matters against a 650 ms database call is a Chapter 08 question (Measure First).

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
6 ยท Verification โ€” same test, compare

Verification means a rerun that agrees within error bars. Re-running the whole suite twice more (see Chapter 08) gave concatPlus 2,769 / 3,163 / 3,113 ns and concatBuilder consistently several times lower, so the ordering is trustworthy; the absolute values are not. A claim of "30 % faster" would have needed non-overlapping intervals from a quiet machine.

โš  Common bugs & edge cases:
  • No @State input. Constants get folded. Diagnose: a result in the 0.3-2 ns range for non-trivial work.
  • Unused results. Return from the method or call Blackhole.consume. Diagnose: -prof perfasm/-prof gc shows 0 B/op when you expected allocation.
  • Benchmarking inside a manual loop. The JIT can unroll and hoist; prefer one operation per invocation or @OperationsPerInvocation.
  • Running from the IDE or Maven test phase. Other classpath, agents and forks=0 change the JIT. Use the generated runner jar / java -jar.
  • Comparing across machines or JDK versions. Only compare numbers produced by one harness on one setup.
  • Believing a microbenchmark about request latency. It answers "which idiom is cheaper in isolation" only.
05
Chapter Five - Lab C: Open vs Closed Load

Measuring a Real Freeze Without Coordinated Omission

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
1 ยท Problem โ€” the load test said p99 = 29 ms

The OrderService test endpoint is hammered with a closed-loop tool (two workers) and the report shows p99 = 29 ms. A release that includes a 1.5 s stop-the-world-style freeze looks perfectly healthy. In Chapter 05 of Measure First this was a simulation; here it is a real HTTP server and a real client.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
2 ยท Hypothesis โ€” what we expect

A closed-loop client stops sending during the freeze (both workers are stuck waiting), so it records ~2 slow samples. An open-model client keeps issuing 100 requests per second on schedule, so ~150 requests experience the freeze. We expect a p99 near 1.4 s vs near 29 ms.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
3 ยท Measurement โ€” build the instrument and run it

The server (com.sun.net.httpserver, 64 worker threads) sleeps 20 ms per request as a stand-in for a database call. For the "stall" variant, the handler additionally sleeps until a deadline 5 s into the run, creating a 1.5 s freeze that every in-flight request experiences. The open-model client computes intended = t0 + i x interval, spins until that moment, submits the request on a virtual thread and records completion - intended. Source in Chapter 09.

Anatomy of the plain-Java open-model load generator (LoadGen.java)
scheduler one thread, fixed rate virtual threads one per request HttpClient keep-alive pool server 8 or 64 workers intended start = t0 + i x interval (computed, never "now") record latency = completion - intended start <-- the coordinated-omission-correct number also record completion - actual send to see how much the client itself lagged
Output - java LoadGen stall (real run; 15 s per cell; machine load ~20 on 12 cores)
--- server no-stall (15 s each) --- closed loop, 2 workers n= 1356 rps= 90.4 mean= 22.1 p50= 21.7 p95= 24.7 p99= 27.5 p99.9= 43.4 max= 44.0 (ms) open 100 rps (from send) n= 1500 rps= 100.0 mean= 22.1 p50= 21.4 p95= 25.0 p99= 28.1 p99.9= 57.9 max= 71.0 (ms) open 100 rps (intended) n= 1500 rps= 100.0 mean= 22.3 p50= 21.6 p95= 25.3 p99= 28.8 p99.9= 58.0 max= 71.5 (ms) --- server stall (15 s each) --- closed loop, 2 workers n= 1226 rps= 81.7 mean= 24.5 p50= 21.4 p95= 25.1 p99= 29.0 p99.9= 1510.3 max= 1520.4 (ms) open 100 rps (from send) n= 1500 rps= 100.0 mean= 103.5 p50= 23.2 p95= 821.1 p99= 1410.2 p99.9= 1542.3 max= 1571.7 (ms) open 100 rps (intended) n= 1500 rps= 100.0 mean= 104.2 p50= 23.6 p95= 821.8 p99= 1413.7 p99.9= 1542.5 max= 1571.8 (ms)
Closed vs open workload models
Closed model (N users) Open model (arrival rate lambda) user 1 user 2 user 3 server next request waits for the reply: slow server => FEWER requests sent (self-throttling, hides overload) clock 100 req/s server arrivals do not wait for replies: slow server => queue GROWS (like real, independent users)
p99 during a real 1.5 s server freeze (LoadGen stall; same server, same freeze)
10 ms 100 ms 1000 ms 3000 ms closed loop (2 workers) 29.0 ms open 100 rps, from send 1410.2 ms open 100 rps, intended 1413.7 msClosed loop recorded 1,226 requests (rps 81.7); the open model recorded 1,500 (rps 100.0). Same freeze, 49x different p99.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
4 ยท Diagnosis โ€” what the data showed
  • No stall: all three measures agree (p99 27.5 / 28.1 / 28.8 ms). The tool is fine when nothing hurts.
  • With the freeze: closed loop p99 = 29.0 ms, but p99.9 = 1,510 ms - the freeze is visible only as 2 samples out of 1,226. The loop also reports 81.7 rps instead of the intended 100: the tell-tale of omission.
  • Open model: p95 = 821 ms, p99 = 1,410 ms, mean 103.5 ms; 1,500 samples, exactly 100 rps.
  • Measuring from send vs from intended time differs by under 4 ms here (1,410.2 vs 1,413.7): virtual threads let the generator launch every request on time, so the client itself did not lag. If the generator had been saturated (e.g. too few threads), only the "intended" column would have exposed it.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
5 ยท Optimisation โ€” change one variable: workload model

Switch from closed (N workers, think-less) to open (fixed arrival rate), keep everything else. Where your tool supports it: k6 constant-arrival-rate / ramping-arrival-rate executors, Gatling open injection profiles (constantUsersPerSec, rampUsersPerSec), wrk2 (-R rate, records latency from the scheduled time). These are named from tool documentation; they were not run in this sandbox.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
6 ยท Verification โ€” same test, compare
MetricClosed loop (2 workers)Open model (100 rps)
Requests recorded (15 s)1,2261,500
Achieved rps81.7100.0
mean24.5 ms104.2 ms
p9525.1 ms821.8 ms
p9929.0 ms1,413.7 ms
max1,520.4 ms1,571.8 ms
โš  Common bugs & edge cases:
  • Choosing closed because it is simpler. It is right only when the real system really is N users in a loop (a connection pool in front of a database, a batch with fixed workers).
  • Generator on the same box as the server. They fight for CPU; the result is a mixture. Here both ran on one machine for convenience - production tests put the generator on its own instances.
  • Generator saturation hiding as server latency. Monitor the generator: if "send time minus intended time" grows, the client is the bottleneck.
  • New TCP connection per request. Measures TLS/handshake cost, not your service (page 7).
06
Chapter Six - Lab D: The Step Test

Finding the Knee: Capacity from an Open-Model Ramp

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
1 ยท Problem โ€” how much traffic can one instance take?

Capacity planning starts with one number per instance: the arrival rate at which the SLO breaks. Guessing ("it handled 300 in the demo") is how outages are born.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
2 ยท Hypothesis โ€” what we expect

By Little's Law the test server (8 worker threads, 20 ms per request) can hold 8 requests in service, so max throughput is about 8 / 0.0207 s = 386 requests/s. Below that, latency should be flat at ~21 ms; above it, a queue forms and latency should grow with time under load.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
3 ยท Measurement โ€” step the load

LoadGen step offers 100, 200, 300, 340, 370, 400 and 450 req/s for 6 s each in the open model against the 8-worker server. Latency is measured from intended send time. (The leading "rps" in the report is the offered rate n / seconds.)

Output - java LoadGen step (real run; times in ms)
offered 100 rps n= 600 rps= 100.0 mean= 23.5 p50= 22.3 p95= 29.6 p99= 40.9 p99.9= 64.9 max= 64.9 (ms) offered 200 rps n= 1200 rps= 200.0 mean= 21.2 p50= 21.1 p95= 21.7 p99= 22.8 p99.9= 25.2 max= 25.8 (ms) offered 300 rps n= 1800 rps= 300.0 mean= 20.8 p50= 20.7 p95= 20.9 p99= 21.2 p99.9= 24.1 max= 25.2 (ms) offered 340 rps n= 2040 rps= 340.0 mean= 20.8 p50= 20.7 p95= 21.2 p99= 22.3 p99.9= 24.6 max= 24.9 (ms) offered 370 rps n= 2220 rps= 370.0 mean= 22.2 p50= 21.4 p95= 26.0 p99= 29.9 p99.9= 36.0 max= 37.4 (ms) offered 400 rps n= 2400 rps= 400.0 mean= 157.5 p50= 179.1 p95= 254.2 p99= 270.4 p99.9= 303.0 max= 305.3 (ms) offered 450 rps n= 2700 rps= 450.0 mean= 557.6 p50= 578.1 p95= 977.3 p99= 1005.5 p99.9= 1015.4 max= 1017.8 (ms)
The hockey stick, measured: open-model step test (real LoadGen step output)
0 250 500 750 1000 100 200 300 340 370 400 450 offered arrival rate (req/s), 8 server worker threads, 20 ms service time capacity ~ 8 / 0.0207 s = 386/s p99 1,005 ms p50 578 ms p99 29.9 ms at 96 % of capacity
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
4 ยท Diagnosis โ€” what the data showed
  • From 200 to 340 req/s p99 stays 21-23 ms: the server is not queueing. Even at 370 req/s (96 % of capacity) p99 is only 29.9 ms - but that is the last stable step.
  • At 400 req/s (4 % above the 386/s limit) p50 jumps from 21 ms to 179 ms and p99 to 270 ms; at 450 it is p50 578 ms / p99 1,005 ms. Latency grows with test duration because the backlog grows - the system is unstable, not slow.
  • The knee matches the prediction within a few percent. The SLO (say p99 < 50 ms) is met up to ~370 req/s; the safe operating point is lower, ~300 (78 % utilisation), to leave headroom for jitter.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
5 ยท Optimisation โ€” change one variable

The fix for a saturated tier is one of: raise capacity (more workers or instances), lower service time (the 20 ms is the lever - Chapter 06 of Measure First), or shed load. We change none of them here; the experiment's product is the number "~370 rps per instance at p99 < 50 ms", which feeds Capacity Planning.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
6 ยท Verification โ€” same test, compare
OfferedPredicted by 8/0.0207Measured p99Verdict
300/s78 % busy, no queue21.2 msstable
370/s96 % busy, small queue29.9 msstable, little headroom
400/s104 %: queue grows270.4 ms (6 s run)unstable
450/s117 %: queue grows faster1,005.5 msunstable

Because the unstable steps lasted only 6 s, their latencies are a function of how long you ran them. A soak at 400 rps would show seconds, then timeouts. Report such steps as "saturated", not as a latency.

โš  Common bugs & edge cases:
  • Reading a latency number from a saturated step. It is an artefact of test length. Stop the ramp when p99 breaks the SLO.
  • Steps too short. Allow warm-up and let queues form; 60 s per step is more typical than 6 s (6 s was used here for time).
  • Too coarse steps near the knee. Add finer steps (340, 370, 400 here) around the predicted capacity.
  • Forgetting the dependent tiers. This server had no database. In OrderService the knee is usually set by the Hikari pool or the database (page 8), not Tomcat.
07
Chapter Seven - Test Design

Shapes, Realism and What to Record

The experiments above used a trivial endpoint. A load test of real OrderService adds three realism requirements: the right traffic shape, realistic data and mix, and a complete record of what was run.

Load-test shapes
Ramp find the knee, gently Step hold each level, read p99 per step Soak hours at 70 % - leaks, pools, GC creep Spike 10x for 2 min - queues, autoscaling y-axis = offered load (rps), x-axis = time. Every shape answers a different question; none replaces the others.
TestShapeAnswersTypical OrderService setting
Smoke1-5 users, 1 minDoes the script work?before every real run
Ramp / stepincrease until SLO breaksWhere is the knee? (Chapter 06)+50 rps every 2-5 min
Soak60-80 % of knee for hoursleaks, pool exhaustion, GC creep, log growth4-12 h at 250 rps
Spikejump 10x for minutesqueues, autoscaling reaction, load shedding100 to 1,000 rps for 2 min
Stress / breakpointbeyond capacityhow it fails and recoversverify graceful degradation, not just survival
Realism checklist
  • Data distribution: real customer ids are skewed (a few have thousands of orders). Uniform random ids hit different cache and index behaviour than production; sample production id distributions.
  • Request mix: reproduce the ratio of GET /orders/{id}, GET /customers/{id}/orders and POST /orders from access logs, not 1:1:1.
  • Think time and arrival pattern: open model with Poisson-like arrivals; if you model users, add think time between steps.
  • Data volume: a table with 1,000 rows has different plans from one with 50 million (page 8).
  • Connection behaviour: keep-alive, TLS and HTTP version like real clients.
  • Downstream realism: stub PaymentGateway and InventoryService with realistic latency distributions (including a slow tail), not instant mocks.
What to record for every run
RecordWhy
commit / build id, JVM flags, JDK versionreproducibility; flags drift
instance type, count, region/AZ, container limitsresults do not transfer across shapes
data set size and generation seedplans and cache hit ratios depend on it
load script version, rate, duration, warm-up discardedthe test is part of the experiment
p50/p95/p99/p99.9, throughput, error rate, intended vs achieved rpsomission check
CPU, GC pause, pool active/pending, DB CPU and connectionsto find why, not just that
Example tool invocations (how-to; not run in this sandbox)
Shell - illustrative commands, flags per tool documentation
# k6: open model at 300 req/s for 10 minutes (script defines the scenario) # executor: constant-arrival-rate, rate: 300, timeUnit: 1s, duration: 10m, preAllocatedVUs: 200 k6 run orders-open-300.js # wrk2: constant throughput 300 req/s, 8 threads, 64 connections, 60 s, latency distribution wrk2 -t8 -c64 -d60s -R300 --latency http://orders.test.internal/orders/42 # Gatling: open injection (Scala/Java DSL): constantUsersPerSec(300).during(600)
โš  Common bugs & edge cases:
  • Testing against a shared dev environment. Other traffic changes your results; use an isolated stack or label the run noisy.
  • Load testing production-like data volume with an empty cache only. Test both cold and warm cache states; report both.
  • No guard on side effects. A POST /orders load test creates real orders and payments; point it at stubs and clean data between runs.
08
Chapter Eight - Baseline Discipline

Noisy Neighbours, Repeatability and Honest Reporting

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
1 ยท Problem โ€” the same benchmark, three different answers

While producing this page, the benchmark suite was run three times unchanged. During all of them the sandbox was shared with other compile and test jobs: uptime reported load averages of 15-38 on a 12-core machine.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
2 ยท Hypothesis โ€” what we expect

A noisy neighbour steals CPU time and cache; short, CPU-bound benchmarks suffer most, and the effect is unevenly distributed between iterations, forks and runs. Expect scores to wander and error bars to widen.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
3 ยท Measurement โ€” compare complete runs
Benchmark (ns/op)Run 1 (2 forks, load 29-37)Run 2 (2 forks, load 38 to 21)Run 3 (3 forks, load 21-22)
sumLoop45.4 +/- 14.225.9 +/- 11.132.7 +/- 9.6
sumStream157.2 +/- 93.247.2 +/- 19.279.8 +/- 35.3
concatPlus2,768.6 +/- 643.13,163.1 +/- 3,169.13,113.0 +/- 1,460.2
concatBuilder1,208.2 +/- 454.02,960.5 +/- 1,526.81,148.4 +/- 321.1

(Run 1 used the first draft of the dead-code benchmark so only the four unchanged benchmarks are comparable. Run 2 load average fell from 38 to 21; run 3 stayed ~21-22. The load values are the uptime readings taken at start and end of each run.)

Noisy neighbour in the flesh: three runs of the same benchmarks
10 100 1000 45.4 25.9 32.7 sumLoop (ns/op) 157.2 47.2 79.8 sumStream (ns/op) 2768.6 3163.1 3113 concatPlus (ns/op) Same code, three complete JMH runs on a loaded host (load avg 15-38): sumStream differs 3.3x.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
4 ยท Diagnosis โ€” what the data showed
  • sumStream varied from 47 to 157 ns - 3.3x - for identical code. concatBuilder in run 2 (2,960 ns) was not faster than concatPlus (3,163 +/- 3,169): error bars overlapped completely, so run 2 alone would have refuted the correct conclusion.
  • The error column honestly warned about it: the run with the worst ranking also had +/- 3,169 ns on a 3,163 ns score (100 %).
  • Fork-level logs showed intra-run swings (93 ns to 500 ns inside one fork) - a sign of CPU theft, not of JIT behaviour.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
5 ยท Optimisation โ€” change one variable: control the environment
ControlHow
Dedicated hardware / quiet machinea benchmark box or dedicated host; on AWS use a dedicated tenancy or a large instance you do not share, and avoid burstable (T-family) types whose CPU credits change throughput mid-run
Pin and fix CPU frequencytaskset / cgroup cpuset; disable turbo and power saving on bare metal (not available in a managed cloud VM)
Check the load before and afterrecord uptime, CPU steal (%st in top), and discard runs where load was not ~0
Repeat and report the spreadat least 3 complete runs; report min / median / max, never the best
A/B interleaverun baseline and candidate alternately on the same host so drift hits both
Keep a baseline in CIcompare each commit to a stored result with a noise threshold, not a single-run diff
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
6 ยท Verification โ€” same test, compare

A claim is verified when a reviewer can rerun the recorded procedure on a quiet machine and obtain results whose confidence intervals overlap yours. The summary you publish should state: hardware, load at start/end, forks, iterations, and for each comparison whether the intervals are disjoint.

โš  Common bugs & edge cases:
  • Reporting the best run. Selection bias; the best run is the least noisy-neighbour-affected and luckiest.
  • Benchmarking on a laptop on battery or while compiling. Thermal throttling and background jobs.
  • Burstable cloud instances. Credit exhaustion turns a flat line into a cliff mid-test. Diagnose: CPU credit balance metric and a step in throughput.
  • Cross-AZ or shared-NIC noise. For network-heavy tests record the instance placement and run at several times of day.
09
Chapter Nine - Build It

Try It Yourself: Complete Source

All numbers on this page were produced by the code below on JDK 21.0.2 with JMH 1.37, on a 12-core laptop-class machine that was busy with other work (load average 15-38). Expect different absolute numbers; the shapes - dead code at ~1 ns, a hockey stick at the capacity predicted by workers / service time, and an open-model p99 that sees the freeze - should reproduce.

NaiveBench.java - how a hand-rolled timer lies
Java - NaiveBench.java
public class NaiveBench { static int[] totals = new int[50]; static String plus() { String s = ""; for (int i = 0; i < 50; i++) s += totals[i] + ","; return s; } static String builder() { StringBuilder sb = new StringBuilder(); for (int i = 0; i < 50; i++) sb.append(totals[i]).append(','); return sb.toString(); } static long sink; public static void main(String[] args) { // Mistake 1: time ONE call, cold, in a fixed order long t0 = System.nanoTime(); plus(); long a = System.nanoTime() - t0; t0 = System.nanoTime(); builder(); long b = System.nanoTime() - t0; System.out.printf("one cold call each : plus=%,d ns builder=%,d ns%n", a, b); // Mistake 2: loop with a hand-rolled warm-up of 1 iteration, order-dependent for (String name : new String[]{"builder first", "plus first"}) { boolean builderFirst = name.startsWith("builder"); long[] r = new long[2]; for (int round = 0; round < 2; round++) { boolean useBuilder = (round == 0) == builderFirst; long s = System.nanoTime(); for (int i = 0; i < 20_000; i++) sink += (useBuilder ? builder() : plus()).length(); r[useBuilder ? 1 : 0] = (System.nanoTime() - s) / 20_000; } System.out.printf("%-14s -> plus=%,d ns/op builder=%,d ns/op%n", name, r[0], r[1]); } } }
LoadGen.java - open- vs closed-model load generator (JDK only)
Shell
javac LoadGen.java java LoadGen stall # closed vs open under a 1.5 s freeze (~65 s) java LoadGen step # open-model step test (~45 s)
Java - LoadGen.java
import com.sun.net.httpserver.HttpServer; import java.net.InetSocketAddress; import java.net.URI; import java.net.http.*; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.*; /** * Tiny load lab. A local "OrderService" endpoint sleeps ~20 ms per request (stand-in for a DB call). * java LoadGen stall : closed-loop vs open-model client against a server that freezes for 1.5 s * java LoadGen step : open-model step test at increasing arrival rates (server has 8 worker threads) */ public class LoadGen { static volatile long stallFromNs = Long.MAX_VALUE, stallToNs = Long.MAX_VALUE; static HttpServer startServer(int workers) throws Exception { HttpServer s = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); s.setExecutor(Executors.newFixedThreadPool(workers)); s.createContext("/orders/42", ex -> { long now = System.nanoTime(); if (now >= stallFromNs && now < stallToNs) { // simulated freeze (GC pause, failover...) try { Thread.sleep((stallToNs - now) / 1_000_000); } catch (InterruptedException e) { } } try { Thread.sleep(20); } catch (InterruptedException e) { } // simulated DB call byte[] body = "{\"id\":42,\"status\":\"PAID\"}".getBytes(); ex.sendResponseHeaders(200, body.length); ex.getResponseBody().write(body); ex.close(); }); s.start(); return s; } static double pct(long[] sortedNs, double p) { int rank = (int) Math.ceil(p / 100.0 * sortedNs.length); return sortedNs[Math.max(0, rank - 1)] / 1e6; } static void report(String name, long[] ns, double seconds) { long[] a = ns.clone(); Arrays.sort(a); double mean = Arrays.stream(a).average().orElse(0) / 1e6; System.out.printf("%-26s n=%5d rps=%6.1f mean=%7.1f p50=%6.1f p95=%7.1f p99=%8.1f p99.9=%8.1f max=%8.1f (ms)%n", name, a.length, a.length / seconds, mean, pct(a, 50), pct(a, 95), pct(a, 99), pct(a, 99.9), a[a.length - 1] / 1e6); } /** closed loop: each worker sends, waits for the response, sends again */ static long[] closed(HttpClient c, URI uri, int workers, long durationNs) throws Exception { List<Long> all = Collections.synchronizedList(new ArrayList<>()); long end = System.nanoTime() + durationNs; List<Thread> ts = new ArrayList<>(); for (int i = 0; i < workers; i++) { Thread t = Thread.ofPlatform().start(() -> { HttpRequest r = HttpRequest.newBuilder(uri).build(); while (System.nanoTime() < end) { long t0 = System.nanoTime(); try { c.send(r, HttpResponse.BodyHandlers.discarding()); } catch (Exception e) { } all.add(System.nanoTime() - t0); } }); ts.add(t); } for (Thread t : ts) t.join(); return all.stream().mapToLong(Long::longValue).toArray(); } /** open model: requests are launched on a fixed schedule; latency is measured from the INTENDED start */ static long[][] open(HttpClient c, URI uri, int rate, long durationNs) throws Exception { int total = (int) (rate * durationNs / 1_000_000_000L); long[] fromIntended = new long[total], fromSend = new long[total]; long interval = 1_000_000_000L / rate; long t0 = System.nanoTime(); try (ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor()) { HttpRequest r = HttpRequest.newBuilder(uri).build(); for (int i = 0; i < total; i++) { long intended = t0 + i * interval; while (System.nanoTime() < intended) Thread.onSpinWait(); final int idx = i; vt.submit(() -> { long sent = System.nanoTime(); try { c.send(r, HttpResponse.BodyHandlers.discarding()); } catch (Exception e) { } long done = System.nanoTime(); fromIntended[idx] = done - intended; fromSend[idx] = done - sent; }); } } return new long[][]{fromIntended, fromSend}; } public static void main(String[] args) throws Exception { String mode = args.length > 0 ? args[0] : "stall"; HttpClient client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build(); if (mode.equals("stall")) { HttpServer s = startServer(64); URI uri = URI.create("http://127.0.0.1:" + s.getAddress().getPort() + "/orders/42"); closed(client, uri, 2, 2_000_000_000L); // warm-up open(client, uri, 100, 2_000_000_000L); for (String variant : new String[]{"no-stall", "stall"}) { long now = System.nanoTime(); if (variant.equals("stall")) { stallFromNs = now + 5_000_000_000L; stallToNs = stallFromNs + 1_500_000_000L; } else { stallFromNs = stallToNs = Long.MAX_VALUE; } System.out.println("--- server " + variant + " (15 s each) ---"); long[] c = closed(client, uri, 2, 15_000_000_000L); report("closed loop, 2 workers", c, 15); if (variant.equals("stall")) { stallFromNs = System.nanoTime() + 5_000_000_000L; stallToNs = stallFromNs + 1_500_000_000L; } long[][] o = open(client, uri, 100, 15_000_000_000L); report("open 100 rps (from send)", o[1], 15); report("open 100 rps (intended)", o[0], 15); } s.stop(0); System.exit(0); } else { HttpServer s = startServer(8); URI uri = URI.create("http://127.0.0.1:" + s.getAddress().getPort() + "/orders/42"); open(client, uri, 100, 2_000_000_000L); for (int rate : new int[]{100, 200, 300, 340, 370, 400, 450}) { long[][] o = open(client, uri, rate, 6_000_000_000L); report("offered " + rate + " rps", o[0], 6); } s.stop(0); System.exit(0); } } }
Experiments to try
  • Change the server to 4 workers: the knee should move to about 4 / 0.0207 = 193 req/s. Predict first, then measure.
  • In the open generator, reduce the generator parallelism (use a platform thread pool of 4) and compare "from send" vs "intended" latency under a stall.
  • Add @Param({"10","50","500"}) for the number of lines in OrderBench and watch += go quadratic.
  • Run -prof gc on the concat benchmarks to see bytes allocated per op (a built-in JMH profiler; not run here).
10
Chapter Ten - Interview Angles

Benchmarking Questions and Model Answers

  • Q: Why do we need warm-up in a Java benchmark?
    Code starts interpreted, then C1, then C2 compiles hot paths using the profile gathered so far. Early iterations measure compilation and class loading; in the lab the first 50 ms iteration of a builder loop was 3x steady state.
  • Q: What is dead-code elimination in JMH terms?
    If a benchmark computes a value nobody uses the JIT may delete the computation. In the lab, an unused 50-element sqrt loop reported 1.7 ns while the same loop with its result returned took 247 ns. Fix: return the value or pass it to a Blackhole.
  • Q: Why must benchmark inputs come from @State fields?
    Compile-time constants let the JIT fold the work away (Math.sqrt of a literal is evaluated at compile time).
  • Q: What do forks add?
    Fresh JVMs, so one run's JIT decisions do not contaminate or favour another; disagreement between forks is a warning.
  • Q: Open vs closed workload - when each?
    Open when arrivals are independent of the system (web traffic); closed when a fixed population loops (workers behind a pool, batch jobs). Closed tools under-report outages (coordinated omission).
  • Q: How do you find single-instance capacity?
    Step test in the open model, watch p99 and the queue; capacity is the last step meeting the SLO, minus headroom. Cross-check with workers / service time (Little).
  • Q: Your benchmark shows 20 % improvement. What do you check before merging?
    Error bars/CI disjoint, repeated on a quiet machine, same JDK and flags, other benchmark in the same suite not regressed, and whether the changed code is even on the critical path of a real request.
  • Q: What makes a load test realistic?
    Production-shaped data and request mix, open arrivals, realistic downstream latency including tails, warm and cold cache states, a generator that is not the bottleneck.
11
Chapter Eleven - Summary

Cheat Sheet and Recap

Which benchmark for which question
QuestionInstrumentKey discipline
Is idiom A cheaper than B?JMHState inputs, consume results, forks, read +/-
Where does a request spend time?Component test + profiler / tracesAttribute before optimising (Profiling)
How much traffic per instance?Open-model step testStop at SLO break; do not quote saturated latencies
Does it survive a bad minute?Spike + fault injectionIntended-time latency; check recovery
Does it leak?SoakWatch heap after GC, pools, threads
Is my number trustworthy?Repeat on a quiet hostRecord environment; report spread
โˆ‘ Benchmarking - what to remember
  • Hand-rolled timers measure the JVM warming up: the first += call cost 52-181 ms; the same loop varied 7x with run order.
  • JMH needs three things: warm-up + forks, inputs from @State, results consumed. Violate them and it reports 1 ns for work that costs 247 ns.
  • Read the error column. Non-overlapping intervals support a claim; overlapping ones do not, whatever the means say.
  • Open model + intended time: closed p99 = 29 ms, open p99 = 1,410 ms for the same 1.5 s freeze.
  • Step tests find the knee: p99 21-30 ms up to 370 rps, 270 ms at 400 rps, 1,005 ms at 450 rps on an 8-worker, 20 ms server - capacity matched workers / service time.
  • Noise is real: the same suite moved up to 3.3x between runs on a loaded host; control and record the environment.
Related pages: