Memory Models and Visibility
When one thread writes x = 1 and then ready = true, you might expect another thread that sees ready == true to also see x == 1.
Learning Objectives
Explain what a memory model is, and how compiler and CPU reordering affect visibility.
Separate atomicity, visibility and ordering; state the data-race-free (DRF) guarantee; build happens-before graphs.
Publish objects safely, initialise them safely, and recognise double-checked locking pitfalls; publish immutable data.
Compare volatile across C/C++ and Java/C#, and summarise the Java, C/C++, .NET, Rust (Send/Sync) and JavaScript (SharedArrayBuffer/Atomics) models.
Distinguish hardware consistency from language guarantees and compiler barriers from fences; handle MMIO and ISR visibility; read litmus tests; reason about portability.
Plain-Language Explanation
When one thread writes x = 1 and then ready = true, you might expect another thread that sees ready == true to also see x == 1. Without synchronization, it may not. Compilers reorder and cache values in registers to make code fast; CPUs keep writes in private store buffers and execute out of order. Each core has its own view of memory for a short while. A memory model is the rulebook that says which views are allowed — and which synchronization operations force everyone to agree.
Precise Technical Explanation
What is a memory model?
A memory model specifies which values a read may return in a multithreaded program. Languages define one (C/C++11, Java JSR-133, .NET ECMA-335 plus the CLR's stronger implementation, Go, Rust ≈ C++20, JavaScript's for SharedArrayBuffer); hardware defines its own (x86-TSO, Arm, POWER, RISC-V RVWMO). The compiler maps language guarantees onto hardware.
Compilers may reorder, merge, hoist and eliminate memory accesses that are not observable *in a single thread*: keeping a variable in a register across a loop, moving a store after a later independent store, removing a "redundant" re-read. Without synchronization, other threads can observe these effects (the Java hang below).
CPUs execute out of order and buffer stores. On x86, a store followed by a load from a *different* address may appear reordered (store buffering); Arm and POWER also reorder store-store, load-load and load-store pairs. Fences and acquire/release instructions restore order where the program requires it.
Visibility of writes A write is *visible* to another thread once that thread's reads can return it. Without synchronization there is no deadline: a value may stay in a register forever (compiler) or become visible "eventually" (hardware). Synchronization creates the guarantee.
Atomicity versus visibility versus ordering Question it answers
Can anyone see this operation half-done?
atomic types, locks Will another thread see this write, and when?
synchronization (release/acquire, locks, volatile in Java/C#)
Will other threads see my writes in the order I made them?
memory orders, fences, locks An atomic relaxed counter is atomic but provides no ordering of other data; a Java volatile long is atomic, visible and ordered.
Data-race-free program guarantees The central theorem of modern memory models (DRF-SC): if a program has no data races (every conflicting access is ordered by happens-before or atomic seq_cst), then every execution behaves as if all operations were interleaved in one sequential order. Write race-free programs and you may reason with simple interleavings.
Nodes are memory operations; edges are program order within a thread plus synchronizes-with edges (unlock→lock, release→acquire, thread start/join, volatile write→read in Java, channel send→receive in Go). A read may only see writes that are not overwritten along happens-before paths — and any two conflicting accesses not connected by a path are a data race.
To give another thread a reference to an object *and* its fully constructed state, transfer it through a synchronization point: a lock, a release store/acquire load, a volatile/final field in Java, a thread-safe queue or channel, or static initialisation.
Java guarantees that final fields are visible to all threads once the constructor finishes (if this did not escape during construction), even under racy publication. C++ has no such rule — publication must be synchronised.
Double-checked locking pitfalls Fix: make instance a Java/C# volatile, a C++ std::atomic<Thing*> with acquire/release (or simply a function-local static), or use the language's lazy facility (Lazy<T>, std::call_once, holder idiom).
Immutable data publication Immutable objects need no synchronization *after* safe publication — and in Java, objects whose fields are all final are safe even under racy publication. Immutability is the cheapest concurrency tool.
Volatile semantics by language volatile means Thread synchronization?
every access performed as written (for MMIO, signal handlers, setjmp)
No — no atomicity, no inter-thread ordering; data races remain UB sequentially consistent, atomic (even long/double) field access
Yes (a volatile write synchronizes-with later reads) acquire on read, release on write (Volatile.Read/Write) Partly (no atomic RMW)
volatile is not a general-purpose replacement for a mutex or atomic operation in any language: even Java's volatile int x; x++ is still a racy read-modify-write.
volatile in C/C++ versus Java/C# The same keyword solves different problems: C/C++ volatile stops the compiler from optimising away device-register accesses; Java/C# volatile is a lightweight inter-thread visibility and ordering tool.
Java Memory Model overview JSR-133 (Java 5): happens-before from program order, monitor unlock→lock, volatile write→read, Thread.start, Thread.join, final field freeze. Racy programs still never see "out-of-thin-air" values. VarHandle (Java 9) exposes plain, opaque, acquire/release and volatile access modes.
C and C++ memory models overview C11/C++11: atomics with memory orders (relaxed, consume, acquire, release, acq_rel, seq_cst), fences, and data-race UB for everything else. memory_order_consume is effectively implemented as acquire by all compilers..NET memory model overview
ECMA-335 specifies a weak model; the actual CLR implementation is stronger (stores are not reordered with other stores on all supported platforms since .NET Core). Use Volatile, Interlocked, lock, and System.Threading primitives rather than relying on implementation details.
Rust Send and Sync overview Send: a value may be moved to another thread. Sync: &T may be shared between threads. The compiler checks them, so safe Rust cannot express a data race (Rc<T> is neither; Arc<Mutex<T>> is both). Atomics follow the C++20 model.
JavaScript's shared-memory/Atomics model overview Ordinary JavaScript objects are never shared between agents (workers exchange copies via messages). SharedArrayBuffer shares raw memory; Atomics.load/store/add/compareExchange/wait/notify are sequentially consistent; non-atomic accesses to shared buffers may race (well-defined but unpredictable values, no UB). Browsers require cross-origin isolation to enable SharedArrayBuffer.
Hardware consistency versus language guarantees Code that "works on x86" may rely on x86's strong ordering, not on the language. The compiler is also allowed to reorder unless the language forbids it — so even on x86 a missing volatile/atomic can break code, as the Java example shows.
Compiler barriers versus hardware fences A compiler barrier (asm volatile(""::: "memory"), std::atomic_signal_fence) stops compile-time reordering only. A hardware fence (mfence, dmb, fence; std::atomic_thread_fence) also orders the CPU's memory operations. Inter-thread synchronization needs the latter (via atomics or fences); a single core interacting with an interrupt handler may need only the former.
Memory-mapped I/O caveats Device registers need volatile (every access happens), correct memory types (device/uncached) and sometimes explicit barriers (__DSB(), wmb()) so writes reach the device in order. Atomics are generally not meant for MMIO.
Embedded/ISR visibility caveats Data shared between an interrupt handler and the main loop on a single core: mark it volatile (or use atomics) so the compiler re-reads it, make multi-word updates atomic by disabling interrupts briefly, and use barriers where the architecture requires them (e.g. after clearing an interrupt flag on Cortex-M before returning). Target documentation rules here.
Tiny programs that ask "can this outcome happen?" Classic names: SB (store buffering — both threads read 0 after writing), MP (message passing — reader sees flag but not data), LB (load buffering), IRIW. The SB test below *does* produce the "impossible" outcome on real x86 hardware.
Reasoning about correctness Make the program data-race-free, identify every synchronizes-with edge, and check that each consumer read is happens-after the producer write it needs. If you need weaker-than-seq_cst orders, prove each case or use a model checker.
Portability across architectures Test concurrent code on Arm as well as x86; use the language's atomics (never "it's an aligned int, so it's atomic"); avoid relying on implementation-specific strength (.NET, x86).
pseudocode — broken without proper atomics
if instance == null: # check without the lock
lock:
if instance == null:
instance = new Thing() # may be published before Thing is fully constructed
return instanceWhy It Matters in Real Systems
Memory-model bugs are the hardest concurrency bugs: they depend on compiler versions, optimization levels and CPU families, and often appear only after a port to Arm (phones, Apple silicon, Graviton servers).
Key Terms
Rules for which values reads may return Compiler or CPU changing the apparent order of memory operations
Whether/when a write can be observed by another thread Data-race-free programs behave sequentially consistently
Sharing a reference together with its initialised state Minimal program probing allowed outcomes Compiler barrier / fence Orders compiler / CPU memory operations
Total store order — x86's hardware model
Syntax and API Overview
This is a concurrency concept, not a language construct; the tools are atomics with memory orders (C/C++/Rust/Go), volatile/VarHandle/final (Java), Volatile/Interlocked (C#), and Atomics (JavaScript).
Complete Code Example
The store-buffering litmus test on real hardware (G++ 11.2, x86-64). Each iteration: thread 1 does x = 1; r1 = y, thread 2 does y = 1; r2 = x. Under sequential consistency at least one of r1, r2 must be 1. With relaxed atomics, x86's store buffers can make both read 0:
Output (one real run on the build machine):
A visibility bug caused by the *compiler* (the JIT), not the CPU — Temurin JDK 21. The worker loops on a plain boolean; after the loop is JIT-compiled, the read may be hoisted out of the loop and the update never seen. The program is given a 4-second deadline:
Safe publication with JavaScript SharedArrayBuffer and Atomics across real Node.js 24 worker threads:
#include <atomic>
#include <iostream>
#include <thread>
std::atomic<int> x, y, r1, r2;
std::atomic<int> start{0}, done{0};
template <std::memory_order Store, std::memory_order Load>
long run(int iterations) {
long both_zero = 0;
std::thread t1([&] {
for (int i = 1; i <= iterations; ++i) {
while (start.load(std::memory_order_acquire) < i) {} // wait for round i
x.store(1, Store);
r1.store(y.load(Load), std::memory_order_relaxed);
done.fetch_add(1, std::memory_order_acq_rel);
}
});
std::thread t2([&] {
for (int i = 1; i <= iterations; ++i) {
while (start.load(std::memory_order_acquire) < i) {}
y.store(1, Store);
r2.store(x.load(Load), std::memory_order_relaxed);
done.fetch_add(1, std::memory_order_acq_rel);
}
});
for (int i = 1; i <= iterations; ++i) {
x.store(0); y.store(0);
start.store(i, std::memory_order_release); // release both threads
while (done.load(std::memory_order_acquire) < 2 * i) {} // wait for both to finish
if (r1.load() == 0 && r2.load() == 0) ++both_zer
…relaxed : r1 == r2 == 0 in 117744 of 200000 runs
seq_cst : r1 == r2 == 0 in 0 of 200000 runspublic class Visibility {
static boolean stopPlain = false; // no synchronization
static volatile boolean stopVolatile = false;
public static void main(String[] args) throws Exception {
Thread v = new Thread(() -> { long n = 0; while (!stopVolatile) { n++; } System.out.println("volatile worker stopped after " + (n > 0 ? "many" : "0") + " iterations"); });
v.start();
Thread.sleep(300);
stopVolatile = true;
v.join();
Thread p = new Thread(() -> { long n = 0; while (!stopPlain) { n++; } System.out.println("plain worker stopped"); });
p.start();
Thread.sleep(300); // let the JIT compile the hot loop
stopPlain = true; // a write the worker may never observe
System.out.println("main set stopPlain = true and waits for the worker...");
p.join();
System.out.println("never reached if the read was hoisted");
}
}volatile worker stopped after many iterations
main set stopPlain = true and waits for the worker...
[no progress after 4 s — the process was killed]const { Worker, isMainThread, workerData } = require("node:worker_threads");
if (isMainThread) {
const shared = new SharedArrayBuffer(16);
const view = new Int32Array(shared); // [0] = ready flag, [1] = payload, [2] = counter
const workers = [0, 1, 2, 3].map(() => new Worker(__filename, { workerData: shared }));
let finished = 0;
workers.forEach((w) => w.on("exit", () => {
if (++finished === workers.length) {
console.log("4 workers x 10000 Atomics.add ->", Atomics.load(view, 2));
}
}));
view[1] = 42; // write the payload ...
Atomics.store(view, 0, 1); // ... then publish (Atomics are sequentially consistent)
Atomics.notify(view, 0); // wake workers blocked in Atomics.wait
} else {
const view = new Int32Array(workerData);
Atomics.wait(view, 0, 0); // sleep while ready == 0
if (Atomics.load(view, 1) !== 42) throw new Error("payload not visible");
for (let i = 0; i < 10000; i++) Atomics.add(view, 2, 1);
}4 workers x 10000 Atomics.add -> 40000Line-by-Line Explanation
Litmus test: a coordinator thread releases both threads each round via start and waits on done; the interesting part is the two lines per thread. With relaxed operations each store may sit in the core's store buffer while the following load reads the *other* variable from cache — so both loads return 0. With seq_cst, GCC emits xchg (a full barrier) for the stores, draining the buffer first, and the outcome disappears.
Java: the volatile loop always terminates; the plain loop may spin forever because the compiled code reads stopPlain once. If the run is killed at the deadline, that is the bug manifesting; on a different JVM, CPU or timing it might happen to exit — which is precisely why this is a bug.
JavaScript: workers block in Atomics.wait until the main thread's Atomics.store + Atomics.notify; the payload written before the store is guaranteed visible. Atomics.add makes the shared counter exact.
Output
Shown under each program; litmus counts and the Java result are from one real run.
Interleaving or State Trace
Store buffering on x86:
x = 1 → store buffer (not yet in cache) y = 1 → store buffer r1 = y reads 0 from cache r2 = x reads 0 from cache buffer drains: x = 1 buffer drains: y = 1
Result r1 = r2 = 0 — impossible under sequential consistency, allowed by x86-TSO and by C++ relaxed atomics.
Correctness and Safety Analysis
The relaxed litmus program is data-race-free (all shared accesses are atomic) but deliberately weakly ordered, so the language permits the outcome. The Java plain-boolean program has a data race on stopPlain; the JMM permits the worker never to see the write. The JavaScript program uses only sequentially consistent Atomics on shared memory.
Common Mistakes and Misconceptions
"x86 is strongly ordered, so I don't need atomics." The compiler still reorders, and SB happens even on x86.
"volatile makes it thread-safe" (C/C++).
"It's just a boolean flag" — it still needs volatile (Java/C#) or an atomic.
Double-checked locking with a plain field.
Publishing this from a constructor (a listener registration, a thread start) before construction finishes.
Failure Modes and Debugging Steps
Symptoms: loops that never see a flag, half-initialised objects, failures only on Arm or only in optimised builds. Debug with ThreadSanitizer (finds the data race), review every shared field for its synchronization, and test on weak-memory hardware or with model checkers (JCStress for Java, CDSChecker/GenMC for C/C++).
Performance and Scalability Implications
Stronger orders cost barriers (especially seq_cst stores on x86, most orders on Arm); unnecessary volatile in Java prevents some optimisations. The DRF approach — correct synchronization at a few well-defined points — is both safest and usually fastest.
Security Considerations
Memory-ordering bugs in reference counting and lock-free structures can lead to use-after-free. Speculative execution side channels (Spectre) are a different memory-system issue, not covered by language memory models.
Platform and Runtime Caveats
The SB result depends on the CPU and timing; the count varies per run and per machine; on some machines it may be rare.
Whether the Java loop hangs depends on the JIT (C2 hoists the field read in this pattern on HotSpot; an interpreter-only run would not).
SharedArrayBuffer in browsers requires Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers.
Real-World Applications
Lazy singletons, configuration hot reload (publish a new immutable config by atomic pointer swap), lock-free queues, flags for shutdown, interrupt/main-loop communication on MCUs.
Best Practices
Write data-race-free code; then reason with simple interleavings.
Publish through locks, release/acquire atomics, volatile/final (Java), queues, or immutable objects.
Use the language's lazy-initialisation facility instead of hand-written double-checked locking.
Never rely on x86 or .NET implementation strength.
Exercises
E1 · Beginner. Name three ways to safely publish an object to another thread in Java.
Answer: Store the reference in a volatile field, publish it under a lock both threads use, put it in a thread-safe collection/queue (or initialise it statically, or make all fields final).
E2 · Intermediate. Why does the seq_cst version of the litmus test never produce r1 = r2 = 0?
Answer: All four operations are in one total order consistent with program order. Whichever store comes first in that order is before the other thread's load, so that load reads 1.
E3 · Advanced challenge. Write a correct C++ double-checked lazy initialisation with std::atomic<T*>, and say why a function-local static is usually better.
Answer: T* p = inst.load(acquire); if (!p) { lock_guard g(m); p = inst.load(relaxed); if (!p) { p = new T; inst.store(p, release); } } return p; — the acquire load pairs with the release store. A function-local static (static T t; return t;) is initialised thread-safely by the compiler since C++11, with less code and no leak.
Quiz
? On x86, what does the store-buffering outcome r1 == r2 == 0 demonstrate?
+ Even a strongly ordered CPU lets a later load pass an earlier store to a different address. :: x86-TSO allows store-load reordering.
- x86 has a bug. :: The behavior is specified.
- Relaxed atomics are not atomic. :: They are atomic; they just do not order other operations.
- The compiler removed the stores. :: The stores happened; they were buffered.
? Which statement about C/C++ volatile is correct?
- It makes ++x atomic. :: It provides no atomicity.
- It synchronizes threads like Java volatile. :: C/C++ volatile has no inter-thread semantics.
+ It forces every access to happen as written, which is what device registers need. :: Its purpose is MMIO and signal handlers.
- It is equivalent to memory_order_seq_cst. :: It is not an atomic.
? Which create a happens-before edge between threads? (select all that apply)
+ A release store read by an acquire load :: Synchronizes-with.
+ Unlocking a mutex that another thread then locks :: Lock release/acquire.
+ Thread.join() returning :: Join synchronizes with thread completion.
- Two threads reading the same plain variable :: Reads do not synchronize.Summary
Memory models define which values reads may see. Compilers and CPUs reorder, so atomicity, visibility and ordering must be requested explicitly through synchronization. Data-race-free programs behave sequentially consistently. Publish safely, initialise once with library facilities, never confuse C/C++ volatile with Java/C# volatile, and validate weak-order code with litmus tests, model checkers and weak-memory hardware.
Further Reading
Jeremy Manson, William Pugh, Sarita Adve, "The Java Memory Model" (POPL 2005).
Hans Boehm & Sarita Adve, "Foundations of the C++ Concurrency Memory Model" (PLDI 2008).
Peter Sewell et al., "x86-TSO: A Rigorous and Usable Programmer's Model" (CACM 2010).
Russ Cox, "Memory Models" (blog series, 2021); ECMAScript specification, "Memory Model".