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
- Chapter 2: Why Naive Benchmarks Lie — and How JMH Doesn't
- Chapter 3: Memory and Allocation — The Young-Generation Economy
- Chapter 4: Garbage Collectors and Choosing One
- Chapter 5: Hidden Allocation — Boxing, Streams, Strings
- Chapter 6: The Java Memory Model in Five Rules
- Chapter 7: Contention — Locks, CAS, and Striping
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
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
intloop 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 effect | The lie | JMH counter |
|---|---|---|
| Dead-code elimination | unused result → loop deleted → "0.3 ns" | return the value / Blackhole.consume |
| Constant folding | compile-time-known input → computed once | inputs from non-final @State fields |
| Warmup | first 10K iterations measure the interpreter | @Warmup iterations before @Measurement |
| Profile pollution | benchmark A's types deopt benchmark B's fast path | @Fork — fresh JVM per benchmark |
| Loop unrolling/OSR | manual timing loop ≠ production loop shape | JMH controls the measurement loop |
| Run-to-run variance | one number, no error bars | multiple 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
| Collector | Strategy | Pauses | Choose when |
|---|---|---|---|
| Parallel | stop-the-world, maximum throughput | 100s of ms | batch jobs; pause-insensitive (Spark executors) |
| G1 (default) | region-based, incremental, pause-target | ~10–200 ms, tunable | the general default; services, brokers |
| ZGC / Shenandoah | fully concurrent, colored pointers / brooks pointers | <1–2 ms | latency SLOs with big heaps; pay ~10–15% throughput |
| Serial | single-threaded | long | tiny 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 aLongper put/get outside the small cache (−128..127); along-keyed primitive collection or plainlongaccumulator is allocation-free. The classic regression: a refactor flipslong→Longin 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 += xis O(n²) copying;StringBuilderwith a pre-sized capacity avoids both the copies and the resize-doubling garbage. - Iterator churn, defensive copies, varargs arrays,
Optionalin 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):
- 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).
- 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).
volatile= visibility + ordering, never atomicity:count++on a volatile is still read-modify-write race. Compound ops need CAS or locks.- 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.
- 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.StampedLockfor 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
@Forkto 0 and watch profile pollution. The broken variants are the lesson; keep a table of effect→symptom. - Lab 02: run with
-Xlog:gcand 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:
- Walk bytecode → interpreter → C1 → C2 with what each tier adds, and define deoptimization with a trigger example.
- Name five benchmark lies and the JMH feature countering each.
- Explain the young-gen economy (why cost ∝ survivors) and the two GC-log numbers that matter.
- Choose a collector for: Spark executor, Kafka broker, p99-bound trading service — with one-line justifications.
- Recite the five JMM rules; state precisely what volatile does and doesn't give.
- 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
- JMH official samples — all 40; they are Chapter 2 in executable form
- Goetz et al., Java Concurrency in Practice — ch. 2–3, 11, 15–16
- JSR-133 FAQ — the JMM in plain language
- Shipilev, JVM Anatomy Quarks — shipilev.net/jvm/anatomy-quarks (TLABs, safepoints, escape analysis — short and definitive)
- G1 tuning guide (Oracle) and ZGC wiki
- async-profiler and JDK Flight Recorder
- jcstress — concurrency stress-testing the JMM cases