The Java Memory Model

When one thread writes a value and another reads it, when is the reader guaranteed to see it? The answer is the Java Memory Model (JMM). It is the foundation under volatile, synchronized, atomics and the Disruptor, and it's a favourite topic in low-latency Java interviews.

1. The problem

You'd expect a write by one thread to be visible to every other thread immediately, in the order it was written. On real hardware it isn't:

All three are great for single-threaded speed and invisible to a single thread. They only show up when threads share data. The JMM says exactly which reorderings are allowed and how to forbid them.

2. A business example: the office with private notepads

Workers sit at desks with their own notepads (CPU caches). There's one shared filing cabinet (main memory). Workers copy what they need onto their notepad and update the cabinet when convenient, not necessarily in the order they made notes.

Alice writes the report, then puts a "report ready" sticky note in the cabinet. Bob sees the sticky note and goes to read the report... but Alice's notepad pushed the sticky note to the cabinet before the report. Bob reads an empty report.

The fix is an office rule: "anything you wrote before posting a release note must reach the cabinet before the note does, and anyone who acquires (reads) that note must re-read the cabinet afterwards." That's exactly what a volatile write and read do.

3. What goes wrong without it

ProblemExampleFix
Visibilitywhile (!stop) {} never ends: the JIT reads stop once and loops forever.volatile boolean stop
Reorderingdata = 42; ready = true; Another thread sees ready but data == 0.Make ready volatile (a release/acquire pair)
Atomicitycount++ is read, add, write. Two threads lose updates.AtomicLong.incrementAndGet(), a lock, or a single writer
Check-then-actif (!map.containsKey(k)) map.put(k, v) from two threadscomputeIfAbsent on a ConcurrentHashMap
Unsafe publicationAssigning a new object to a plain shared field; another thread sees it half-built.Final fields, volatile field, or hand-off through a concurrent structure

4. Try it: the "store buffering" litmus test

Two threads, two shared variables, both start at 0:

Thread 1:  x = 1;  r1 = y;
Thread 2:  y = 1;  r2 = x;

If memory behaved like one shared whiteboard, at least one thread would see the other's write, so r1 = r2 = 0 would be impossible. Pick a field type and see what the JMM allows.

With plain fields, each core's write sits in its store buffer while the next instruction, the read, goes ahead. Both reads see the old 0. This really happens on x86 and ARM. Declaring the fields volatile makes all volatile accesses behave as if they ran in one global order, which forbids it.

5. Happens-before

The JMM's core idea: if action A happens-before action B, then B sees everything A (and everything before A) wrote. If there's no happens-before path between a write and a read of the same variable, and at least one is a write, that's a data race and almost anything is allowed. The edges:

EdgeMeaning
Program orderWithin one thread, earlier statements happen-before later ones.
MonitorReleasing a lock (end of synchronized) happens-before the next acquire of the same lock.
VolatileA write to a volatile field happens-before every later read of that field that sees it.
Thread start / joint.start() happens-before everything in t; everything in t happens-before t.join() returning.
Final fieldsAfter a constructor finishes, other threads see its final fields correctly, even without synchronization (if this didn't escape).
Concurrency utilitiesPutting into a BlockingQueue, submitting to an executor, completing a future: each documents a happens-before edge.
TransitivityA hb B and B hb C means A hb C. This is how a single volatile write can publish a whole batch of plain writes.

6. The tools, from weakest to strongest

ToolGives youCost
Plain fieldNothing across threadsFree
VarHandle opaqueThe value isn't cached forever, but no ordering of other fieldsAlmost free
Release write / acquire read
setRelease, getAcquire (formerly lazySet)
Everything before the release is visible to whoever acquires it. Exactly the "publish a message" pattern.Cheap. On x86 just a compiler barrier.
volatile / VarHandle volatileRelease + acquire + one global order of all volatile accessesA full fence on the write (on x86 a locked instruction, tens of cycles)
CAS: compareAndSet, getAndAddAtomic read-modify-write; lock-free algorithmsTens of cycles, much more under contention as the cache line bounces between cores
synchronized / ReentrantLockMutual exclusion + visibilityCheap uncontended; under contention threads park, costing microseconds

VarHandle (Java 9) is the supported way to get the fine-grained modes; before it, libraries used sun.misc.Unsafe. See off-heap memory.

7. Cache lines and false sharing

Caches move memory in cache lines: 64 bytes on x86 and most ARM servers, 128 bytes on Apple M-series. Cores keep their copies consistent with a coherence protocol (MESI and variants): to write a line, a core must own it exclusively, which invalidates every other core's copy.

False sharing A B one 64-byte line Core 1 writes A, core 2 writes B: the line ping-pongs between cores on every write. Padded Apadding Bpadding Each value owns its line: no interference. Fix: pad with unused fields (what the Disruptor's Sequence does), or @jdk.internal.vm.annotation.Contended (@Contended needs -XX:-RestrictContended outside the JDK).

Two unrelated counters, written by two threads, that happen to sit in the same line slow each other down as if they were one shared variable. Benchmarks commonly show several times lower throughput. Nothing in the source code reveals it; you find it by measuring.

8. How the Disruptor uses all this

  1. Claim. With many producers, a producer claims the next sequence with an atomic operation on the shared cursor. That is the only contended write.
  2. Write. It writes the command into the pre-allocated slot with plain writes. The slot is its own until published.
  3. Publish. It marks the sequence as available with a release write. Everything written to the slot before is now visible to anyone who acquires it.
  4. Consume. The matching thread reads the available sequence with an acquire read, then reads the slot with plain reads. Safe, by transitivity.
  5. Each consumer has its own padded sequence that only it writes (the single-writer principle), so there is no CAS on the consumer side and no false sharing between stages.
The single-writer principle (Martin Thompson): if exactly one thread ever writes a piece of data, you never need a lock or CAS for it, only a release write so readers see it. Prayog's matching thread is the single writer of all engine state.

9. In Prayog

WhereWhy it's safe
MatchingEngine: plain HashMap, TreeMap, counters, no locksOnly the matching thread touches it. Single writer.
CommandSlot: plain fields written by the matching thread, read by the journal stageThe matching handler's sequence is released after it finishes the slot; the journal stage acquires it before reading. Happens-before by transitivity.
JournalHandler built on the main thread, used on a stage threadThe Disruptor starts the stage thread later: Thread.start() is a happens-before edge.
The test's Acks handler shares order ids with producer threadsA ConcurrentHashMap and synchronized lists
S7 concurrency test: a planted bug skipped clearing a reused slot's event listCaught: reused objects must be reset by their single owner

10. Questions

Is volatile enough for a counter?

No. volatile gives visibility and ordering, not atomicity: count++ on a volatile is still read, add, write, and two threads can both read 5 and both write 6. Use an atomic, a lock, or one writer.

Why is double-checked locking broken without volatile?

The assignment instance = new Thing() can become visible before the constructor's writes, so another thread sees a non-null but half-initialised object. Making instance volatile adds the release/acquire pair that forbids it.

Why test on ARM as well as x86?

x86 is "strongly ordered" (only store-then-load can reorder), so racy code often works there by luck. ARM allows many more reorderings, so the same bug shows up. Apple Silicon laptops make this easy to hit.