The Java Memory Model (JMM) Explained

KOTLIN › Concurrency

This is one of the more advanced topics here, so start with a simple puzzle. Two threads share a plain field, no lock, no volatile. Thread A writes it, thread B reads it. You'd expect B to see A's write immediately. It's not guaranteed, and that surprises people the first time they hit it.

Two everyday causes sit behind the surprise. A thread can work off its own cached copy of shared data, so a write can sit in a store buffer or a register and never make it to another thread's view. And the compiler and CPU are free to reorder instructions that don't change a single thread's own outcome, purely for speed, so two writes can even become visible to another thread in a different order than you made them.

The Java Memory Model is the rulebook that says exactly when this cached, reordered picture is reined in: when a write is guaranteed visible to another thread, and in what order. Everything you already associate with concurrency, volatile, synchronized, atomics, locks, exists to satisfy that rulebook. Once you see it that way, those tools stop being a pile of memorised facts and become obvious answers to one question: does this create the guarantee I need?

**Problem 1: visibility.** A worker loop checks a plain Boolean flag set by another thread, and sometimes never stops even though the flag was set seconds ago:

var stopped = false            // broken: no happens-before edge
fun run() { while (!stopped) work() }

The reading thread may be looping on a cached value, or the JIT may hoist the read out of the loop entirely, since nothing tells it the field can change from outside. Marking the field volatile fixes it: a volatile write is guaranteed to publish, and every later volatile read of that field is guaranteed to see it.

@Volatile var stopped = false

That one keyword is purely about visibility and ordering. It does not make a multi-step operation like an increment indivisible, which is the next trap to watch for.

Here's a classic senior-signal question. Two threads each run count++ a thousand times on a @Volatile int, and the final result lands under 2000. Why, if volatile already guarantees visibility?

Because count++ is not one step, it is three: read the current value, add one, write the new value back. Volatile makes each individual read and each individual write visible to other threads, but it says nothing about the three steps happening as one indivisible unit.

@Volatile var count = 0
fun bump() { count++ }   // still races

Two threads can both read the same value, both compute value plus one, and both write the same result back. One increment silently vanishes. Volatile fixed visibility perfectly; it never touched atomicity, because the two are separate guarantees. Knowing that distinction, out loud, is one of the most reliable senior signals in a concurrency interview.

The fix is to make the whole read-modify-write into one indivisible step, not to sprinkle more volatile on it.

AtomicInteger does this with a compare-and-swap loop: it reads the value, computes the new one, and only commits the write if nothing else changed the value in between, retrying if it did.

val count = AtomicInteger(0)
fun bump() = count.incrementAndGet()

A lock does the same job a different way: it forces every bump() call to run one at a time, so there is never an interleaving to race in the first place.

val lock = Any()
var count = 0
fun bump() = synchronized(lock) { count++ }

The same shape, guard the whole compound operation with a lock, or use an atomic type's compare-and-set, applies to any read-modify-write, not just a plain increment: updating a running maximum, appending under contention, anything where two threads touching the same value at once could lose one side's work. What doesn't work is reading the field twice, yielding the thread, or reaching for a different data structure. None of those touch the actual problem: two threads computing from the same stale value.

There's a third failure mode, and it's the one people forget exists. The compiler and CPU can reorder independent stores as long as it doesn't change a single thread's own behavior. That's invisible from inside one thread, but dangerous across threads: a writer can publish an object's reference before the constructor's own field writes have actually landed in memory.

// writer thread:
instance = Config()   // the reference store may be reordered
                       // BEFORE Config's own field writes complete

Another thread can then read a non-null instance whose fields are still at their zero or default values, a half-built object. This is genuinely different from the visibility problem: the reader isn't missing a write, the two writes just became visible in the wrong order relative to each other.

Three tools forbid this specific reorder, and they forbid it in different ways.

A volatile reference stops the compiler and CPU from moving the reference store ahead of any store that happened before it in program order, so a reader that sees the reference sees the finished object too.

@Volatile var instance: Config? = null // safe publish

synchronized brackets both construction and every later access inside the same monitor, so the happens-before edge between unlock and lock carries the finished object across.

Marking the fields themselves final, or val in Kotlin, sidesteps the problem completely: final fields get their own freeze guarantee at the end of construction, with no volatile or lock needed at all. Any one of the three closes the gap, you don't need all three at once.

All three problems collapse into one rule: happens-before. If action A happens-before action B, then A's writes are guaranteed visible to B, and A is ordered before B. That's the entire contract, nothing about which thread runs first, nothing about atomicity on its own.

Two edges you've already met, named properly: a volatile write happens-before every later volatile read of that same field. Unlocking a monitor happens-before the next lock of that same monitor.

The relation is transitive: if A happens-before B, and B happens-before C, then A happens-before C, so edges chain across multiple threads without you having to reason about each hop by hand.

Every synchronisation primitive in Java and Kotlin, volatile, synchronized, Mutex, atomics, is just a different mechanism for creating one of these edges. That's the whole subject, restated once you have the vocabulary for it.

Two details about these edges trip people up in interviews.

First: a volatile write does not just publish that one field. It publishes everything the writer thread did before the write, in program order. So if you write ordinary data and then flip a volatile flag, a reader that sees the flag true is guaranteed to see the data too, even though the data field itself was never marked volatile.

data = load()           // plain write
@Volatile ready = true  // publishes data too
// reader: if (ready) use(data) // sees the loaded data

Second: synchronized gives you more than mutual exclusion. Its happens-before edge, unlock then lock on the same monitor, publishes everything the previous holder wrote, any field anywhere, not only fields belonging to the locked object. That's why code that consistently locks on one monitor needs no volatile fields at all to stay correct.

synchronized(lock) { state = next }  // writer
synchronized(lock) { render(state) } // reader, sees it

Two more happens-before edges show up constantly in Android code: Thread.start() and Thread.join().

Everything a thread wrote before calling start() on a new thread is visible inside that new thread, no extra synchronisation needed. And everything the started thread wrote is visible to whoever successfully returns from join() on it.

var result: Report? = null
val t = thread { result = compute() }
t.join()
use(result!!)   // guaranteed visible, join() is the edge

Without the join(), there would be no happens-before edge between the write inside the thread and the read after it, result could still read as null, or as visible-but-stale. That's why a fire-and-forget thread with no join, and no equivalent wait, can leak stale reads. Running work on a background thread and safely reading its result back is exactly this pattern: start it, do other things if you like, then join before you touch what it produced.

One more edge deserves special mention, because it needs zero synchronisation to work: final fields, val in Kotlin.

If an object's reference doesn't escape its constructor, nobody gets a handle to this before construction finishes, the JMM guarantees every thread that later obtains the reference sees the final fields fully initialised. No lock, no volatile, nothing extra required.

class User(val id: String, val name: String)  // both val
// share freely across threads: safely constructed, no locks needed

This is exactly why immutable objects are inherently thread-safe: there's no write left to race against after construction, and construction itself gets a free safe-publication guarantee. It's a big part of why Kotlin nudges you toward val and data classes over mutable state.

Every tool you've met so far, volatile, synchronized, AtomicInteger, start and join, is really just a different way to generate a happens-before edge. Concurrent collections like ConcurrentHashMap are built the same way internally, using compare-and-swap and per-bin locking, they just have the edges baked into every operation for you instead of leaving you to add them.

A put() happens-before a later get() of the same key, guaranteed, with zero synchronisation of your own. That covers the individual operations. What it does not cover is a sequence of two calls you write yourself.

if (map[k] == null) map[k] = v      // still races: two separate ops
map.computeIfAbsent(k) { load(it) } // atomic: one op

Two threads can both check map[k] == null, both see nothing there, and both write, one overwriting the other's work. ConcurrentHashMap never promised that a check and an act, done as two separate calls, would be atomic together. For that you need its own compound operations, putIfAbsent, compute, merge, which run the whole check-and-update as a single atomic step internally.

Kotlin doesn't invent a new memory model, it just gives the same JMM primitives different names. @Volatile is the same annotation you've been using. AtomicInteger and its siblings work identically. kotlinx.coroutines.sync.Mutex is a suspending lock that creates the same unlock-then-lock edge synchronized does. And val, especially on a data class, is the same final-field safe-publication guarantee.

One built-in worth knowing by name: by lazy. Its default mode, LazyThreadSafetyMode.SYNCHRONIZED, runs the initialiser under a lock the first time any thread touches the property, and every other thread that arrives while that's in progress simply waits, then reads the cached result. You never get two threads racing to compute it, or a partially-initialised value leaking out.

val config by lazy { load() } // SYNCHRONIZED by default, safe under contention
val fast by lazy(LazyThreadSafetyMode.NONE) { load() } // NOT thread-safe

If you ever build that behaviour yourself, computing something once no matter how many threads ask for it at the same time, the shape is the same: check whether it's already computed, and if not, do the computation inside a lock so only one caller wins, while everyone else waits for that same lock and then reads the result.

One more thing worth knowing before this topic closes: coroutines don't replace the Java Memory Model, they run on top of it.

A coroutine launched on a dispatcher backed by more than one thread, Dispatchers.Default or Dispatchers.IO, is still subject to the exact same visibility and atomicity rules as a plain thread. A plain var shared between coroutines on such a dispatcher can lose updates exactly the way two raw threads did earlier in this lesson.

var count = 0
repeat(2) { scope.launch(Dispatchers.Default) {
  repeat(1000) { count++ } // lost updates, same race as before
} }

The fixes are the same in spirit too: confine the mutable state to a single thread or dispatcher, or protect it with an AtomicInteger or a Mutex. Suspension points do create happens-before edges of their own, which is exactly why state confined to one coroutine, or one dispatcher, stays safe without any of this machinery, there's simply never a second thread touching it at the same time.

Step back and every concurrency tool you'll ever be asked about is answering the same three questions: is a write visible, is an operation atomic, and can it be reordered. volatile answers visibility and ordering for one field. AtomicInteger and locks answer atomicity. synchronized, Mutex, start() and join() are all just different shaped happens-before edges. Final fields and immutable data get the guarantee for free. Concurrent collections and lazy have the same machinery built in, with the same limits, once you leave a single call, you're back to reasoning about edges yourself.

If an interviewer hands you a snippet of racy code, don't reach for a keyword, name the failure first: is data simply not arriving, is a compound operation splitting into steps another thread can interleave, or is a write landing in the wrong order relative to another write. Then name the edge that closes it, and say what that edge publishes. That sequence, problem, then edge, then what it publishes, is what separates someone who has memorised volatile from someone who actually understands why it works.

Back to The Java Memory Model (JMM)