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:

  1. Bootstrap — loads java.base (rt.jar in old days).
  2. Platform — loads java.* modules outside java.base.
  3. 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

RegionPer-thread?HoldsGC?
Heap (Young + Old)NoAll Java objectsYes
MetaspaceNoClass metadata, method bytecodeYes (rare)
JVM StackYesFrames: locals, operand stack, returnNo (LIFO)
PC RegisterYesCurrent bytecode indexNo
Native StackYesC stack for JNI / runtimeNo
Code CacheNoJIT-compiled native codeEvicted

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:

GCWhenPauseTrade-off
SerialTiny heaps / single-CPUStop-the-worldSimplest, smallest
Parallel (Throughput)Batch jobsSTW, multi-threadMax throughput, ignores pause
G1 (default)General serverSoft target ms-scaleBalances throughput + pause
ZGCLow-latency servicesSub-ms (since 21 generational)Concurrent, region-based
ShenandoahRH-flavored ZGC analogSub-msConcurrent, 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 intInteger. 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 == Integer sometimes 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

PrimitiveBitsWrapperDefault
boolean1 (impl-defined)Booleanfalse
byte8Byte0
short16Short0
char16Character'\u0000'
int32Integer0
long64Long0L
float32Float0.0f
double64Double0.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

InterfaceImplementationsNote
ListArrayList, LinkedListUse ArrayList by default
SetHashSet, LinkedHashSet, TreeSetLinkedHash preserves insertion order
MapHashMap, LinkedHashMap, TreeMap, ConcurrentHashMapTreeMap is a red-black tree
Queue / DequeArrayDeque, LinkedList, PriorityQueueArrayDeque > 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 atomicityvolatile 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:

  1. Program order within a thread.
  2. Monitor lock release ↦ subsequent acquire of the same monitor.
  3. volatile write ↦ subsequent volatile read of the same variable.
  4. Thread.start() ↦ first action of the started thread.
  5. Thread’s last action ↦ Thread.join() return.
  6. Constructor’s final-field write ↦ any reader of a properly published reference.
  7. 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 volatile give 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)

FactoryBacking queueTrap
newFixedThreadPoolunbounded LinkedBlockingQueueSubmitter overload → OOM
newCachedThreadPoolSynchronousQueueUnbounded thread count
newSingleThreadExecutorunbounded queueSame OOM
newScheduledThreadPoolDelayedWorkQueueOK

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

MethodPurpose
thenApplymap (sync transform)
thenComposeflatMap (chain another future)
thenCombinezip two futures
allOf / anyOfcombine many
exceptionally / handleerror 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) — pass Class<T> or Supplier<T>.
  • Cannot do new T[n] — use (T[]) Array.newInstance(cls, n).
  • Cannot overload by erased signature: void f(List<String>) and void 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 (Exception subclasses, except RuntimeException) — must be declared / caught.
  • Unchecked (RuntimeException subclasses) — 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

  1. Sourcecollection.stream(), Stream.of(...), Stream.generate(...), Files.lines(...).
  2. Intermediate ops (lazy) — filter, map, flatMap, sorted, distinct, limit, skip.
  3. 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() uses ForkJoinPool.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 synchronized cannot be unmounted from its carrier. Use ReentrantLock if you’ll block while holding the lock.
  • ThreadLocal still works but with millions of virtual threads it’s expensive. Prefer ScopedValue (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.
  • StringBuilder over += in loops. (See §12.)
  • Reuse objects in hot paths if they’re large and immutable-ish; pool buffers (ByteBuffer).
  • Avoid Stream in 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).
  • final doesn’t make code faster (the JIT proves it itself), but it documents intent and is required for some JMM guarantees.
  • -XX:+UseLargePages on 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.
  • @State holds 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.
  • int overflow wraps; use Math.*Exact, prefer lo + (hi-lo)/2.
  • HashMap treeifies 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.
  • String is immutable. Compact strings since 9. Use StringBuilder in loops.
  • equals and hashCode go together; record does 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. nanoTime lies.

If any of those is shaky, re-read the section, write the smallest program that demonstrates it, and watch it misbehave on purpose.