🛸 Hitchhiker's Guide — Phase 08: Performance Engineering
Read this if: someone asked "is this change faster?" and you didn't know how to prove it.
0. The 30-second mental model
YOUR INTUITION NAIVE BENCHMARK JMH BENCHMARK
↓ ↓ ↓
"StringBuilder System.nanoTime() @Warmup(3) + @Measurement(5)
must be faster before/after + @Fork(1) + Blackhole
than += ..." → JIT lies to you → reproducible truth
The JVM optimizes aggressively. Without a proper harness:
- The JIT compiles and eliminates code you think is running.
- The first iterations are 10–100× slower (interpreter phase).
- Results differ run-to-run by 20–50% without statistical rigor.
JMH eliminates all three problems. It is the only correct way to benchmark JVM code.
1. Why Naive Benchmarks Lie
Dead Code Elimination (DCE)
// BAD: JIT sees result is never used → eliminates the method call entirely
long start = System.nanoTime();
String s = buildString(); // ← JIT: "result discarded, remove this"
long elapsed = System.nanoTime() - start;
// elapsed ≈ 0 ns — you measured nothing
JMH fix: benchmark methods return the result, or consume it via Blackhole.consume().
JMH's framework ensures the return value is "used".
Constant Folding
// BAD: JIT computes 1000 * 1000 at compile time
int SIZE = 1000;
long start = System.nanoTime();
int result = SIZE * SIZE; // ← JIT: this is just 1000000, inline it
JMH fix: put inputs in @State fields. The JIT cannot fold fields it doesn't
know the value of at compile time.
JIT Warm-up
The HotSpot JVM starts in interpreted mode, then tiers up:
- Tier 1: C1 compiler (fast compile, limited optimization)
- Tier 4: C2 compiler (slow compile, full optimization)
The transition happens after ~10,000 invocations. If your benchmark runs 1,000 iterations, the first 100–500 are unrepresentative.
JMH fix: @Warmup(iterations = 3, time = 1) — run 3 warm-up seconds that are
recorded but not included in the final result.
2. Reading JMH Output
Benchmark (n) Mode Cnt Score Error Units
StringConcatBenchmark.concat 10 avgt 5 283.1 ± 5.2 ns/op
StringConcatBenchmark.builder 10 avgt 5 41.8 ± 0.7 ns/op
StringConcatBenchmark.builderPS 10 avgt 5 38.2 ± 0.4 ns/op
| Column | Meaning |
|---|---|
Mode: avgt | Average time per operation |
Mode: thrpt | Operations per unit time |
Mode: ss | Single-shot time (no warmup, measures cold start) |
Cnt | Number of measurement samples |
Score | Mean value across samples |
Error | ±1.96 standard deviations (95% confidence interval) |
Units: ns/op | Nanoseconds per operation |
Rule of thumb: if two scores' error bars overlap, the difference is not statistically significant. You need more iterations or a bigger workload.
3. Allocation and GC Pressure
The Autoboxing Tax
Every int stored in an ArrayList<Integer> or HashMap<String, Integer> is
heap-allocated as an Integer object (16 bytes on HotSpot, including header).
In a tight loop processing 1M items:
int[] → 4 MB heap, no GC pressure
Integer[] → ~16 MB heap + GC pressure from pointer indirection
Escape Analysis
The JIT can eliminate short-lived object allocations if it can prove the object never "escapes" the creating method:
// This allocation may be eliminated entirely by the JIT:
String result = new StringBuilder().append("a").append("b").toString();
// The StringBuilder never escapes → JIT may optimize to direct string creation
This is why JMH benchmarks of "small" allocations sometimes show 0 allocation —
the JIT removed them. Use the JMH GC profiler (-prof gc) to see real allocation
rates: mvn compile exec:java -Dexec.mainClass="org.openjdk.jmh.Main" -Dexec.args="BoxingBenchmark -prof gc".
GC Modes (Java 11+)
| GC | Tuning goal | When to use |
|---|---|---|
| G1GC (default) | Balanced pause time | Most applications |
| ZGC | Sub-millisecond pauses | Latency-sensitive (Kafka broker, etc.) |
| Shenandoah | Concurrent compaction | Low-pause on smaller heaps |
For Apache services (Kafka, HBase, HDFS), the GC choice is a critical deployment decision. A benchmark without specifying GC mode is incomplete.
4. Lock Contention
The Four Primitives
| Primitive | Mechanism | Best for | Limitation |
|---|---|---|---|
synchronized | OS mutex via monitorenter/monitorexit bytecode | Low-contention, simple code | Uncontested is fast; contested has OS overhead |
ReentrantLock | JUC lock with explicit lock/unlock | Needing tryLock, timeout, fairness | More verbose; similar overhead to synchronized |
AtomicLong | CAS (Compare-And-Swap) hardware instruction | Single counter, moderate contention | CAS retry storms under high contention |
LongAdder | Striped counter: each thread has a cell | High-contention write-heavy counters | sum() requires summing all cells (O(stripes)) |
Why LongAdder Wins at High Thread Counts
AtomicLong.incrementAndGet() uses a single CAS loop:
CAS(expected=42, new=43) → success? done : retry
Under 16 threads all hammering the same address, most CAS attempts fail and retry. This is a contention storm — throughput drops as thread count increases.
LongAdder maintains a Cell[] array. Each thread mostly writes to its own cell,
eliminating CAS contention. sum() adds all cells — cheap for infrequent reads.
False Sharing
Two fields on the same 64-byte CPU cache line cause "false sharing":
Thread A writes counter1 → invalidates cache line → Thread B must reload counter2
Even though Thread B never touched counter1.
Java 8+ solution: @jdk.internal.vm.annotation.Contended adds 128 bytes of padding.
LongAdder already uses this internally. This is why it wins at high thread counts:
each Cell is padded to its own cache line.
5. What Can Go Wrong in Production
| Scenario | Symptom | Root cause | Diagnosis |
|---|---|---|---|
| JIT deoptimization | Throughput drops suddenly after stable period | New code path executed → compiled code invalidated | -XX:+PrintCompilation / JFR |
| GC pause spikes | p99 latency 10× higher than median | Old gen collection triggered | GC log analysis, -Xlog:gc* |
| Lock convoy | Threads queuing behind one slow lock holder | Non-atomic critical section + OS scheduling | Thread dump, jstack |
| Memory leak | Old gen grows until OOM | Objects held by static collection | Heap dump, jmap / Flight Recorder |
| False sharing | Counter throughput degrades at 8+ threads | Cache line bouncing between CPU cores | perf stat -e cache-misses |
6. Interview Questions — Concepts You Must Explain Out Loud
| Topic | Question | One-line answer |
|---|---|---|
| JMH basics | Why can't you use System.nanoTime() for benchmarking? | JIT eliminates dead code, and there's no warmup — results are unreliable |
| JMH output | What does the ± in JMH output represent? | 95% confidence interval — overlapping bars means no significant difference |
| Modes | When would you use Mode.SampleTime instead of Mode.AverageTime? | When you care about p99/p999 latency tails, not just the mean |
| Allocation | Why is int[] faster than List<Integer> in a hot loop? | Autoboxing allocates one Integer heap object per element; int[] is contiguous stack/heap primitive |
| GC | What is escape analysis and how does it affect allocation benchmarks? | JIT proves short-lived objects don't escape → eliminates the allocation entirely |
| Contention | When is synchronized faster than AtomicLong? | Uncontested synchronized is a fast biased/thin lock — sometimes cheaper than a CAS instruction |
| LongAdder | Why does LongAdder outperform AtomicLong at high concurrency? | Striped cells + cache-line padding eliminate CAS contention storms |
| False sharing | Two counters in the same object behave strangely under concurrency — what do you suspect? | False sharing: both fields land on the same cache line; fix with @Contended or padding |
7. References
- JMH Samples — official examples for every JMH feature
- Aleksey Shipilёv — JMH Introduction
- Java Memory Model — Chapter 17 of the JLS
- LongAdder Javadoc — explains the striped cell design
- False Sharing — OpenJDK Blog
- G1GC Tuning Guide