Volatile Keyword in Java

Theory & Concept

The Java Memory Model and the Visibility Problem

To understand volatile, we first need a quick recap of the Java Memory Model (JMM). The JMM defines how threads interact through shared memory. Each thread can keep a local cache (CPU registers, L1/L2 caches) of variables. Without any synchronization, a write by one thread may never become visible to another – or it may become visible in a completely unexpected order.

This is where volatile enters. According to the Java Language Specification, a volatile field imposes two key guarantees:

  1. Visibility – A write to a volatile variable happens‑before every subsequent read of that same variable. Every thread reading the field will see the most recent write, regardless of local caches.
  2. Ordering (no reordering) – Reads and writes of other variables cannot be reordered across a volatile read or write. This prevents subtle compiler/CPU instruction‑reordering issues that break multi‑threaded logic.

In short, volatile acts as a lightweight synchronization mechanism for a single variable. It’s much cheaper than using a full‑blown lock, but it doesn’t provide atomicity.

Why volatile Doesn’t Make Code Thread‑Safe

This is the single most misunderstood point: volatile ensures visibility of the value of the variable itself, but it does not protect compound actions. An operation like count++ is three steps:

  1. Read the current value.
  2. Increment it.
  3. Write the new value back.

Two threads can interleave these steps, resulting in lost updates. volatile guarantees that each read sees the latest write, but it doesn’t prevent another thread from sneaking in between your read and your write. Thus, volatile alone is thread‑safe only for simple assignment (e.g., setting a flag) and not for check‑then‑act or read‑modify‑write sequences.

When Should Developers Care?

  • Flags / status indicators: a thread needs to signal another to stop or change state.
  • Double‑checked locking: the famous lazy‑initialization singleton pattern relies on volatile since Java 5.
  • Lock‑free publish: publishing immutable objects safely (though often final fields are enough if properly constructed).

Any scenario that requires a happens‑before edge without the overhead of locking is a candidate.


Implementation Guide

1. A Simple Stop Flag (Visibility in Action)

The most common real‑world use is a thread‑safe flag.

public class StopTask {
    // Without volatile, the loop might never stop
    // because the worker thread may cache the value.
    private volatile boolean running = true;

    public void start() {
        Thread worker = new Thread(() -> {
            int i = 0;
            while (running) {
                i++;  // do some work
            }
            System.out.println("Worker stopped. i = " + i);
        });
        worker.start();

        // Let the worker run for a while
        try { Thread.sleep(100); } catch (InterruptedException e) {}

        // Set the flag from another thread
        running = false;
        System.out.println("Flag set to false from main thread");
    }

    public static void main(String[] args) {
        new StopTask().start();
    }
}

Without volatile, the JIT compiler may hoist the running read into a register and never re‑check the memory location. With volatile, the write running = false is immediately visible, and the loop terminates as expected.

2. The Non‑Atomic Counter Problem (Why volatile Isn’t Enough)

Let’s see what happens when we naively use volatile for a counter.

public class VolatileCounter {
    private volatile int count = 0;
    private static final int ITERATIONS = 10_000;

    public void increment() {
        count++; // NOT atomic
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileCounter counter = new VolatileCounter();
        Runnable task = () -> {
            for (int i = 0; i < ITERATIONS; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start(); t2.start();
        t1.join();  t2.join();

        // Expected: 20000, actual will be less (lost updates)
        System.out.println("Final count (volatile): " + counter.count);
    }
}

You’ll consistently get values < 20,000. The volatile only guarantees that the read of count inside increment() sees the latest value, but the entire read‑increment‑write sequence is not atomic.

3. The Fix: AtomicInteger to the Rescue

java.util.concurrent.atomic.AtomicInteger provides atomic getAndIncrement() via hardware‑level compare‑and‑swap (CAS). It also ensures the same visibility guarantees as volatile.

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
    private final AtomicInteger count = new AtomicInteger(0);
    private static final int ITERATIONS = 10_000;

    public void increment() {
        count.getAndIncrement(); // atomic compound action
    }

    public static void main(String[] args) throws InterruptedException {
        AtomicCounter counter = new AtomicCounter();
        Runnable task = () -> {
            for (int i = 0; i < ITERATIONS; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start(); t2.start();
        t1.join();  t2.join();

        // Always exactly 20000
        System.out.println("Final count (atomic): " + counter.count.get());
    }
}

AtomicInteger not only makes the increment atomic but also provides methods like compareAndSet and lazySet for advanced lock‑free algorithms. Internally, its value field is itself volatile; thus any get() sees the latest write.

4. Double‑Checked Locking with volatile

Before Java 5, the double‑checked locking idiom was broken. The volatile guarantee fixed it by preventing the compiler from reordering the write to the singleton reference with the constructor initialization.

public class Singleton {
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {                     // first check (no lock)
            synchronized (Singleton.class) {
                if (instance == null) {             // second check (with lock)
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Without volatile, a thread might see a partially constructed object. volatile enforces a strict happens‑before between the write of the fully constructed object and the read of the instance variable.


Pros & Cons

Advantages of volatile

  1. Lightweight visibility – Imposes no lock contention; reads/writes are at almost the same speed as normal variable access on modern CPUs.
  2. Simple to understand – For flags and simple state, the code remains concise and clean.
  3. Prevents reordering – Crucial for patterns like double‑checked locking and lock‑free messaging.
  4. Works with long / double atomically – A volatile long or volatile double is guaranteed to be read/written atomically (otherwise these 64‑bit types can be torn).

Disadvantages

  1. No mutual exclusion – It cannot protect a critical section. Compound actions remain non‑atomic.
  2. Limited use cases – It’s not a substitute for proper synchronization when state transitions depend on previous state.
  3. Still requires understanding the JMM – Misplaced volatile gives a false sense of safety.
  4. Performance cost – Although lighter than locks, volatile prevents some CPU and compiler optimisations (e.g., hoisting), which may affect nearby code. In hot paths, measure carefully.

volatile vs AtomicInteger – A Side‑by‑Side Comparison

Aspect volatile AtomicInteger (and other atomics)
Atomicity No – only simple assignments are atomic. Yes – provides atomic getAndIncrement, compareAndSet, etc.
Visibility Yes – a write is always seen by a later read. Yes – underlying value is volatile.
Compound operations Not safe (count++ fails). Safe (getAndIncrement()).
Performance Very low overhead. Slightly higher due to CAS loops, but still lock‑free.
Typical use Flags, state markers, double‑checked locking. Counters, accumulators, non‑blocking data structures.

Key takeaway: If you need to increment a counter shared between threads, forget volatile – reach for an AtomicInteger (or LongAdder under high contention).


volatile vs synchronized – Choosing the Right Tool

synchronized is a locking mechanism providing both atomicity and visibility. When a thread exits a synchronized block, it flushes all cached variables to main memory. When another thread enters a synchronized block on the same monitor, it reloads all variables, seeing the previous thread’s changes.

volatile synchronized
Visibility Only the volatile variable itself + any variables written before it. All variables written before the lock release are visible to the next locker.
Atomicity None (except for single reads/writes of the variable). Full mutual exclusion for the synchronized block.
Blocking Non‑blocking. Blocking – threads wait for the lock.
Use case Simple signals, flags, read‑only volatile references. Critical sections, complex state updates, consistent multi‑variable invariants.
Performance Minimal impact. Higher overhead, especially under contention.
// synchronized for a counter – guaranteed correct but heavier
public synchronized void incrementSync() {
    count++;
}

// volatile for a simple flag – lightweight and correct
private volatile boolean stopped = false;

For a single variable that is only written by one thread and read by others, volatile is perfect. For anything involving read‑modify‑write or multiple variables that must stay consistent together, synchronized or a Lock is the right answer.


Conclusion

volatile is a sharp, specialised tool. It solves the visibility problem with minimal fuss, making it ideal for stop flags, status indicators, and publishing safely constructed objects. But it’s not a silver bullet – it cannot replace proper synchronization for compound actions. Knowing when to escalate to AtomicInteger or synchronized is what separates a concurrency‑savvy developer from one that just sprinkles volatile everywhere and hopes for the best.

Remember the golden rule: For simple writes and reads across threads, volatile wins. For anything else, choose the right tool and keep your code predictable under load.


 

Post a Comment

Previous Post Next Post