Skip to content

Java · Concurrency

Java volatile & the Java Memory Model — Visibility, happens-before & Safe Publication

10 min read Updated 2026-06-20 Share:

Practice volatile & Memory Model interview questions

Why concurrency breaks your intuition

Single-threaded Java behaves the way you read it: statements run top to bottom, a write you just made is the value you read back. The moment a second thread touches the same fields, that intuition collapses. A write made by one thread might never become visible to another; instructions you wrote in one order might appear to run in another. None of this is a JVM bug — it is the Java Memory Model (JMM) deliberately leaving the JVM, JIT, and CPU free to optimize. To write correct concurrent code you stop reasoning about "what the CPU does" and start reasoning about the JMM's one core abstraction: happens-before. This guide builds up that model and shows where volatile, synchronized, and final fit.

The visibility problem

The classic motivating bug is a flag loop that never stops. One thread spins on a boolean; another sets it to true; the spinning thread keeps going forever. Nothing is "wrong" with the code — the reader is simply allowed to keep the flag in a register or CPU cache, or the JIT may hoist the read out of the loop entirely, so it never re-reads main memory.

class Worker {
  boolean stop = false;          // plain field — NOT volatile

  void run() {
    while (!stop) { /* busy */ } // JIT may read stop ONCE, then loop on a cached copy
  }
  void shutdown() { stop = true; } // a different thread sets it — write may never be seen
}

A write being invisible is the heart of the problem: without a synchronization action, the JMM gives no promise that one thread's writes ever reach another. Marking stopvolatile is the smallest fix, but to understand why it works you need the model underneath it.

What the JMM is and why it exists

The Java Memory Model is the slice of the language spec that answers two questions: which writes is a thread guaranteed to see, and what reorderings are legal. It has to exist because real systems optimize aggressively — store buffers, multi-level caches, out-of-order execution, register allocation, JIT reordering. Without a portable contract, the same program would behave differently on every CPU and be impossible to reason about.

// The JMM does NOT promise a single global order of operations across threads.
// It promises a MINIMUM set of guarantees you can build on:
//   volatile, synchronized, final, Thread.start()/join(), and a few library tools.

Crucially, the JMM does not hand you a single tidy timeline of every operation. It gives you a partial order — happens-before — and tells you that anything not ordered by it may be reordered or read stale. Your job is to deliberately place happens-before edges where threads communicate.

The happens-before relationship

Happens-before is the JMM's promise: if action A happens-before B, then everything A wrote is visible to B, and A appears to run before B. The edges come from a fixed set of rules, and transitivity chains them together:

// Sources of happens-before edges:
//   Program order  — each action hb later actions in the SAME thread
//   Monitor lock   — unlocking a monitor hb a later lock of the SAME monitor
//   Volatile       — a write to a volatile field hb every later read of it
//   Thread start   — t.start() hb every action inside the started thread
//   Thread join    — every action in a thread hb another thread's t.join() returning
//   Transitivity   — if A hb B and B hb C, then A hb C

Notice what is missing: two plain reads/writes in different threads have no edge between them by default. Where there is no happens-before edge, the JMM owes you nothing — no visibility, no ordering. Every concurrency bug is, at bottom, a missing edge.

What volatile guarantees — and what it doesn't

volatile does exactly two things: it provides visibility and ordering. A volatile write is flushed so every thread sees it; a volatile read always fetches the latest value from main memory; and the write establishes a happens-before edge to every later read of that field. Because of that edge, plain writes that came before the volatile write ride along and become visible too.

volatile boolean ready = false;
int data;                 // plain field

// writer thread
data = 42;                // ordinary write
ready = true;             // volatile write — happens-before any later read of ready

// reader thread
if (ready) {              // volatile read
  use(data);              // guaranteed to see 42 (carried along by happens-before)
}

What volatile does not give you is atomicity. It makes each individual read and write fresh, but it cannot make a sequence of them indivisible — which is exactly the next trap.

Atomicity vs visibility vs ordering

Interviewers love these three because conflating them is the classic mistake. They are genuinely distinct concerns:

  • Atomicity — an operation completes all-or-nothing; no thread sees a half-done state.
  • Visibility — a write by one thread becomes observable to another.
  • Ordering — the order operations appear to execute in.
volatile int count = 0;
count++;   // really: int tmp = count; tmp = tmp + 1; count = tmp;  — THREE steps

count++ is a read-modify-write: two threads can both read the same value, both add one, both store the same result — a lost update — even though every step sees fresh memory. volatile covers visibility and ordering; it adds no atomicity. For an atomic counter use AtomicInteger.incrementAndGet() (built on hardware compare-and-swap), or a lock. The rule: volatile is correct only when a write doesn't depend on the current value, like a one-way status flag — never for read-modify-write.

volatile vs synchronized

Both establish happens-before edges and both fix visibility, but they solve different problems. synchronized also gives mutual exclusion and atomicity over a whole block; volatile is a single-field, never-blocking visibility tool. And synchronized provides visibility because releasing a monitor happens-before the next acquire of the same monitor — it is not only a lock.

volatile boolean flag;              // cheap, lock-free visibility for ONE field

Object lock = new Object();
synchronized (lock) {               // exclusive access + visibility for a region
  balance = balance - amount;       // compound action made atomic
}

Reach for volatile when you only need one variable's latest value visible. Reach for synchronized when you need an atomic compound operation or must keep multiple related fields consistent together — volatile is per-field and cannot maintain an invariant that spans two fields.

Instruction reordering

Compilers, the JIT, and CPUs may execute instructions out of program order as long as a single thread's result is unchanged — the as-if-serial guarantee. Reordering powers register allocation and latency hiding, so it is everywhere.

int a = 1;   // no dependency between these two,
int b = 2;   // so the JVM/CPU may run them in either order

int y = a;   // depends on a — can NEVER be reordered before 'a = 1'

Within one thread you never notice. Across threads you can: a second thread might observe b set before a. The JMM only forbids reordering across happens-before edges — a volatile access or a lock release/acquire acts as a barrier the optimizer may not cross. This is the precise reason the next pattern needs volatile.

Double-checked locking needs volatile

The double-checked-locking (DCL) singleton checks the instance once without a lock and once inside, to avoid synchronizing on every call. Without volatile it is subtly broken, because instance = new Singleton() is not atomic: it allocates memory, runs the constructor, and assigns the reference — and those steps may be reordered. A second thread could see a non-null reference pointing at a partially constructed object.

class Singleton {
  private static volatile Singleton instance;   // volatile is REQUIRED, not optional

  static Singleton get() {
    if (instance == null) {                // 1st check, no lock — fast path
      synchronized (Singleton.class) {
        if (instance == null)              // 2nd check, under the lock
          instance = new Singleton();      // volatile forbids the dangerous reorder
      }
    }
    return instance;
  }
}

volatile bans the reordering and adds a happens-before edge, so any thread that sees the reference also sees a fully built object. If hand-writing DCL feels fragile, the holder-class idiom (a static nested class initialized lazily by the classloader) gets the same lazy, thread-safe result with no volatile at all.

Safe publication

Publishing an object means making its reference visible to other threads. Safe publication additionally guarantees that a thread which sees the reference also sees the object's fully-initialized fields. Just assigning to a plain field is unsafe: another thread may see the reference but stale field values inside the object.

class Holder {
  private volatile Config config;        // volatile field = safe publication
  void set(Config c) { config = c; }     // readers of config see a COMPLETE Config
  Config get() { return config; }
}

Per Java Concurrency in Practice, the safe routes are: store the reference in a volatile field (or AtomicReference), in a final field set in a constructor, in a field guarded by a lock, or via a static initializer. Transitivity is what makes this work — the one volatile/lock edge carries along every plain write that preceded the publication.

Final fields and freeze semantics

final fields get special freeze semantics: when a constructor finishes, the JMM "freezes" the object's final fields, and any thread that obtains the object through a reference published after construction is guaranteed to see those fields correctly — with no extra synchronization.

class Point {
  final int x, y;                       // frozen when the constructor returns
  Point(int x, int y) { this.x = x; this.y = y; }
}
// Any thread that later sees a Point reference always sees the real x and y.

The one caveat: the object must not leak this during construction (registering a listener or storing this in a shared field before the constructor returns defeats the freeze). Final fields are precisely why immutable objects are inherently thread-safe and can be shared freely.

long and double tearing

The JMM guarantees atomic reads and writes only for 32-bit-or-smaller types and references. A non-volatile long or double (64 bits) may be written as two separate 32-bit stores, so another thread can read a torn value — the high half of one write spliced with the low half of another.

long balance;                         // NOT volatile — a 64-bit write may tear
// Thread A: balance = 0xFFFFFFFF_00000000L;
// Thread B may read the full value, 0, or a mangled mix of two writes.

Declaring the field volatile makes 64-bit reads and writes atomic (and visible). Most modern 64-bit JVMs happen to write longs atomically, but the spec does not require it, so portable code that shares a writable long/double should mark it volatile or guard it with a lock.

Recap

Concurrency intuition fails because the JMM lets the JVM and CPU cache, buffer, and reorder freely; the model's one guarantee is happens-before, and every bug is a missing edge. volatile supplies visibility and ordering for a single field — perfect for a one-way flag — but not atomicity, so count++ still races; use AtomicInteger or a lock for read-modify-write, and a lock for invariants spanning multiple fields. synchronized adds mutual exclusion and atomicity on top of visibility. Reordering is legal within a thread (as-if-serial) but constrained across threads only by happens-before edges, which is why double-checked locking demands a volatile field. Lean on safe publication (volatile, final, locks, static init) and final-field freeze for immutable objects, and remember that shared 64-bit long/double can tear without volatile. Master happens-before and the rest of the JMM falls into place.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel