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:
- CPU caches and store buffers. Each core writes into its own store buffer and cache first. Other cores see the write later.
- Compiler reordering. The JIT compiler may reorder, merge or hoist reads and writes if a single thread can't tell the difference.
- CPU reordering. Cores execute out of order. ARM (Apple M-series, AWS Graviton) reorders more than x86.
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
| Problem | Example | Fix |
|---|---|---|
| Visibility | while (!stop) {} never ends: the JIT reads stop once and loops forever. | volatile boolean stop |
| Reordering | data = 42; ready = true; Another thread sees ready but data == 0. | Make ready volatile (a release/acquire pair) |
| Atomicity | count++ is read, add, write. Two threads lose updates. | AtomicLong.incrementAndGet(), a lock, or a single writer |
| Check-then-act | if (!map.containsKey(k)) map.put(k, v) from two threads | computeIfAbsent on a ConcurrentHashMap |
| Unsafe publication | Assigning 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:
| Edge | Meaning |
|---|---|
| Program order | Within one thread, earlier statements happen-before later ones. |
| Monitor | Releasing a lock (end of synchronized) happens-before the next acquire of the same lock. |
| Volatile | A write to a volatile field happens-before every later read of that field that sees it. |
| Thread start / join | t.start() happens-before everything in t; everything in t happens-before t.join() returning. |
| Final fields | After a constructor finishes, other threads see its final fields correctly, even without synchronization (if this didn't escape). |
| Concurrency utilities | Putting into a BlockingQueue, submitting to an executor, completing a future: each documents a happens-before edge. |
| Transitivity | A 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
| Tool | Gives you | Cost |
|---|---|---|
| Plain field | Nothing across threads | Free |
VarHandle opaque | The value isn't cached forever, but no ordering of other fields | Almost free |
Release write / acquire readsetRelease, 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 volatile | Release + acquire + one global order of all volatile accesses | A full fence on the write (on x86 a locked instruction, tens of cycles) |
CAS: compareAndSet, getAndAdd | Atomic read-modify-write; lock-free algorithms | Tens of cycles, much more under contention as the cache line bounces between cores |
synchronized / ReentrantLock | Mutual exclusion + visibility | Cheap 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.
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
- Claim. With many producers, a producer claims the next sequence with an atomic operation on the shared cursor. That is the only contended write.
- Write. It writes the command into the pre-allocated slot with plain writes. The slot is its own until published.
- 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.
- Consume. The matching thread reads the available sequence with an acquire read, then reads the slot with plain reads. Safe, by transitivity.
- 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.
9. In Prayog
| Where | Why it's safe |
|---|---|
MatchingEngine: plain HashMap, TreeMap, counters, no locks | Only the matching thread touches it. Single writer. |
CommandSlot: plain fields written by the matching thread, read by the journal stage | The 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 thread | The Disruptor starts the stage thread later: Thread.start() is a happens-before edge. |
The test's Acks handler shares order ids with producer threads | A ConcurrentHashMap and synchronized lists |
| S7 concurrency test: a planted bug skipped clearing a reused slot's event list | Caught: 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.