4. Atomics, volatile & the Memory Model

Visibility vs. atomicity, volatile/@Volatile, CAS-based atomic types, and why Python and JavaScript need this machinery far less often than Java, Kotlin, and Go do.

This is the topic where 'the same bug looks completely different depending on the language' is most literal. Java and Kotlin share the JVM's formally-specified memory model and its volatile/Atomic* toolkit. Go has its own, separately-specified memory model with equivalent atomic types. Python's GIL accidentally makes a lot of individual bytecode operations atomic, which lulls people into skipping locks they still need for compound operations. JavaScript has no shared memory at all in the common case -- until you add a Worker and a SharedArrayBuffer, at which point it suddenly needs the most explicit, low-level atomics API of any of the five.

See roadmap: Memory Models & Atomics

Language Verdict: Pros, Cons & Recommendation

Go

4/5
  • Own clearly specified happens-before memory model (go.dev/ref/mem), same rigor as Java's
  • Typed atomic.* wrappers (1.19+) bundle visibility and atomicity together, removing an entire bug class volatile alone doesn't
  • No lightweight 'visibility only' primitive at all -- always pay for full atomic semantics even for a single flag
  • Pre-1.19 function-based atomic API (atomic.AddInt64(&x, 1)) was easy to misuse; older code still uses it

Java

5/5
  • Formally specified happens-before memory model (JLS §17.4) with clear, well-documented edges
  • Full atomic type family (AtomicInteger/Long/Boolean/Reference) built on CAS
  • volatile cleanly separates 'visibility only' from 'atomicity', giving a lighter-weight tool when that's all you need
  • The visibility-vs-atomicity distinction is a frequent, genuine source of subtle bugs (volatile counter++ trap)
  • Requires understanding a nontrivial formal model to reason correctly about edge cases

Kotlin

5/5
  • Inherits Java's exact memory model on the JVM -- no new model to learn
  • @Volatile and full Java atomic-type interop, plus Mutex as a composable coroutine-native alternative
  • Same volatile visibility-vs-atomicity trap as Java
  • Multiplatform targets (Native/JS) have different underlying models, an easy footnote to miss

Python

3/5
  • GIL gives simple flag reads/writes safety without any explicit primitive at all
  • asyncio's cooperative (non-preemptive) scheduling makes compound operations safe as long as no await splits them
  • No atomic-integer/CAS type in the standard library -- lock-free code isn't practical in pure Python
  • The GIL's partial safety net actively encourages the false belief that counter += 1 is safe

JavaScript

3/5
  • No memory-model complexity at all for the overwhelming common single-threaded case
  • Atomics gives a complete, Go/Java-comparable CAS-based toolkit for the SharedArrayBuffer case when you do need it
  • SharedArrayBuffer requires cross-origin-isolation deployment headers with no equivalent constraint elsewhere
  • Zero middle ground between 'no shared memory at all' and 'full Atomics API' -- no volatile-style lightweight option
Recommendation: Java/Kotlin and Go both give you a real, separately-documented memory model to reason from precisely -- lean on that rigor rather than intuition for any question involving unsynchronized access. Treat Python's GIL and JS's single-threadedness as removing most, not all, of this category of bug, and always ask explicitly whether a compound operation (not just a single read/write) is involved before declaring something 'safe without a lock'.

Concurrency Mechanics, Side by Side

The Language's Memory Model in One Paragraph

Must-know
// go.dev/ref/mem defines Go's own happens-before model, deliberately // similar in shape to Java's but expressed via channels/mutexes/atomics: var x int var ready bool // Goroutine A: x = 42; ready = true // Goroutine B: if ready { print(x) } // may print 0 without synchronization

The Go Memory Model is a separate specification from Java's but reaches nearly identical conclusions: a send on a channel happens-before the corresponding receive completes, a Mutex.Unlock happens-before the next Lock, and unsynchronized reads/writes across goroutines have no visibility guarantee at all. The 'race' in a data race is specifically this: two unsynchronized, concurrent accesses to the same memory where at least one is a write -- which is exactly what the -race detector (covered later in this manual) is built to catch.

volatile / @Volatile — Visibility Without Atomicity

Must-know

The single most-tested subtlety here: volatile (in any of these languages) fixes VISIBILITY, never ATOMICITY of compound operations like increment.

// Go has NO volatile keyword at all -- it does not exist in the language. // The equivalent guarantee comes from sync/atomic's typed wrappers, // which provide BOTH visibility and atomicity together: var shutdownRequested atomic.Bool shutdownRequested.Store(true) // Goroutine A for !shutdownRequested.Load() { doWork() } // Goroutine B, sees the write

This is a genuine, notable difference: Go deliberately has no bare 'visibility only' primitive like volatile. Its atomic.Bool/Int32/Int64/... types (sync/atomic, with a cleaner typed API since Go 1.19) always bundle visibility and atomicity together -- there is no lighter-weight 'just make this one flag visible' tool, you reach for atomic.Bool even for a case Java would solve with a plain volatile boolean.

Atomic Types for Compound Operations

Must-know
var counter atomic.Int64 // Go 1.19+ typed atomic wrapper counter.Add(1) counter.CompareAndSwap(5, 10) var head atomic.Pointer[Node] head.CompareAndSwap(oldHead, newHead)

sync/atomic's typed wrappers (atomic.Int64, atomic.Bool, atomic.Pointer[T] via generics since 1.19) replaced the older, error-prone function-based API (atomic.AddInt64(&counter, 1)) that required getting pointer arithmetic exactly right. The typed API is now the idiomatic default and maps closely onto Java's AtomicInteger/AtomicReference in both capability and intent.

Why Python Rarely Needs Explicit Atomics (and Where It Still Does)

Recommended

This deserves its own callout because it's a frequent source of overconfidence: the GIL makes many individual operations look thread-safe without actually making a program correct.

  • What the GIL genuinely gives you for free: individual bytecode instructions (a single LOAD_FAST, STORE_FAST, list append(), dict __setitem__, etc.) are effectively atomic, because no other thread can run Python bytecode while one thread executes an instruction. A simple flag read/write (ready = True) is safe without a lock or volatile-equivalent for this reason.
  • What it does NOT give you: any operation that compiles to more than one bytecode instruction. counter += 1, list.append(x); list.pop(0) as a pair, or any 'check then act' sequence (if key not in d: d[key] = []) can still be interrupted mid-sequence by the GIL switching threads (which happens on a timer, roughly every 5ms by default, or on any blocking call) — a real, reproducible race.
  • Where it changes with asyncio: within a single-threaded asyncio event loop, there is no thread-switching at all, only cooperative suspension at await points — so counter += 1 is safe as long as no await sits between the read and the write. The moment you await in the middle of a compound operation, another task can run and interleave, which is the asyncio equivalent of the JS 'await point' hazard covered elsewhere in this manual.
  • Where it changes with free-threaded Python (3.13+, PEP 703): the optional no-GIL build removes this safety net for individual bytecodes too, meaning code written assuming GIL-provided atomicity (a common, if technically-incorrect, pattern in the wild) can start racing on the free-threaded build in ways it never did before. This is genuinely useful context for a 'is Python moving away from the GIL' follow-up.

SharedArrayBuffer & Atomics — the One Place JS Has Real Shared Memory

Recommended

Every other topic in this manual treats JavaScript's lack of shared mutable memory as the reason most 'compare across languages' questions are moot for JS. SharedArrayBuffer is the deliberate exception, and it's worth understanding in isolation:

  • A SharedArrayBuffer is a fixed-length raw binary buffer that can be transferred to a Worker and then genuinely shares the same underlying memory with the main thread and other workers — not a copy, not a message, the same bytes.
  • You interact with it through a typed array view (Int32Array, Float64Array, etc.), and any access from multiple threads that isn't done through the Atomics object (Atomics.load, .store, .add, .compareExchange, .wait, .notify) is a data race with no ordering guarantee — structurally identical to an unsynchronized access in Go or a non-volatile field in Java.
  • Atomics.wait()/Atomics.notify() give JS its only genuinely blocking wait primitive of the five languages' 'condition variable' equivalents — but Atomics.wait can only be called from a worker thread, never the main thread (calling it there throws), since blocking the main thread would freeze the entire page/process.
  • Because of the Spectre/Meltdown-era side-channel risk of high-resolution shared memory timing, browsers require cross-origin isolation (Cross-Origin-Opener-Policy/Cross-Origin-Embedder-Policy headers) to enable SharedArrayBuffer at all — a deployment constraint with no equivalent in Java/Kotlin/Go/Python, where shared memory across threads is simply always available.

For interview purposes: knowing that this exists, and that it's the one place JS's memory model conversation resembles Java's/Go's, is usually sufficient depth — writing lock-free SharedArrayBuffer algorithms from scratch is a niche, systems-level skill rarely tested directly.

Further Reading