Skip to main content

Java Memory Model, Happens-Before & Race Conditions

To write correct, high-performance concurrent code in Java, engineers must look past surface-level myths ("threads cache variables in L1") and understand how the Java Memory Model (JMM, JLS §17.4) governs compiler optimizations (C2 JIT), CPU store buffers, instruction reordering, and Happens-Before edges.


1. The Java Memory Model (JMM) & The L1 Cache Myth

The JMM is a formal contract between the Java programmer, the JIT compiler (javac / C1 / C2), and the underlying CPU hardware (registers, Store Buffers, L1/L2/L3 caches, and RAM).

CPU Core 0L1 & L2 (Private)CPU Core 1L1 & L2 (Private)L3 Cache (Shared LLC)Main Memory (RAM)Stale read barrier (No volatile)

The CPU Cache Visibility Problem

Access Latency: N/A (Architecture Flaw)

Overview: Why Thread 1 changes are invisible to Thread 2 without synchronization.

  • Under-The-Hood Details:
    • Core 0 writes variable x = 42. The change is buffered in Core 0's write buffer and L1 cache.
    • Core 1 reads x from its own L1 cache which still contains the stale value (x = 0).
    • Fix: Declaring a variable volatile or using synchronized inserts a memory fence (barrier) that forces Core 0 to flush its store buffer and Core 1 to invalidate its local caches.

💡 Click on CPU Cores, L3 Shared cache, Main RAM, or the stale read line above to diagnose JMM Visibility Bugs.

Refuting the "Stale L1 Cache" Myth: C2 JIT Hoisting vs. MESI

Consider the classic infinite loop bug when stopping a worker thread without volatile:

public class StopFlagBug {
private static boolean stop = false; // Missing volatile!

public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
long i = 0;
while (!stop) {
i++;
}
System.out.println("Stopped at i = " + i);
});
worker.start();

Thread.sleep(1000);
stop = true;
System.out.println("Main set stop = true");
}
}

A common textbook misconception claims this program hangs because "Core 1 caches stop = false in its L1 cache and never syncs with Core 0." On modern x86-64 and ARMv8 CPUs, hardware cache coherence protocols (MESI / MOESI / MOESIF) automatically invalidate stale L1 cache lines across cores within nanoseconds.

Why does the program actually hang forever? C2 JIT Loop-Invariant Code Motion (Hoisting).

  1. During the first ~10,000 iterations, the worker thread runs in the interpreter (-Xint) or C1 compiler.
  2. Once the loop becomes hot, the C2 JIT compiler analyzes while (!stop) { i++; }. Because stop is not volatile and nothing inside the loop body modifies stop or establishes a synchronization edge, the JMM allows C2 to assume no other thread modifies stop.
  3. C2 hoists the read out of the loop into a CPU register:
    if (!register_stop) {
    while (true) { // Infinite loop in native assembly!
    i++;
    }
    }
  4. Empirical Proof:
    • Running with java -Xint StopFlagBug (disabling JIT) or java -XX:TieredStopAtLevel=1 StopFlagBug (C1 only, no C2 hoisting) causes the worker thread to terminate immediately after stop = true because MESI cache coherence propagates the cache line update!
    • Adding System.out.println(i) or Thread.sleep(0) inside the loop also makes the loop terminate—not because of a valid synchronization contract on stop, but because PrintStream.println acquires an internal lock / barriers and Thread.sleep crosses a native syscall boundary, preventing C2 from hoisting stop. Relying on side-effects inside a loop without a direct happens-before edge on the variable itself is a Heisenbug that can break across JDK upgrades.
JMM Under the Hood: C2 JIT Hoisting, Store Buffers (x86 vs ARM) & Piggybacking
Core 0: Main Threadstop = true (t = 1s)Flushes via MESI ProtocolL1/L2 Cache (MESI)stop == trueHardware Cache IS Coherent!Core 1: WorkerNEVER READS CACHE!Loops on RegisterHotSpot C2 JIT Compilation Transformation (After ~1-10ms Warmup):Source: while (!stop) { spins++; }Compiled: if (!stop) { while (true) { spins++; } } // Hoisted into register!
HANGS FOREVER (5/5 runs Worker stays alive after 3s)
Variant (a): Plain boolean stop = false (JDK 17 C2 Default)

With no method calls, locks, or volatile barriers inside the loop, HotSpot C2 proves stop is not modified locally and hoists the read outside the loop. Even though MESI cache coherence updates L1 cache in nanoseconds, the CPU never reads memory again!


2. Store Buffers, Hardware Reordering & x86 TSO vs. ARMv8

If MESI keeps L1 caches coherent, when does the CPU hardware itself cause visibility and ordering bugs? The answer lies in Store Buffers, Invalidate Queues, and Out-of-Order Execution.

The Dekker / Store Buffering Litmus Test (jcstress)

Consider two threads executing on two cores with initial state x = 0, y = 0 (non-volatile):

// Thread 1 (Core 0) // Thread 2 (Core 1)
x = 1; y = 1;
int r1 = y; int r2 = x;

Intuitively, if Thread 1 runs first (r1 = 0, r2 = 1), Thread 2 runs first (r1 = 1, r2 = 0), or they interleave (r1 = 1, r2 = 1), it seems impossible to get r1 = 0, r2 = 0. Yet running this test with OpenJDK jcstress (@JCStressTest) on an Intel/AMD x86-64 processor yields r1 = 0, r2 = 0 on ~6.29% of executions!

Physical Mechanism:

  1. Core 0 executes x = 1. Instead of stalling ~50–100 cycles waiting for Core 1 to acknowledge the MESI Read-For-Ownership (RFO) invalidation, Core 0 writes x = 1 into its private Store Buffer and immediately executes r1 = y (reading y = 0 from L1 cache).
  2. Simultaneously, Core 1 writes y = 1 into its private Store Buffer and executes r2 = x (reading x = 0 from L1 cache).
  3. Both Store Buffers subsequently drain to L1 cache and invalidate the peer core's cache line—too late! To the outside world, the Store-Load operations were reordered.

x86-64 (TSO) vs. ARMv8 / Apple Silicon / AWS Graviton (Weakly Ordered)

Not all CPU architectures allow the same hardware reorderings. This distinction explains why buggy concurrent Java code can run for years on Intel servers and crash within minutes after migrating to AWS Graviton (ARM64):

Reordering TypePatternx86-64 (Total Store Order)ARMv8 / AArch64 (Weak Memory Model)Compiler (C2 JIT)
Store-LoadWrite x=1 →\rightarrow Read yAllowed (via Store Buffer)AllowedAllowed
Load-LoadRead x →\rightarrow Read yForbidden in hardwareAllowedAllowed
Load-StoreRead x →\rightarrow Write y=1Forbidden in hardwareAllowedAllowed
Store-StoreWrite x=1 →\rightarrow Write y=1Forbidden in hardwareAllowedAllowed

[!WARNING] Why Unsafe Double-Checked Locking (DCL) Explodes on ARMv8: In instance = new Helper(), allocating memory, running the constructor (helper.field = 42), and assigning the reference instance are separate stores. On x86-64, hardware forbids Store-Store reordering, often masking a missing volatile unless JIT reorders it. On ARMv8 (AWS Graviton / Apple M-series), the CPU itself reorders the reference assignment before the constructor store completes (Store-Store + Load-Load), causing another thread to observe a non-null instance with uninitialized default fields (0 / null)!

JVM Memory Barriers (JMM Cookbook)

To enforce JMM guarantees across any CPU architecture, the JVM emits logical memory barriers (implemented as lock addl $0, 0(%rsp) or mfence on x86, and dmb ish / ldar / stlr on ARMv8):

  • LoadLoad: Ensures loads before the barrier complete before loads after the barrier.
  • StoreStore: Ensures stores before the barrier are visible before stores after the barrier (flushes Store Buffer before publishing reference).
  • LoadStore: Ensures loads before the barrier complete before stores after the barrier.
  • StoreLoad: The heaviest barrier! Drains the Store Buffer completely before any subsequent load executes (required after every volatile write followed by a volatile read).

3. The Happens-Before Relationship (All 8 JLS §17.4.5 Rules)

Instead of forcing developers to reason about x86 Store Buffers versus ARMv8 pipeline reorderings, the Java Language Specification defines the Happens-Before (hb\text{hb}) partial ordering.

The Fundamental Guarantee: If action AA happens-before action BB (A→hbBA \xrightarrow{hb} B), then every memory write performed by AA is guaranteed to be visible to BB, and neither the JIT compiler nor the CPU may reorder AA after BB. If two conflicting accesses (at least one is a write) have no happens-before edge between them, the program contains a Data Race.

The Complete List of Happens-Before Rules

  1. Program Order Rule: Within a single thread, each action happens-before every subsequent action in program order (A→poB  ⟹  A→hbBA \xrightarrow{po} B \implies A \xrightarrow{hb} B).
  2. Monitor Lock Rule: An unlock (synchronized exit or Lock.unlock()) on a monitor happens-before every subsequent lock acquisition on that same monitor instance.
  3. Volatile Variable Rule: A write to a volatile field happens-before every subsequent read of that same volatile field.
  4. Thread Start Rule: A call to Thread.start() happens-before any action inside the started thread.
  5. Thread Termination / Join Rule: Any action in a thread happens-before another thread returns from Thread.join(), Thread.isAlive() == false, or Future.get() on that thread.
  6. Thread Interruption Rule: A thread calling t.interrupt() happens-before the interrupted thread detects the interrupt (via InterruptedException, interrupted(), or isInterrupted()).
  7. Finalizer Rule: The end of an object's constructor happens-before the start of its finalize() method.
  8. Transitivity Rule: If A→hbBA \xrightarrow{hb} B and B→hbCB \xrightarrow{hb} C, then A→hbCA \xrightarrow{hb} C.

Transitive Piggybacking in Practice

Because of Program Order + Volatile/Monitor + Transitivity, you do not need to make every field volatile. If Thread 1 writes to plain fields and then writes to a single volatile variable, and Thread 2 reads that volatile variable and then reads the plain fields, the plain fields are guaranteed to be visible!

public class ConfigPublisher {
private int timeoutMs; // Plain field (NOT volatile)
private String endpoint; // Plain field (NOT volatile)
private volatile boolean ready; // Volatile publication gate

public void publish() {
this.timeoutMs = 5000; // (1) Plain write
this.endpoint = "https://api";// (2) Plain write
this.ready = true; // (3) Volatile write (StoreStore + StoreLoad barrier)
}

public void consume() {
if (this.ready) { // (4) Volatile read (LoadLoad + LoadStore barrier)
// By transitivity: (1) -> (2) -> (3) -> (4) -> (5)
// Guaranteed to see timeoutMs == 5000 and endpoint == "https://api"!
System.out.println(this.endpoint + " : " + this.timeoutMs); // (5)
}
}
}

JDK Internal Usage: AbstractQueuedSynchronizer (volatile int state) and LinkedBlockingQueue (AtomicInteger count) rely heavily on piggybacking so that reading/writing queue node pointers does not require volatile barriers on every node field.

Safe Publication & final Field Freeze Semantics (JLS §17.5)

Why are Java String and record classes thread-safe even when published across threads without volatile?

  • When an object has final fields, the JVM inserts a StoreStore freeze barrier at the end of the constructor before the object reference can be stored anywhere.
  • Crucial Rule: Any thread that sees a reference to that object (even via a non-volatile data race!) is guaranteed to see the initialized values of all its final fields (and any objects reachable through those final fields), provided the this reference did not escape during construction (e.g., registering this in a listener inside the constructor).

java.util.concurrent Memory Consistency Properties

Beyond core JLS rules, the java.util.concurrent package specification guarantees happens-before edges for higher-level utilities:

  • Concurrent Collections (BlockingQueue, ConcurrentHashMap): Placing an object into a concurrent collection happens-before another thread retrieves or removes that object from the collection.
  • ExecutorService & CompletableFuture: Submitting a Runnable/Callable happens-before its execution begins. Completing a CompletableFuture happens-before any dependent stage (thenApply, thenAccept) or blocking consumer (get(), join()) returns.
  • The LongAdder.sum() Exception (Gotcha!): Unlike AtomicLong.get(), LongAdder.sum() iterates over striped Cell[] counters sequentially without acquiring a global lock or establishing a cross-cell happens-before snapshot. Use LongAdder for high-throughput metrics/telemetry, never as a synchronization gate or sequence generator!

4. Race Conditions vs. Data Races & The 7 Production Patterns

Engineers frequently conflate Data Race and Race Condition. They are neither identical nor subsets of each other:

ConceptFormal DefinitionExample
Data RaceTwo threads access the same memory location concurrently, at least one access is a write, and there is no Happens-Before edge ordering them.Reading/writing a non-volatile stop flag across threads.
Race ConditionProgram correctness depends on the relative timing or interleaving of threads violating an application invariant.Checking if (stock > 0) then decrementing stock--, even if stock is a volatile variable or stored in a thread-safe map!

Can you have a Data Race WITHOUT a Race Condition? (Benign Data Race)

Yes—String.hashCode() in the JDK:

public int hashCode() {
int h = hash; // Plain int field (NOT volatile!) — intentional Data Race!
if (h == 0 && !hashIsZero) {
h = isLatin1() ? StringLatin1.hashCode(value) : StringUTF16.hashCode(value);
if (h == 0) {
hashIsZero = true;
} else {
hash = h; // Plain write
}
}
return h;
}

Why is this safe in java.lang.String? Because it satisfies all 4 Benign Data Race preconditions:

  1. The field is a 32-bit int (guaranteed atomic read/write by JLS §17.7, no word tearing).
  2. The computation is idempotent and deterministic (every thread computes the exact same hash from the immutable final byte[] value).
  3. The value transitions at most once from 0 to h.
  4. No other mutable state piggybacks on hash for visibility.

Can you have a Race Condition WITHOUT a Data Race?

Yes! Every access below is protected by synchronized (via Vector or ConcurrentHashMap.get/put), so there is zero Data Race, yet the compound action still suffers a fatal Race Condition:

// Zero Data Race (every get/put is volatile/synchronized), 100% Race Condition!
if (!concurrentMap.containsKey(key)) {
concurrentMap.put(key, computeExpensiveValue()); // Another thread can slip in between!
}
// Fix: Use atomic compound action concurrentMap.computeIfAbsent(key, k -> ...)

The 5 Classic Race Condition Archetypes in Production

1. Read-Modify-Write (count++ Bytecode Interleaving)​

Why does volatile int count; count++; lose updates when two threads increment it? Compiling and running javap -c reveals that count++ is four separate bytecode instructions:

0: getstatic #7 // Step 1: Read count from memory into operand stack (e.g., 41)
3: iconst_1 // Step 2: Push constant 1
4: iadd // Step 3: Add in ALU register (41 + 1 = 42)
5: putstatic #7 // Step 4: Write 42 back to memory

volatile only guarantees that Step 1 reads the latest value and Step 4 flushes immediately. If Thread A and Thread B both execute getstatic at count = 41, both compute 42 and write 42—one increment is permanently lost.

  • Fix: Use AtomicInteger.incrementAndGet() (hardware CAS loop LOCK CMPXCHG) or LongAdder (under high contention).

2. Check-Then-Act (Inventory Overselling)​

Checking a condition and acting on it in two separate steps allows another thread to invalidate the condition in the window between Check and Act:

// WRONG: Check and Act are separate atomic operations
if (stock.get() > 0) {
stock.decrementAndGet(); // Can drop to -1 if two threads pass the check when stock == 1!
}

// CORRECT: Lock-free CAS loop combining Check + Act atomically
public boolean tryReserveStock(AtomicInteger stock, int quantity) {
while (true) {
int current = stock.get();
if (current < quantity) {
return false; // Out of stock
}
if (stock.compareAndSet(current, current - quantity)) {
return true; // Atomically verified current hasn't changed and updated!
}
// CAS failed due to contention; loop retries with fresh value
}
}

3. Multi-Variable Invariant Trap (AtomicInteger ≠\neq Atomic Transaction!)​

Suppose a warehouse invariant requires available + reserved == 100. A developer uses two AtomicInteger fields:

private final AtomicInteger available = new AtomicInteger(100);
private final AtomicInteger reserved = new AtomicInteger(0);

public void reserveOne() {
available.decrementAndGet(); // Step A: available becomes 99
// <--- Window where an observer thread sees available(99) + reserved(0) == 99!
reserved.incrementAndGet(); // Step B: reserved becomes 1
}

Even though each field is atomic, atomicity does not compose across multiple independent variables. Furthermore, if you wrap reserveOne() in synchronized to protect writes, any reader thread checking available + reserved MUST also acquire that exact same lock—otherwise the reader will observe torn intermediate states (99 or 101) mid-mutation!

4. Monitor Target Pitfalls (synchronized on the Wrong Object)​

  • Instance vs. Static Lock Mismatch: Putting synchronized on an instance method locks this, while a static synchronized method locks MyClass.class. Two threads calling those two methods do not block each other at all.
  • Locking on Value-Based / Boxed Primitives (synchronized (count)): Because count++ autoboxes Integer.valueOf(count + 1) into a brand-new Integer instance on every increment, threads lock on different objects! Even worse, Integer.valueOf(-128..127) shares cached JVM singletons, risking deadlocks with unrelated libraries (flagged by JEP 390 warnings).

5. Hidden Stateful Utilities in static final Singletons​

Two standard JDK classes look like stateless utilities but hold mutable internal fields—making private static final declarations catastrophic in multi-threaded web servers:

  • SimpleDateFormat (calendar.setTime() corruption): Inside SimpleDateFormat.parse(), the parser mutates an internal CalendarBuilder and calls calendar.clear(). Under 8 concurrent threads running 80,000 parses, ~80% fail—either throwing NumberFormatException or silently returning corrupted dates (e.g., year 2260 instead of 2026)!
    • Fix: Replace with immutable java.time.format.DateTimeFormatter.
  • java.util.regex.Matcher (groups[], from, to corruption): Pattern is immutable and thread-safe (static final Pattern is great), but Matcher stores mutable cursor indices (first, last, groups[]). Sharing a static final Matcher across threads causes ~25% of regex matches to silently return wrong substrings or false negatives!
    • Fix: Keep static final Pattern, and instantiate pattern.matcher(input) per invocation.

6. Database Lost Update (When JVM Locks Aren't Enough)​

Synchronizing inside a Java pod (synchronized or ReentrantLock) only coordinates threads inside a single JVM heap. When 5 Kubernetes pods run SELECT stock FROM products WHERE id = 42 followed by UPDATE products SET stock = ?, JVM locks are useless.

  • Fix: Push the atomic Check-Then-Act into the database engine using a conditional atomic update:
    UPDATE products
    SET stock = stock - :qty
    WHERE id = :productId AND stock >= :qty;
    -- Check affected_rows == 1 in application code!

5. ThreadLocal Map Internals & Memory Leaks

ThreadLocal avoids synchronization by giving each thread its own isolated copy of a variable—useful for per-request context or legacy non-thread-safe objects when refactoring isn't possible.

Internal Architecture

  • Every java.lang.Thread instance owns a field ThreadLocal.ThreadLocalMap threadLocals.
  • Each Entry in ThreadLocalMap extends WeakReference<ThreadLocal<?>> (the key is weak), while the value (Object v) is held via a strong reference: Thread Ref⟶Thread⟶ThreadLocalMap⟶Entry{key (WeakRef)⇢ThreadLocalvalue (StrongRef)⟶HeavyObject\text{Thread Ref} \longrightarrow \text{Thread} \longrightarrow \text{ThreadLocalMap} \longrightarrow \text{Entry} \begin{cases} \text{key } (\text{WeakRef}) \dashrightarrow \text{ThreadLocal} \\ \text{value } (\text{StrongRef}) \longrightarrow \text{HeavyObject} \end{cases}

🧬ThreadLocal Lifecycle: Active

Active Thread(Pooled Tomcat)ThreadLocalMap.EntryKey: WeakRefValue: StrongThreadLocal instanceUserContext (Active)UserContext Object (In-Use)

Before GC: ThreadLocal Reference Active

WeakRef Key: Referenced (Active)

StrongRef Value: Reachable & Active

Leak Status: No Leak (Safe)

  • Memory Operations:
    • The thread holds a strong reference to the ThreadLocalMap.
    • The map entry holds a WeakReference to the ThreadLocal key (e.g., UserContext).
    • The value object (e.g., User entity) is strongly referenced and reachable.

💡 Switch between Before GC and After GC tabs to analyze how ThreadLocal reference leaks occur.

Why the Memory Leak Happens in Thread Pools

  1. In Tomcat or ThreadPoolExecutor, worker threads live for the entire lifetime of the JVM.
  2. If a ThreadLocal instance is garbage-collected (e.g., dynamic classloader reload or non-static ThreadLocal), the weak key becomes null.
  3. However, the strong reference chain Thread -> ThreadLocalMap -> Entry -> value remains unbroken until the thread either dies or calls remove() (or enough subsequent ThreadLocal operations trigger heuristic stale-entry expunging).
  4. Worse still: if the ThreadLocal is static final (the standard pattern), the key never becomes null! Failing to call remove() means the value not only leaks memory, it leaks tenant/security context into the next HTTP request handled by that pooled thread!
private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();

public void handleRequest(UserContext ctx) {
CONTEXT.set(ctx);
try {
processBusinessLogic();
} finally {
CONTEXT.remove(); // MANDATORY: prevents both memory leak and cross-request tenant bleed!
}
}

Senior/Staff Interview Playbook: How to Ace JMM, Race Conditions & Happens-Before Interviews

When an interviewer asks "Why does a thread loop forever without volatile?" or "How do you fix a race condition on a counter?", a mid-level candidate talks vaguely about "CPU cache flushing." A Principal Engineer grounds the answer in C2 JIT compiler optimizations, Store Buffers, JLS §17.4.5 Happens-Before edges, and multi-node failure boundaries.

1. The 60-Second Senior Framing Strategy

  1. Separate Compiler from Hardware: Explain immediately that JMM bugs arise at two distinct layers: JIT Compiler optimizations (C2 loop-invariant hoisting, dead-store elimination) and CPU Hardware memory models (Store Buffers causing Store-Load reordering on x86-64 TSO, plus Load-Load/Store-Store reorderings on ARMv8/Graviton).
  2. State the Invariant via Happens-Before: Frame visibility and ordering strictly through Happens-Before edges (A→hbBA \xrightarrow{hb} B) rather than "flushing RAM." Without a happens-before edge between conflicting accesses, the program has a Data Race.
  3. Distinguish Data Race vs. Race Condition: Point out that eliminating data races (e.g., making fields volatile or using ConcurrentHashMap) does not eliminate race conditions if compound actions (Read-Modify-Write, Check-Then-Act, or multi-variable invariants) are not executed atomically.
  4. Draw the Process Boundary: Always ask whether the invariant lives inside a single JVM (solved by CAS / locks) or spans multiple Kubernetes pods (requiring conditional SQL UPDATE ... WHERE, optimistic version columns, or distributed fencing tokens).

2. Classic Interviewer Traps & Principal Rebuttals

Trap 1: "When a thread loops forever on while (!stop), it's because the CPU core cached stop = false in its L1 cache, right?"​

  • Junior/Mid Answer: "Yes, each CPU core has its own L1 cache, and without volatile, the CPU never syncs L1 cache with main RAM."
  • Principal Rebuttal: "Actually, hardware L1 caches on x86 and ARM are kept coherent automatically within nanoseconds by the MESI cache coherence protocol. The infinite loop is caused by the C2 JIT compiler performing loop-invariant hoisting: seeing no synchronization edge on stop inside the loop, C2 hoists the read into a CPU register once before the loop (if (!reg) while(true)). You can prove this empirically by running with -Xint or -XX:TieredStopAtLevel=1—the loop terminates immediately without volatile because MESI propagates the cache line!"

Trap 2: "If I put System.out.println() or Thread.sleep(0) inside while (!stop), the loop stops! Doesn't that fix the bug without volatile?"​

  • Junior/Mid Answer: "Yes, println uses synchronized internally, which flushes all variables in memory."
  • Principal Rebuttal: "That is a dangerous Heisenbug. Under JLS §17.4.5, a lock acquisition inside println only establishes a happens-before edge with another thread that locks the exact same monitor. If the writer thread just sets stop = true without locking System.out, there is zero formal happens-before edge on stop. It only works by accident today because opaque method calls and native syscalls inhibit C2 from hoisting stop across the call boundary—an optimization behavior that can change with any JDK update or inlining heuristic."

Trap 3: "Why did our Double-Checked Locking (DCL) singleton work for 5 years on AWS EC2 Intel instances and immediately throw NullPointerException when we migrated to AWS Graviton (ARM64)?"​

  • Junior/Mid Answer: "ARM CPUs are faster so threads collide more often."
  • Principal Rebuttal: "x86-64 uses Total Store Order (TSO), which prohibits hardware Store-Store and Load-Load reordering—only allowing Store-Load reordering via the Store Buffer. In instance = new Service(), initializing the constructor fields and writing the instance pointer are two stores (Store-Store). On x86, unless C2 reordered them, hardware kept the constructor stores ordered before the reference publication. ARMv8 (Graviton) uses a weakly-ordered memory model that freely reorders Store-Store and Load-Load in hardware, allowing a reader core to see the non-null instance pointer before the constructor's field stores drain—unless instance is declared volatile (which emits stlr / ldar release-acquire barriers on ARM64) or initialized via final fields."

Trap 4: "To keep available + reserved == 100 thread-safe without locks, I changed both int fields to AtomicInteger. Is my class thread-safe now?"​

  • Junior/Mid Answer: "Yes, AtomicInteger uses CAS so neither variable can have lost updates."
  • Principal Rebuttal: "No—individual field atomicity does not compose into multi-variable transactional atomicity. Between available.decrementAndGet() and reserved.incrementAndGet(), there is an observable window where available + reserved == 99. Worse, even if you wrap the writer method in synchronized, any reader method that computes available.get() + reserved.get() without acquiring the exact same lock will still read torn intermediate states. To make a multi-variable invariant lock-free, you must pack both values into a single immutable value object (e.g., record StockState(int available, int reserved)) held inside a single AtomicReference<StockState> updated via CAS."

Trap 5: "Notice how ConcurrentHashMap is thread-safe. Why did my rate-limiter using map.compute(key, ...) freeze the entire application under load?"​

  • Junior/Mid Answer: "Because ConcurrentHashMap locks the whole map during compute()."
  • Principal Rebuttal: "In Java 8+, ConcurrentHashMap.compute() acquires a synchronized monitor lock on the head node of that hash bucket. While that is fine for a 10-nanosecond counter increment, if a developer puts a blocking I/O call (like a Redis check or HTTP call) inside the compute() lambda, the thread holds the bucket monitor across network I/O—blocking every other thread whose key hashes to that bucket and risking recursive lock deadlocks. Keep compute() / merge() strictly CPU-bound and side-effect-free."

3. Mid-Level vs. Principal Candidate Cheat Sheet

Concept / ScenarioMid-Level Candidate SaysPrincipal Candidate Demonstrates
Why while (!stop) hangs"The CPU caches stop in L1 cache and never flushes to RAM.""MESI keeps L1 coherent; C2 JIT loop-invariant hoisting lifts !stop out of the loop into a register (-Xint proves it)."
Why x=1; r1=y vs y=1; r2=x yields 0, 0"Threads interleaved in a weird order.""Even under sequential interleaving 0, 0 is impossible; it happens because writes sit in each core's Store Buffer (Store-Load reordering), requiring a StoreLoad fence."
volatile Overhead & Guarantee"volatile makes a variable thread-safe and skips CPU cache.""volatile disables JIT hoisting, emits StoreStore/StoreLoad (write) and LoadLoad/LoadStore (read) barriers, and enables transitive piggybacking, but does not make count++ atomic."
Safe Publication of Immutable Objects"Immutable objects never need synchronization.""JLS §17.5 guarantees visibility of final fields via a constructor freeze barrier, only if this does not escape during construction."
Stateful Static Singletons"SimpleDateFormat throws exceptions in threads.""Sharing static final SimpleDateFormat or Matcher causes silent data corruption (~80% wrong dates / ~25% missed regex matches); use DateTimeFormatter and per-call pattern.matcher()."
Fixing Multi-Field Race Conditions"Make both fields AtomicInteger.""Combine coupled fields into an immutable record inside AtomicReference<State> with CAS, or guard both reads and writes with the same lock."
📖
Track Page Progress0 / 635 Read
Knowledge Base Completion0%