🛸 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
ColumnMeaning
Mode: avgtAverage time per operation
Mode: thrptOperations per unit time
Mode: ssSingle-shot time (no warmup, measures cold start)
CntNumber of measurement samples
ScoreMean value across samples
Error±1.96 standard deviations (95% confidence interval)
Units: ns/opNanoseconds 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+)

GCTuning goalWhen to use
G1GC (default)Balanced pause timeMost applications
ZGCSub-millisecond pausesLatency-sensitive (Kafka broker, etc.)
ShenandoahConcurrent compactionLow-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

PrimitiveMechanismBest forLimitation
synchronizedOS mutex via monitorenter/monitorexit bytecodeLow-contention, simple codeUncontested is fast; contested has OS overhead
ReentrantLockJUC lock with explicit lock/unlockNeeding tryLock, timeout, fairnessMore verbose; similar overhead to synchronized
AtomicLongCAS (Compare-And-Swap) hardware instructionSingle counter, moderate contentionCAS retry storms under high contention
LongAdderStriped counter: each thread has a cellHigh-contention write-heavy counterssum() 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

ScenarioSymptomRoot causeDiagnosis
JIT deoptimizationThroughput drops suddenly after stable periodNew code path executed → compiled code invalidated-XX:+PrintCompilation / JFR
GC pause spikesp99 latency 10× higher than medianOld gen collection triggeredGC log analysis, -Xlog:gc*
Lock convoyThreads queuing behind one slow lock holderNon-atomic critical section + OS schedulingThread dump, jstack
Memory leakOld gen grows until OOMObjects held by static collectionHeap dump, jmap / Flight Recorder
False sharingCounter throughput degrades at 8+ threadsCache line bouncing between CPU coresperf stat -e cache-misses

6. Interview Questions — Concepts You Must Explain Out Loud

TopicQuestionOne-line answer
JMH basicsWhy can't you use System.nanoTime() for benchmarking?JIT eliminates dead code, and there's no warmup — results are unreliable
JMH outputWhat does the ± in JMH output represent?95% confidence interval — overlapping bars means no significant difference
ModesWhen would you use Mode.SampleTime instead of Mode.AverageTime?When you care about p99/p999 latency tails, not just the mean
AllocationWhy is int[] faster than List<Integer> in a hot loop?Autoboxing allocates one Integer heap object per element; int[] is contiguous stack/heap primitive
GCWhat is escape analysis and how does it affect allocation benchmarks?JIT proves short-lived objects don't escape → eliminates the allocation entirely
ContentionWhen is synchronized faster than AtomicLong?Uncontested synchronized is a fast biased/thin lock — sometimes cheaper than a CAS instruction
LongAdderWhy does LongAdder outperform AtomicLong at high concurrency?Striped cells + cache-line padding eliminate CAS contention storms
False sharingTwo 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