Why architecture matters here
Memory-model bugs are the worst class of defect in production software because they are heisenbugs by construction. A racy read does not fail deterministically; it fails when a JIT recompilation changes register allocation, when a new CPU generation buffers stores differently, when load shifts thread timing by microseconds. The canonical horror story is a config object published through a plain field: it works in every test, works in production for months, then a JIT decision makes one reader observe the object reference before its fields — and a NullPointerException fires in code that “cannot” be null. No stack trace explains it, no local reproduction exists, and git blame points at code that is three years old.
Architecture matters because correctness under the JMM cannot be tested into a program — the model permits executions your test hardware may never exhibit. An x86 machine has a strong memory model (stores are not reordered with other stores), so code missing a required barrier can pass every stress test and then corrupt state on ARM servers, where the hardware reorders aggressively. The only reliable approach is structural: reason in happens-before edges, not in imagined instruction timelines, and make every piece of cross-thread shared state either immutable, confined, or guarded by an explicit synchronization primitive whose edge you can name.
There is also a performance dimension. Every edge costs something — a volatile write drains the store buffer, a contended lock parks threads — and over-synchronizing serializes the very parallelism the threads exist for. The JMM gives you a spectrum from plain access (free, no guarantees) through opaque, acquire/release, and volatile (sequentially consistent), and knowing precisely which strength each shared variable needs is how high-performance concurrent code stays both fast and correct.
The architecture: every piece explained
The reordering machine. Three layers rewrite your program. The JIT applies classic optimizations as if the code were single-threaded: reordering independent statements, hoisting loop-invariant reads (which turns while (!stop) {} into an infinite loop if stop is plain), and eliminating “redundant” loads. The core executes micro-ops out of order. And the store buffer — a per-core queue of retired writes awaiting cache ownership — means even x86 lets a load pass an older store to a different address. MESI cache coherence guarantees a single order of writes per cache line once they reach the cache, but it does not order writes to different lines and does not flush store buffers; coherence is not consistency.
Happens-before edges. Program order gives edges within one thread. Across threads: an unlock of monitor M happens-before every subsequent lock of M; a volatile write happens-before every subsequent read of that variable; Thread.start() happens-before the first action of the started thread; the last action of a thread happens-before join() returning. Crucially, edges compose transitively and carry everything: a volatile write publishes not just the volatile itself but all writes program-ordered before it. This “piggybacking” is the whole design pattern of safe publication — build the object with plain writes, publish the reference with one releasing write.
The primitive lineup. synchronized/ReentrantLock: mutual exclusion plus edges; the only choice when a compound read-modify-write must be atomic. volatile: visibility and ordering without exclusion; reads are cheap (a compiler constraint on most hardware), writes drain the store buffer. VarHandle modes: opaque (coherent, no ordering), acquire/release (one-way fences, the sweet spot for publish-subscribe fields), volatile (full sequential consistency). final fields: a freeze at constructor exit guarantees any thread that sees the object reference sees the final fields initialized — provided this does not escape construction. Atomics (AtomicLong, LongAdder) layer CAS loops on volatile semantics for lock-free counters and state machines.
End-to-end flow
Walk the standard pattern — one thread builds a configuration object and publishes it; worker threads consume it — through the machine. (1) The publisher runs cfg = new Config(...): plain writes fill the object’s fields in some order the JIT chose, sitting in core 0’s store buffer. (2) It then executes CONFIG.setRelease(this, cfg) (or a volatile store). The release semantics forbid the compiler from sinking any earlier write below it and emit the hardware barrier that ensures all buffered field writes drain to cache before the reference write becomes visible. The reference now sits in coherent cache, fields already there.
(3) A worker on core 1 executes Config c = CONFIG.getAcquire(this). The acquire semantics forbid hoisting later reads above it. Either it observes the old reference — fine, it uses the old config — or it observes the new one, and the happens-before edge (release write → acquire read of the same variable) guarantees every field write program-ordered before the publish is visible. There is no third state; the half-built object is unrepresentable because of the edge, not because of luck. (4) Contrast the racy version: with a plain field, the compiler may keep the old reference cached in a register forever (the worker never sees the update), or the hardware may make the reference visible before the fields (the worker sees nulls). Both are legal executions of the racy program.
(5) Now a shutdown: main sets a volatile running = false; each worker’s loop reads it (volatile read — cannot be hoisted), finishes its batch with plain operations, and exits; main calls join() on each worker. The join edge means main now reliably sees every write the workers made — their final counters, their flushed buffers — with no further synchronization. (6) Aggregating those counters needs no locks at all: the join already ordered everything. Recognizing which edges you already have, and not paying for redundant ones, is the difference between a clean concurrent design and a lock-encrusted one.