Concurrency Roadmap/Memory Models & Atomics

volatile / Atomic Variables

volatile buys you visibility and ordering via happens-before, but not atomicity of compound operations like increment — that gap is exactly what dedicated atomic types (built on compare-and-swap) exist to close.

!3/5Theory: 40m
Language-specific mechanics: Concurrency Language Manual — Atomics, volatile & the Memory Model

What volatile actually promises

volatile (and its equivalents — C++11's std::atomic used with acquire/release ordering, or a plain field accessed only through memory-order-aware primitives) is the lightest-weight tool the happens-before relation gives you, and it's worth being precise about exactly what it buys, because the gap between what people assume it does and what it actually does is where a huge fraction of production concurrency bugs live.

A volatile variable guarantees two things, and only two things:

  1. Visibility. A write to a volatile variable happens-before every subsequent read of that same variable that observes the write (the volatile rule from the previous subtopic). Practically: the write can't be cached indefinitely in a register or a per-core cache line and hidden from other threads — it's flushed out, and reads pull a fresh value rather than a stale cached one.
  2. Ordering, via transitivity. Because of the happens-before relation, everything a thread wrote before a volatile write becomes visible to any thread that reads that volatile write afterward — the volatile variable acts as a one-way publication gate. This is also why the compiler and CPU are forbidden from reordering ordinary accesses across a volatile access in ways that would violate that guarantee.

What it does not give you is atomicity of anything beyond a single read or a single write of that one variable. This is the single most common volatile misunderstanding, and it's worth internalizing as a concrete failure case rather than an abstract warning:

volatile counter = 0 // Two threads both run this: counter = counter + 1 // "counter++"

counter++ is not one operation — it's three: read counter, compute counter + 1, write the result back. volatile guarantees each of those individual reads and writes is visible promptly. It guarantees nothing about the three-step sequence as a whole being atomic. If two threads interleave their read-compute-write sequences, both can read the same starting value, both compute the same incremented value, and one increment is silently lost — a classic lost update, indistinguishable at the source level from correct code, and (per the previous subtopic) not reliably reproducible under light testing. volatile makes the symptom less visible-seeming (you'd swear the variable is "thread-safe" because every individual access looks synchronized) while doing nothing to fix the actual race.

Atomic variable classes: making the compound operation itself atomic

This is the gap that dedicated atomic types exist to close. An atomic integer/reference/boolean type (the AtomicInteger-style classes are the canonical example, but the concept is the same everywhere: C++'s std::atomic<int>, Rust's AtomicUsize, and so on) provides operations like "increment and return the new value" or "compare and swap" as a single indivisible hardware-backed step, not as separate read/compute/write operations a scheduler can interleave arbitrarily.

Under the hood, most atomic read-modify-write operations are implemented using the compare-and-swap (CAS) hardware primitive (covered in depth in the next subtopic): the atomic increment isn't magic, it's a tight retry loop that keeps attempting "if the value is still what I last read, swap in value+1; otherwise, re-read and try again." This is why atomic types are typically described as lock-free: no thread ever blocks waiting for another to release something, but under high contention a thread's CAS attempt can fail and retry repeatedly, which is a real (if usually small) performance cost worth knowing about rather than assuming atomics are "free."

The practical decision rule senior engineers are expected to articulate cleanly:

SituationRight tool
One thread writes, others only read (a flag, a "shutdown requested" signal, a completed reference being published)volatile / plain atomic load-store is enough
Multiple threads write, and the new value only depends on a constant or an external input (a straightforward "set to this fixed value")Still fine with volatile in many cases, but verify no compound read-modify-write is hiding in the update
Multiple threads write, and the new value depends on the previous value (increment, append, "add unless over some cap")Needs an atomic RMW operation or a full lock — volatile alone is not sufficient, no matter how it looks in testing
Multiple related fields must be updated together and observed consistently as a groupNeither volatile nor a single atomic variable is enough — you need a lock, an atomic reference to an immutable composite object, or an explicit protocol

Why the JVM (and C++11) needed to formalize this precisely

This isn't a minor implementation detail — getting volatile's semantics exactly right was a large part of what JSR-133 rewrote in the Java Memory Model, because the pre-Java-5 specification was genuinely too weak to make double-checked locking or safe publication reliable. The C++11 memory model went further and exposed the spectrum of ordering strengths explicitly through std::memory_order (relaxed, acquire/release, sequentially consistent) rather than bundling "atomic" and "sequentially consistent" together the way Java's volatile effectively does — a good example of the same underlying happens-before idea being formalized with different amounts of programmer-facing knobs in different languages. Knowing that both exist, and roughly how they differ in granularity of control, is the kind of cross-language fluency senior interviews probe for even when the interview itself is in one specific language.

Pitfalls and interview framing

  • "I made it volatile, so it's thread-safe now." True only if every operation on the variable is a simple, independent read or a simple, independent write. The moment a new value is computed from an old one, volatile alone is insufficient — reach for an atomic RMW operation or a lock.
  • Believing atomics are lock-free "for free." They avoid blocking, but under real contention a CAS-retry loop still burns CPU cycles on failed attempts; profiling under realistic contention matters more than the word "atomic" in the type name.
  • Forgetting atomics only cover one variable. AtomicInteger a and AtomicInteger b being individually atomic says nothing about a and b being updated or observed consistently together — that's a multi-variable invariant problem no single atomic type solves.
  • Overusing volatile as a substitute for understanding the actual invariant. If you find yourself sprinkling volatile on every shared field hoping it "helps," that's a sign to step back and identify what actual happens-before edges the code needs, rather than treating the keyword as a charm.
  • Ignoring platform reality. In a pure-Python program running on CPython, the GIL serializes bytecode execution, which sidesteps most of the classic volatile-style visibility bugs for plain Python objects — but this is not a substitute for understanding the model: multiprocessing, C-extension code that releases the GIL, and other runtimes (no-GIL builds, Jython) reintroduce exactly these issues, so "Python doesn't need this" is a dangerously overbroad takeaway.
Reference implementations in:

Fixing the Flag: volatile / Atomic Visibility

The same start/stop flag from the happens-before subtopic, now declared with an explicit visibility guarantee instead of a plain field.

type Worker struct { running atomic.Bool } func (w *Worker) stop() { w.running.Store(false) } func (w *Worker) run() { for w.running.Load() { // do work } }

Go has no volatile. atomic.Bool (typed wrappers since 1.19) is the flag tool: it bundles visibility and atomicity together, which is more than Java's volatile gives you and exactly enough here because every access is a simple load or store. A mutex around a plain bool also works, but atomics are the lighter, idiomatic choice for a stop flag.

The Compound-Operation Trap: volatile counter++ vs. a Real Atomic Increment

volatile guarantees each read and each write is visible — it says nothing about a read-modify-write sequence like an increment being a single step. This is the single most common volatile misunderstanding.

// BUG: a plain int64 increment is a data race — Go has no volatile // that could even *pretend* this is safe. var broken int64 func brokenInc() { broken++ } // read, add, write — three racy steps // FIX: sync/atomic. Add is a single hardware-backed RMW. var correct atomic.Int64 func correctInc() { correct.Add(1) }

You can't make Java's volatile count++ mistake in Go because there is no volatile — the race detector just flags the plain increment as a data race. atomic.Int64.Add (or the older atomic.AddInt64(&x, 1)) is the real atomic increment; under contention it is a CAS retry loop, not a lost update.

Further Resources (Optional)