Concurrency Basics

volatile & Memory Visibility

15 min Lesson 6 of 13

volatile & Memory Visibility

You already know that a race condition occurs when two threads modify shared data without synchronisation. But there is a subtler, and often more treacherous, category of concurrency bug: a thread can read a value that is stale — not because another thread is writing at the same time, but simply because the JVM or the CPU is allowed to keep a private cached copy of the variable. Understanding this is the heart of the Java Memory Model.

The Visibility Problem

Modern CPUs do not read and write main memory on every instruction. Each core has its own registers and one or more layers of cache (L1, L2, L3). The JVM exploits this heavily: a value written by Thread A might sit in A's CPU cache and never be flushed to main memory. Thread B, running on a different core, reads from its own cache and sees the old value indefinitely.

Consider this classic example:

public class VisibilityDemo { // no synchronisation, no volatile private static boolean keepRunning = true; public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { int count = 0; while (keepRunning) { // may loop forever count++; } System.out.println("Stopped. Count = " + count); }); worker.start(); Thread.sleep(1000); keepRunning = false; // main thread writes System.out.println("Flag set to false"); } }

On many JVMs and hardware configurations this program never terminates. The worker thread caches keepRunning in a register and never re-reads main memory. The write by the main thread is invisible to it.

This is not a bug in your logic — it is a feature of the platform. The Java Language Specification (JLS) and the Java Memory Model (JMM) deliberately allow these optimisations. Without them, every memory access would require a full cache flush, making Java programs significantly slower. The trade-off is that you, the programmer, must declare which variables need visibility guarantees.

Happens-Before: The Formal Guarantee

The Java Memory Model does not think in terms of caches or CPU instructions. Instead it defines a single, hardware-agnostic rule called happens-before.

A happens-before relationship between two actions (a write and a read) guarantees that the write is visible to the read. Put differently: if action A happens-before action B, then every memory write performed by A (or any action before A) is guaranteed to be visible to B.

Key happens-before rules in the JMM:

  • Program order: Within a single thread, each statement happens-before the next.
  • Monitor unlock → lock: Unlocking a synchronized block happens-before any subsequent lock on the same monitor.
  • volatile write → volatile read: A write to a volatile variable happens-before every subsequent read of that same variable.
  • Thread start: Thread.start() happens-before any action in the started thread.
  • Thread join: All actions in a thread happen-before Thread.join() returns.
Transitivity matters. If A happens-before B, and B happens-before C, then A happens-before C. This lets you reason about chains of guarantees, not just individual pairs.

The volatile Keyword

Declaring a field volatile establishes a happens-before edge between every write to that field and every subsequent read of it, across all threads.

Two concrete effects follow from this:

  1. Visibility: A write to a volatile field is immediately flushed to main memory. A read always fetches from main memory (bypassing the CPU cache). The stale-read problem disappears.
  2. No reordering around volatile access: The JVM and CPU are forbidden from moving reads or writes of other variables across a volatile access. This is the "memory barrier" effect.

Fixing the earlier demo is trivial:

public class VisibilityFixed { private static volatile boolean keepRunning = true; public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { int count = 0; while (keepRunning) { // now always re-reads from main memory count++; } System.out.println("Stopped. Count = " + count); }); worker.start(); Thread.sleep(1000); keepRunning = false; System.out.println("Flag set to false"); } }

Adding volatile guarantees the worker thread will observe the write within a bounded time.

volatile Is Not a Replacement for synchronized

volatile guarantees visibility but not atomicity. A classic mistake:

public class BrokenCounter { private volatile int count = 0; // called from multiple threads public void increment() { count++; // NOT atomic — read, increment, write are three separate ops } }

Even though every read of count goes to main memory, the compound read-modify-write of count++ is still a race condition: two threads can both read the same value, both add 1, and both write back, losing one increment.

For compound operations you need either synchronized or an AtomicInteger (covered in the next lesson). The correct use cases for volatile are:

  • A single boolean flag set by one thread and read by others (e.g., a shutdown flag).
  • A single reference (or primitive) that is written by exactly one thread and read by many, where only the latest value matters.
  • Fields that act as publication barriers — writing a volatile field after constructing an object ensures the constructed object is safely visible to other threads that subsequently read that field.

Publication via volatile

A subtle but important use of volatile is safe publication. Without it, other threads could see a partially constructed object even if the reference was written after the constructor returned — because the JVM can reorder the field stores inside the constructor with the reference assignment.

public class Config { public final String host; public final int port; public Config(String host, int port) { this.host = host; this.port = port; } } public class Server { // volatile ensures that once a non-null config is read, // all of its fields are also visible private volatile Config config; public void reload(String host, int port) { config = new Config(host, port); // volatile write } public void handleRequest() { Config c = config; // volatile read if (c != null) { System.out.println(c.host + ":" + c.port); } } }
Do not conflate visibility with atomicity. This is the single most common mistake when learning volatile. Visibility means: once a value is written, readers will see it. Atomicity means: a compound operation (like ++ or a conditional update) completes as a single indivisible step. volatile gives you only the former.

Performance Considerations

A volatile read is cheaper than synchronized but not free. Each read forces a cache-coherence protocol round-trip on modern hardware (an x86 "mfence" or equivalent barrier). In tight loops that read a volatile many millions of times per second the overhead is measurable. The standard pattern is to copy the volatile field into a local variable at the top of the method and work with the local variable inside the loop, re-reading the volatile only when needed:

public void process() { boolean running = this.keepRunning; // one volatile read while (running) { doWork(); running = this.keepRunning; // re-read periodically if needed } }

Summary

volatile is the lightest-weight synchronisation primitive in Java. It solves the visibility problem by establishing a happens-before relationship between writes and reads, preventing threads from operating on stale cached values. It does not provide atomicity for compound operations — that job belongs to synchronized or the java.util.concurrent.atomic package. Use volatile when you have a single writer, or when the only requirement is that the latest value is always visible, with no multi-step invariant to protect.