Java Runtime Deep Dive
Target audience: candidates interviewing in Java at FAANG, finance, Android, or any backend role where the interviewer is allowed to probe “what does the JVM do here?”
Scope: HotSpot JVM (OpenJDK 21+ baseline), with notes on JDK 17 LTS where behavior diverges. Other JVMs (OpenJ9, GraalVM native-image) are noted only where they change interview-grade answers.
Java’s verbosity makes it easy to mistake “I write Java daily” for “I know Java.” A senior interviewer will quickly find the gap by asking what Integer i = 200; Integer j = 200; i == j returns, why your HashMap has O(log N) worst-case lookup since Java 8, and what volatile actually guarantees. This guide closes the gap.
1. JVM Architecture
The JVM is a stack-based virtual machine with a tiered execution model.
.java ── javac ──► .class (bytecode)
│
▼
┌─────────────────────┐
│ Class Loader │ (Bootstrap → Platform → App)
└────────┬────────────┘
▼
┌─────────────────────┐
│ Runtime Data Areas │ Heap, Method Area / Metaspace,
│ │ Stacks (per-thread), PC Reg, Native Stack
└────────┬────────────┘
▼
┌─────────────────────┐
│ Execution Engine │ Interpreter ↔ C1 (client) ↔ C2 (server)
│ │ + Tiered Compilation + OSR
└─────────────────────┘
Tiered compilation (default since Java 8): hot methods are compiled by C1 (fast, lower-quality code) and after enough invocations re-compiled by C2 (slower, high-quality, profile-guided). OSR (on-stack replacement) lets a long-running interpreted loop be replaced by JITed code mid-flight.
// Hot loop — JIT will inline, unroll, and vectorize this.
long sum = 0;
for (int i = 0; i < 1_000_000_000; i++) sum += i;
To see what the JIT does:
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly Main # needs hsdis
java -XX:+PrintCompilation -XX:+PrintInlining Main
Class loaders
Three default loaders form a delegation chain:
- Bootstrap — loads
java.base(rt.jar in old days). - Platform — loads
java.*modules outsidejava.base. - App — loads your classpath.
Delegation rule: every loader asks its parent first. This is why you cannot shadow java.lang.String with your own.
Interview framing
“What happens when I call a Java method?”
The bytecode invokevirtual looks up the method via the receiver’s class vtable, the interpreter executes it, profile data accumulates, and after a threshold the JIT compiles a specialized version. Subsequent calls jump to native code.
2. Memory Regions
| Region | Per-thread? | Holds | GC? |
|---|---|---|---|
| Heap (Young + Old) | No | All Java objects | Yes |
| Metaspace | No | Class metadata, method bytecode | Yes (rare) |
| JVM Stack | Yes | Frames: locals, operand stack, return | No (LIFO) |
| PC Register | Yes | Current bytecode index | No |
| Native Stack | Yes | C stack for JNI / runtime | No |
| Code Cache | No | JIT-compiled native code | Evicted |
Heap structure under generational collectors (G1, Parallel, Serial):
Young Generation: Eden + Survivor 0 + Survivor 1
Old Generation: tenured objects
Allocation goes to Eden (bump-pointer in a thread-local allocation buffer — TLAB — so it’s lock-free). When Eden fills, a minor GC moves live objects to a Survivor space. Objects that survive enough minor GCs are promoted to Old.
// Each call allocates in Eden — extremely fast (just bump a pointer in TLAB).
List<Integer> tmp = new ArrayList<>();
Common JVM flags
-Xms2g -Xmx2g # initial / max heap
-XX:MetaspaceSize=256m
-XX:+UseG1GC # default since 9
-XX:MaxGCPauseMillis=200 # G1 pause target
-XX:+HeapDumpOnOutOfMemoryError
Interview framing
“Where does
new ArrayList<>()live?”
In Eden, on the heap. The reference variable lives in the current frame on the JVM stack. Allocation is bump-pointer in a TLAB; collection is generational copying.
3. Garbage Collectors
Java has many GCs. Know these four:
| GC | When | Pause | Trade-off |
|---|---|---|---|
| Serial | Tiny heaps / single-CPU | Stop-the-world | Simplest, smallest |
| Parallel (Throughput) | Batch jobs | STW, multi-thread | Max throughput, ignores pause |
| G1 (default) | General server | Soft target ms-scale | Balances throughput + pause |
| ZGC | Low-latency services | Sub-ms (since 21 generational) | Concurrent, region-based |
| Shenandoah | RH-flavored ZGC analog | Sub-ms | Concurrent, region-based |
G1 in 90 seconds
Heap is split into ~2000 equal-size regions of 1–32 MB. Regions are tagged Eden / Survivor / Old / Humongous. G1 maintains a remembered set per region tracking incoming references so it can collect a subset of regions (“the collection set”) without scanning the whole heap. Pauses are bounded by MaxGCPauseMillis; G1 picks regions to maximize freed space within the budget.
Young collection: evacuate Eden + Survivor → new Survivor / Old
Mixed collection: young + selected old regions
Concurrent mark: tracks live objects in Old without stopping the app
Full GC: fallback STW; means you've misconfigured
A Full GC in G1 is a sign of trouble — usually too small a heap, humongous-allocation churn, or metaspace pressure.
ZGC / Shenandoah
Concurrent, region-based, colored pointers (ZGC uses high bits of references as state). Pause times stay sub-ms regardless of heap size (TB-scale heaps tested). Trade-off: higher CPU and slightly lower throughput. Generational ZGC (Java 21) closes the throughput gap.
Tuning lever, not algorithm
In an interview: say which collector and why, not “I tuned the GC.” If you tuned, name the flag and the metric you watched.
4. Object Model — Headers, Autoboxing, Integer Cache
Every Java object has a header (12 or 16 bytes depending on compressed oops + alignment) before its fields. An int field costs 4 bytes; an Integer reference costs 4 bytes (compressed oops) + the boxed object’s overhead (≈16 bytes).
// Roughly:
// int[1_000_000] ≈ 4 MB
// Integer[1_000_000] ≈ 20 MB (4 MB array + ~16 MB of boxed Integers)
Autoboxing
Java silently converts int ↔ Integer. Each unbox can throw NullPointerException if the wrapper is null.
Integer x = null;
int y = x; // NPE — boxed → primitive deref
Integer cache
Integer.valueOf(i) caches -128..127 (and the cache upper bound is tunable via -XX:AutoBoxCacheMax). This produces the most-asked Java gotcha in history:
Integer a = 100, b = 100;
System.out.println(a == b); // true — cached, same object
Integer c = 200, d = 200;
System.out.println(c == d); // false — new objects, == compares references
System.out.println(c.equals(d)); // true
Always use .equals() for boxed numbers. == on object references checks identity.
Interview framing
“Why does
Integer == Integersometimes work and sometimes not?”
Integer.valueOf caches small values; large values create new objects; == is reference identity. Use .equals (or .intValue() ==).
5. Primitives vs Wrappers
| Primitive | Bits | Wrapper | Default |
|---|---|---|---|
boolean | 1 (impl-defined) | Boolean | false |
byte | 8 | Byte | 0 |
short | 16 | Short | 0 |
char | 16 | Character | '\u0000' |
int | 32 | Integer | 0 |
long | 64 | Long | 0L |
float | 32 | Float | 0.0f |
double | 64 | Double | 0.0d |
Generics cannot use primitives → List<int> is illegal. Use List<Integer> (slow, boxed) or specialized libs (IntStream, Eclipse Collections, fastutil) for hot paths.
// Hot loop on primitives — JIT loves this.
long sum = 0;
for (int i : intArray) sum += i;
// Same loop on Integer — boxing in/out, GC pressure.
long sum = 0;
for (Integer i : integerList) sum += i;
Project Valhalla (preview) introduces value classes that erase the wrapper overhead. Not yet shippable; mention only if the interviewer raises it.
Overflow
Integer arithmetic wraps silently:
int x = Integer.MAX_VALUE + 1; // -2147483648 — no exception
Math.addExact(Integer.MAX_VALUE, 1); // throws ArithmeticException
In interviews involving sums, products, or mid = (lo + hi) / 2, always consider overflow and prefer mid = lo + (hi - lo) / 2.
6. Collections Framework
| Interface | Implementations | Note |
|---|---|---|
List | ArrayList, LinkedList | Use ArrayList by default |
Set | HashSet, LinkedHashSet, TreeSet | LinkedHash preserves insertion order |
Map | HashMap, LinkedHashMap, TreeMap, ConcurrentHashMap | TreeMap is a red-black tree |
Queue / Deque | ArrayDeque, LinkedList, PriorityQueue | ArrayDeque > LinkedList for stacks/queues |
ArrayList
Backed by an Object[]. Growth is 1.5× ((oldCap >> 1) + oldCap). Append amortized O(1). add(0, x) is O(N).
ArrayList<Integer> l = new ArrayList<>(1_000_000); // pre-size to avoid resizes
HashMap
Open chaining: each bucket is a linked list. Treeification (Java 8+): when a bucket has ≥ 8 entries and table size ≥ 64, the bucket converts to a red-black tree; back to a list at ≤ 6 entries.
// Worst-case lookup pre-Java-8: O(N). Post-Java-8: O(log N).
HashMap<String, Integer> m = new HashMap<>();
Default load factor 0.75, default capacity 16. put triggers resize() (allocate new table, rehash all entries) when size > capacity * loadFactor.
Hash function mixes the user hashCode() with (h ^ (h >>> 16)) to defend against weak hashes.
// hashCode contract: equal objects → equal hashCodes.
// Bad: forgetting hashCode when overriding equals
@Override public boolean equals(Object o) { ... }
// must override hashCode too
@Override public int hashCode() { return Objects.hash(...); }
LinkedHashMap
HashMap + doubly-linked list across entries. Iteration order = insertion (or access, with accessOrder=true). The 5-line LRU cache:
class LRU<K, V> extends LinkedHashMap<K, V> {
private final int cap;
LRU(int cap) { super(cap, 0.75f, true); this.cap = cap; }
@Override protected boolean removeEldestEntry(Map.Entry<K, V> e) {
return size() > cap;
}
}
TreeMap
Red-black tree → all ops O(log N), supports firstKey, floorKey, ceilingKey, subMap — irreplaceable for ordered queries.
PriorityQueue
Binary min-heap on an array. add / poll O(log N), peek O(1). Iteration order is not sorted — only the head is.
ConcurrentHashMap (Java 8+)
Lock-free reads, fine-grained synchronization on writes (CAS + synchronized per bucket). Replaces Hashtable (deprecated for performance) and Collections.synchronizedMap (one big lock).
7. Concurrency — synchronized, ReentrantLock, Atomics, CAS
synchronized
Reentrant intrinsic lock. Implemented as an object header bit + bias / lightweight / heavyweight states (HotSpot-specific).
synchronized (lock) {
// critical section
}
public synchronized void f() { ... } // same as synchronized(this)
public static synchronized void g() {} // synchronized on the Class object
ReentrantLock
java.util.concurrent.locks.Lock. Explicit lock/unlock, supports tryLock, lockInterruptibly, fairness, multiple condition variables.
Lock lock = new ReentrantLock();
lock.lock();
try { /* CS */ } finally { lock.unlock(); }
Pick ReentrantLock when you need timeouts, fairness, or multiple Conditions. Otherwise, synchronized is shorter and the JIT optimizes it well.
Atomics
AtomicInteger, AtomicLong, AtomicReference use CAS (Compare-And-Swap) on hardware. Lock-free, lower overhead than locks for single-variable updates.
AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet(); // lock-free
counter.compareAndSet(0, 1); // CAS primitive
For high-contention counters, prefer LongAdder — it stripes the counter across cells to reduce CAS contention.
volatile
Marks a field for the JMM. Reads see the latest write from any thread. No atomicity — volatile int x; x++; is still a race.
volatile boolean shutdown = false; // OK as a flag
8. Java Memory Model — Happens-Before
The JMM defines when a write by one thread is visible to another. Without happens-before, the JIT and CPU may reorder, cache, or simply skip your reads.
Happens-before edges:
- Program order within a thread.
- Monitor lock release ↦ subsequent acquire of the same monitor.
volatilewrite ↦ subsequent volatile read of the same variable.- Thread.start() ↦ first action of the started thread.
- Thread’s last action ↦ Thread.join() return.
- Constructor’s
final-field write ↦ any reader of a properly published reference. - Transitivity: A→B and B→C ⇒ A→C.
// Classic publication bug — without `volatile`, another thread may see
// `instance != null` but read uninitialized fields.
class Singleton {
private static volatile Singleton instance;
public static Singleton get() {
Singleton s = instance;
if (s == null) {
synchronized (Singleton.class) {
s = instance;
if (s == null) instance = s = new Singleton();
}
}
return s;
}
}
Interview framing
“What does
volatilegive me?”
Visibility (no caching) and ordering (no reorder across the access). Not atomicity. Not mutual exclusion.
9. Executors and Thread Pools
new Thread(...) is a code smell — never spin OS threads ad-hoc.
ExecutorService pool = Executors.newFixedThreadPool(8);
Future<Integer> f = pool.submit(() -> compute());
Integer result = f.get();
pool.shutdown();
Built-in factories (and their traps)
| Factory | Backing queue | Trap |
|---|---|---|
newFixedThreadPool | unbounded LinkedBlockingQueue | Submitter overload → OOM |
newCachedThreadPool | SynchronousQueue | Unbounded thread count |
newSingleThreadExecutor | unbounded queue | Same OOM |
newScheduledThreadPool | DelayedWorkQueue | OK |
Production pattern: construct ThreadPoolExecutor directly with bounded queue + named thread factory + sensible rejection policy.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, 16, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
namedThreadFactory("worker"),
new ThreadPoolExecutor.CallerRunsPolicy());
ForkJoinPool
Work-stealing pool used by parallelStream and CompletableFuture defaults. Each worker has its own deque; idle workers steal from the back of others’ deques. Optimized for divide-and-conquer.
10. CompletableFuture
A composable async-result type. Replaces Future (which has only blocking get).
CompletableFuture<String> f =
CompletableFuture.supplyAsync(() -> fetch())
.thenApply(String::trim)
.thenCompose(s -> CompletableFuture.supplyAsync(() -> enrich(s)))
.exceptionally(ex -> "fallback");
Combinators
| Method | Purpose |
|---|---|
thenApply | map (sync transform) |
thenCompose | flatMap (chain another future) |
thenCombine | zip two futures |
allOf / anyOf | combine many |
exceptionally / handle | error handling |
orTimeout (Java 9+) | bound completion |
Default executor for the *Async variants is ForkJoinPool.commonPool(). Use a dedicated executor for IO-bound work — the common pool’s parallelism is cpus - 1 and you’ll starve compute.
11. Generics and Type Erasure
Generics are a compile-time feature. The runtime sees raw types: List<String> becomes List. Type checks insert checkcast instructions.
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
a.getClass() == b.getClass(); // true — both ArrayList
Consequences
- Cannot do
new T()(no class token) — passClass<T>orSupplier<T>. - Cannot do
new T[n]— use(T[]) Array.newInstance(cls, n). - Cannot overload by erased signature:
void f(List<String>)andvoid f(List<Integer>)collide. - Heap pollution: unchecked casts can hide type errors until use.
PECS
Producer Extends, Consumer Super.
void copy(List<? extends Number> src, List<? super Number> dst) { ... }
? extends T lets you read T’s. ? super T lets you write T’s. Memorize this; it’s asked.
12. String Pool, intern(), Encoding
String is immutable. The compiler interns string literals into a pool (used to live in PermGen, now in heap since Java 7).
String a = "hello";
String b = "hello";
a == b; // true — both reference the pooled string
String c = new String("hello");
a == c; // false
a == c.intern(); // true
Use String.intern() rarely — it’s a global side effect with non-trivial cost.
Compact Strings (Java 9+)
A String is a byte[] plus a coder byte: LATIN1 (1 byte/char) or UTF16 (2 bytes/char). A pure-ASCII string halves its memory vs Java 8.
Concatenation
a + b + c compiles, since Java 9, to an invokedynamic calling a small generated method via StringConcatFactory. Fast.
A for loop with s = s + c is O(N²) in elapsed time despite the optimization, because each iteration allocates a new String. Use StringBuilder:
StringBuilder sb = new StringBuilder(n);
for (char c : data) sb.append(c);
return sb.toString();
StringBuffer is StringBuilder + synchronization. You almost never want StringBuffer.
13. equals / hashCode Contract
1. Reflexive: x.equals(x) == true
2. Symmetric: x.equals(y) ⇔ y.equals(x)
3. Transitive: x.equals(y) && y.equals(z) ⇒ x.equals(z)
4. Consistent: repeated calls with no mutation return the same result
5. x.equals(null) == false
6. equals ⇒ hashCode equal (NOT the converse)
Break #6 and HashMap silently loses your entries.
record Point(int x, int y) {} // record auto-generates correct equals/hashCode
For non-record classes: Objects.equals and Objects.hash are your friends. IDE-generated implementations are fine; hand-rolled ones are usually wrong on edge cases (null fields, inheritance, NaN doubles).
Inheritance trap
Symmetric equals between a class and a subclass is essentially impossible without breaking Liskov. Mark the class final, or use composition + a getClass() check (not instanceof).
14. Exception Design
Three families:
- Checked (
Exceptionsubclasses, exceptRuntimeException) — must be declared / caught. - Unchecked (
RuntimeExceptionsubclasses) — programmer errors, callers may ignore. Error— JVM problems (OutOfMemoryError,StackOverflowError). Don’t catch.
Modern Java APIs lean unchecked because checked exceptions don’t compose with lambdas / streams.
list.stream().map(this::parse) // parse throws IOException → won't compile
Workarounds: wrap in RuntimeException, or use a checked-exception-friendly stream library.
try-with-resources
try (var in = Files.newInputStream(path);
var gz = new GZIPInputStream(in)) {
...
} // both closed in reverse order, even on exception
Resources must implement AutoCloseable. Suppressed exceptions (close throws after the body throws) are kept on the original via addSuppressed.
15. Streams
Lazy, pull-based pipelines.
int total = orders.stream()
.filter(o -> o.year() == 2025)
.mapToInt(Order::total)
.sum();
Lifecycle
- Source —
collection.stream(),Stream.of(...),Stream.generate(...),Files.lines(...). - Intermediate ops (lazy) —
filter,map,flatMap,sorted,distinct,limit,skip. - Terminal op (eager) —
forEach,collect,reduce,count,findFirst,toList()(Java 16+).
Pitfalls
- A stream is single-use. Re-collecting fails with
IllegalStateException. - No checked exceptions. Lambdas can’t throw them.
- Stateful intermediate ops (
sorted,distinct) buffer the whole stream. Don’t call them on infinite streams. parallel()usesForkJoinPool.commonPool— only worth it for CPU-heavy ops on large data with no shared state.
list.stream().parallel().mapToInt(...)... // measure first
16. Common Interview Gotchas
== vs .equals()
Always discussed alongside the Integer cache. Use .equals for objects, == only for primitives or true identity checks.
Integer overflow
Integer.MAX_VALUE + 1 // -2147483648
(long)Integer.MAX_VALUE + 1 // 2147483648 — promote first
Math.addExact(a, b) // throws on overflow
Floating-point equality
0.1 + 0.2 == 0.3 // false
Math.abs(a - b) < 1e-9 // OK
Double.compare(a, b) == 0 // handles NaN consistently
String.split regex
"a.b".split(".") returns [] because . is regex “any char.” Use "\\." or Pattern.quote(".").
Modifying a collection during iteration
Throws ConcurrentModificationException — even single-threaded. Use Iterator.remove() or removeIf.
Arrays.asList(int[])
Returns a List<int[]> of length 1. Use Arrays.stream(arr).boxed().toList() or IntStream.
Switch fall-through
Classic switch falls through. New switch (->) does not, and is exhaustive on sealed types and enums.
String s = switch (day) {
case MON, TUE -> "weekday";
case SAT, SUN -> "weekend";
default -> "?";
};
17. Records, Sealed Classes, Pattern Matching (Java 16–21)
Records
record Point(int x, int y) {
static Point origin() { return new Point(0, 0); }
}
Records are transparent immutable carriers: auto-generated constructor, accessors, equals/hashCode/toString. Implicitly final. Cannot extend, can implement interfaces.
Sealed classes
sealed interface Shape permits Circle, Rect {}
record Circle(double r) implements Shape {}
record Rect(double w, double h) implements Shape {}
Sealed types restrict the set of permitted subclasses. Combined with pattern matching, this gives exhaustive switching:
double area = switch (shape) {
case Circle c -> Math.PI * c.r() * c.r();
case Rect r -> r.w() * r.h();
}; // no default needed — compiler knows the universe
Pattern matching for instanceof
if (obj instanceof String s && s.length() > 3) {
use(s);
}
Modern Java is much terser than Java 8. If your interviewer is on JDK 21, leverage records + sealed + pattern switch — it shows fluency.
18. Project Loom — Virtual Threads (Java 21+)
A virtual thread is a Java-managed lightweight thread that runs on top of a small pool of OS carrier threads. Park-on-blocking-IO is implemented in the JDK.
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
exec.submit(() -> {
try (var sock = new Socket("h", 80)) { ... }
});
}
}
Use cases: thread-per-request servers without thread-pool sizing pain. Not faster for CPU-bound work — same number of CPUs. The win is scalability of blocking IO.
Sharp edges
- Pinning: a virtual thread inside
synchronizedcannot be unmounted from its carrier. UseReentrantLockif you’ll block while holding the lock. ThreadLocalstill works but with millions of virtual threads it’s expensive. PreferScopedValue(preview).- Native code (JNI) also pins the carrier.
Interview framing
“When would you use virtual threads instead of an executor?”
Massively concurrent IO — thousands+ of in-flight blocking calls — where you’d otherwise need async/reactive code. CPU-bound work still wants a fixed pool sized to cores.
19. Performance Hot Tips
- Pre-size collections (
new ArrayList<>(n),new HashMap<>(n*4/3+1)) to avoid resize churn. - Primitive arrays beat boxed lists by 4–10× for tight loops.
StringBuilderover+=in loops. (See §12.)- Reuse objects in hot paths if they’re large and immutable-ish; pool buffers (
ByteBuffer). - Avoid
Streamin micro-loops — the lambda allocations dominate. Streams shine on big pipelines, not 5-element ones. - Escape analysis lets HotSpot stack-allocate or scalar-replace short-lived objects. You don’t tune this; you write code that doesn’t escape (no leaking
this, no storing in fields). finaldoesn’t make code faster (the JIT proves it itself), but it documents intent and is required for some JMM guarantees.-XX:+UseLargePageson Linux for big heaps.- Profile with async-profiler (sampling, low-overhead) or JFR (built-in, low-overhead). Avoid
printf-debugging perf.
# Async profiler — wall-clock CPU profile.
asprof -d 30 -f flame.html <pid>
20. JMH — Java Microbenchmark Harness
You cannot benchmark Java with System.nanoTime() around a loop. The JIT will hoist invariants, dead-code-eliminate unused results, and warm up partway through your “measurement.”
JMH handles all of that:
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
public class HashBench {
int[] data = ...;
@Benchmark public int sumLoop() {
int s = 0;
for (int x : data) s += x;
return s;
}
}
Key ideas:
- Warmup iterations before measurement.
@Stateholds inputs (avoids constant-folding).- Return values prevent dead-code elimination (or use
Blackhole.consume). - Forks isolate JIT state across benchmarks.
When an interviewer asks “is X faster than Y?” the senior answer is: “I’d write a JMH benchmark — but my prediction is Y because [allocation / branch / cache reason].” Predict, then measure.
What To Memorize Cold
- JVM = bytecode interpreter + tiered C1/C2 JIT. G1 is default GC. Heap = young (Eden + S0 + S1) + old.
- Integer cache
-128..127. Always.equals()for boxed numbers. intoverflow wraps; useMath.*Exact, preferlo + (hi-lo)/2.HashMaptreeifies overflowing buckets to red-black trees → worst-case O(log N) since 8.volatile= visibility + ordering, not atomicity.- JMM happens-before: lock, volatile, start/join, final-after-publication.
- Generics erase. No
new T[], no overload by erased signature. Stringis immutable. Compact strings since 9. UseStringBuilderin loops.equalsandhashCodego together;recorddoes it for you.- Records are final immutable carriers. Sealed + pattern match = exhaustive switch.
- Virtual threads scale blocking IO; they don’t help CPU.
- JMH for benchmarks.
nanoTimelies.
If any of those is shaky, re-read the section, write the smallest program that demonstrates it, and watch it misbehave on purpose.