Warmup Guide — JVM Performance Engineering

Zero-to-expert primer for Phase 08: how the JVM actually executes code (JIT), manages memory (GC), and coordinates threads — and how to measure any of it without fooling yourself (JMH).

Table of Contents


Chapter 1: The JVM Execution Model — Interpreter to C2

From zero: javac produces bytecode, not machine code. At runtime, HotSpot executes it in tiers: interpreter (immediate, slow) → C1 compiler (fast compile, light optimization, adds profiling counters) → C2 (slow compile, aggressive optimization of the hot ~1%). Methods get promoted on invocation/loop-count thresholds; long loops get OSR (on-stack replacement) mid-execution.

The C2 facts that matter for both performance work and benchmarking honesty:

  • Inlining is the gateway optimization — most others (escape analysis, dead-code elimination) only fire after the callee is inlined. Small methods inline; megamorphic call sites (≥3 receiver types observed) don't — which is why an interface call that's monomorphic in production but polymorphic in your benchmark harness measures differently.
  • Speculation + deoptimization: C2 compiles assuming observed behavior (this branch never taken; only one subclass loaded). When the assumption breaks, the code deoptimizes back to the interpreter and recompiles — visible as latency spikes after a new code path or class load.
  • Escape analysis: objects proven not to escape get scalar-replaced — allocated in registers, not the heap. A microbenchmark can show "zero allocation" for code that allocates plenty in real, non-inlinable contexts.
  • Safepoints: stop-the-world operations (GC phases, deopt, thread dumps) wait for all threads to reach poll points; a counted int loop without a poll delays everyone — mysterious tail latency with "short GC pauses" is often time-to-safepoint (-Xlog:safepoint).

Chapter 2: Why Naive Benchmarks Lie — and How JMH Doesn't

The canonical lies, each mapped to its JMH counter-measure (Lab 01's content):

JIT effectThe lieJMH counter
Dead-code eliminationunused result → loop deleted → "0.3 ns"return the value / Blackhole.consume
Constant foldingcompile-time-known input → computed onceinputs from non-final @State fields
Warmupfirst 10K iterations measure the interpreter@Warmup iterations before @Measurement
Profile pollutionbenchmark A's types deopt benchmark B's fast path@Fork — fresh JVM per benchmark
Loop unrolling/OSRmanual timing loop ≠ production loop shapeJMH controls the measurement loop
Run-to-run varianceone number, no error barsmultiple iterations+forks, score ± error

The discipline beyond the tool: benchmark the deployment shape (realistic types at call sites, realistic data sizes), report distributions, and treat any result you can't explain mechanistically as a bug in the benchmark until proven otherwise. JMH's 40 official samples are the curriculum for this; the lab walks the core ones.

Chapter 3: Memory and Allocation — The Young-Generation Economy

The generational design: most objects die young. The heap splits into a young generation (eden + survivor spaces) and an old generation. Allocation is a TLAB pointer bump (~10 cycles — allocation itself is nearly free!); when eden fills, a young GC copies the few survivors and resets — cost proportional to survivors, not garbage. Objects surviving several young GCs get promoted to old; old-gen collection is the expensive one.

The operational consequence: what hurts is not allocation but allocation rate (MB/s), because it sets young-GC frequency, and high promotion rates fill the old gen. The two numbers to pull from GC logs (-Xlog:gc*) or JFR: allocation rate and promotion rate. Halving allocation rate halves GC frequency with zero tuning — which is why Lab 02 attacks allocation in code rather than flags in the JVM: per-record garbage in hot loops (Ch. 5) is where data-infrastructure GC problems actually live (Spark executors, Kafka brokers — same story).

Chapter 4: Garbage Collectors and Choosing One

CollectorStrategyPausesChoose when
Parallelstop-the-world, maximum throughput100s of msbatch jobs; pause-insensitive (Spark executors)
G1 (default)region-based, incremental, pause-target~10–200 ms, tunablethe general default; services, brokers
ZGC / Shenandoahfully concurrent, colored pointers / brooks pointers<1–2 mslatency SLOs with big heaps; pay ~10–15% throughput
Serialsingle-threadedlongtiny heaps, containers with 1 CPU

The selection logic is just: what does the workload buy with a pause? Batch buys nothing (take Parallel's throughput); a p99-SLO service buys everything (pay ZGC's tax). Two perennial gotchas: humongous allocations in G1 (objects >½ region bypass the young-gen economy — fix the allocation or the region size), and "bigger heap" as a reflex fix (often just makes pauses longer; the problem was allocation or promotion rate). And the Kafka-specific wisdom: the page cache does the heavy lifting — modest heaps (~6 GB) regardless of collector.

Chapter 5: Hidden Allocation — Boxing, Streams, Strings

Lab 02's bestiary — allocation that doesn't look like allocation:

  • Autoboxing: Map<Long, Long> allocates a Long per put/get outside the small cache (−128..127); a long-keyed primitive collection or plain long accumulator is allocation-free. The classic regression: a refactor flips longLong in a hot aggregation; allocation rate ×6 (this is the war story in interview-prep/03).
  • Streams: the pipeline objects + boxed elements (for non-primitive-specialized streams) + lambda captures allocate per call. Fine on cold paths (clarity wins); measured against a loop on hot paths — Lab 02 benchmarks exactly this, including when escape analysis rescues the stream (and when it doesn't: deep pipelines, non-inlined).
  • String concatenation in loops: s += x is O(n²) copying; StringBuilder with a pre-sized capacity avoids both the copies and the resize-doubling garbage.
  • Iterator churn, defensive copies, varargs arrays, Optional in tight loops — the long tail; the method is what matters: JFR allocation profiling (or async-profiler -e alloc) ranks allocation sites by bytes; fix the top three; re-measure.

Chapter 6: The Java Memory Model in Five Rules

The minimum correct mental model for review and design (Goetz ch. 16, compressed):

  1. Data race = undefined behavior territory: two threads, same variable, at least one write, no happens-before ordering → reads may see stale/torn values forever (visibility failure, not just timing).
  2. happens-before edges come from: lock release→acquire (same monitor), volatile write→read (same variable), thread start/join, and final-field initialization (safe publication at constructor completion).
  3. volatile = visibility + ordering, never atomicity: count++ on a volatile is still read-modify-write race. Compound ops need CAS or locks.
  4. Safe publication: an object handed between threads must cross a happens-before edge (volatile field, concurrent collection, final field in properly-constructed object). The subtle bug class: mutable object published via plain field — receiver may see a half-constructed object.
  5. Immutability ends the conversation: final fields + no leaked this → freely shareable, no synchronization, no reasoning burden. The cheapest concurrency is the kind you designed out.

Chapter 7: Contention — Locks, CAS, and Striping

What happens when threads actually collide (Lab 03's ladder):

  • synchronized: JIT-friendly (elision, coarsening), uncontended cost is tiny; contended cost is parking/wakeup and serialization. Default choice for short critical sections.
  • AtomicLong (CAS): optimistic — read, compute, compare-and-swap, retry on conflict. Uncontended: one instruction-ish. Contended: retry storms — N threads hammering one cache line, the line ping-pongs between cores, throughput drops as threads rise.
  • LongAdder (striping): contention detected → splits into per-thread-ish cells; increments touch different cache lines (no ping-pong); sum() walks the cells. Write-heavy counters scale linearly; reads cost more and are weakly consistent — exactly right for metrics, wrong for a balance check.
  • ReentrantLock: choose for features (tryLock w/ timeout, interruptibility, fairness, multiple conditions), not speed. StampedLock for read-mostly with optimistic reads — non-reentrant, easy to misuse.
  • The senior pattern behind all of it: don't share (thread-local accumulate, merge at the end), shrink the critical section, stripe what must be shared — in that order, before reaching for cleverer locks. False sharing (independent hot fields on one cache line — @Contended) is the bonus villain Lab 03's benchmark exposes.

Lab Walkthrough Guidance

Order: Lab 01 (JMH) → Lab 02 (GC/allocation) → Lab 03 (contention) — learn the instrument before pointing it at memory and threads.

  • Lab 01: run the provided benchmarks, then break them deliberately: remove the Blackhole and watch dead-code elimination produce nonsense; make the input a constant and watch folding; drop @Fork to 0 and watch profile pollution. The broken variants are the lesson; keep a table of effect→symptom.
  • Lab 02: run with -Xlog:gc and JFR; record allocation rate for the boxed vs primitive and stream vs loop variants; connect the JMH deltas to the GC-log deltas — the correlation (alloc rate → GC frequency → throughput) is the deliverable.
  • Lab 03: run the contention benchmark at 1, 4, 8, 16 threads; plot ops/sec-per-thread for synchronized/AtomicLong/LongAdder; explain each curve's shape with Chapter 7's mechanics (the AtomicLong decline is the money observation), and verify correctness tests still pass (fast and wrong is not a result).

Success Criteria

You are ready for Phase 09 when you can, from memory:

  1. Walk bytecode → interpreter → C1 → C2 with what each tier adds, and define deoptimization with a trigger example.
  2. Name five benchmark lies and the JMH feature countering each.
  3. Explain the young-gen economy (why cost ∝ survivors) and the two GC-log numbers that matter.
  4. Choose a collector for: Spark executor, Kafka broker, p99-bound trading service — with one-line justifications.
  5. Recite the five JMM rules; state precisely what volatile does and doesn't give.
  6. Draw the synchronized/CAS/LongAdder contention curves and explain each shape.

Interview Q&A

Q: Benchmark says your new code is 40× faster. What do you check before celebrating? Mechanistic plausibility first — 40× means either the old code was absurd or the new benchmark is broken. Check: is the result consumed (DCE)? inputs non-constant (folding)? forked JVM (pollution)? warmed up? realistic call-site polymorphism and data sizes? Then explain the speedup's mechanism (fewer allocations? better inlining? algorithm?) — no mechanism, no merge. (This is also the reviewer stance from Phase 06's benchmark scenario.)

Q: A Kafka broker shows rising p99 with low CPU and short GC pauses. Where do you look? Short GC pauses but rising tails → time-to-safepoint (one thread in a counted loop or long JNI call delays the world — -Xlog:safepoint), lock contention (thread dumps / JFR contention events — Ch. 7's ping-pong shows here), page-cache misses turning reads into disk I/O, or humongous-allocation churn in G1. The skill being probed: enumerating non-GC causes of pause-shaped symptoms.

Q: When would you NOT use LongAdder for a counter? When reads are frequent and must be precise/atomic with the increment (it's weakly consistent and sum() walks cells), when memory per counter matters (cells × counters — metric-registry explosions), or when there's no contention (AtomicLong is simpler and equal). Tool choice = read/write ratio + contention level + consistency need — saying that sentence is the answer.

References