Guide contents

Lesson 31 of 71

Visibility, Atomicity, and Reordering

The Java Memory Model (JMM, chapter 17 of the Java Language Specification) defines which values a read of a shared variable may return when several threads run at once. This lesson shows the three ways multithreaded code goes wrong without it: lost updates (atomicity), stale values (visibility), and operations seen in an unexpected order (ordering). It builds on Threads, synchronized, wait and notify. The rules that fix these problems are the subject of Happens-Before and volatile.

Property Question it answers Typical failure
Atomicity Does an operation happen as one indivisible step? count++ loses updates
Visibility Does a write by one thread become visible to another? Loop never sees stop = true
Ordering Do other threads see writes in program order? Flag seen before the data it guards

Programs that show a failure print a property that holds on every run (for example "count <= 2000000: true"), because the exact failing value changes from run to run.

Why a memory model exists

A single-threaded program only needs to behave as if statements ran in order. Hardware and compilers use that freedom heavily:

  • Caches and store buffers. Each core writes into a private store buffer and reads through private caches, so another core can see a write later than the writing core does. A later load can complete before an earlier store drains, which is store-load reordering, and it happens on x86. Cache Coherence and False Sharing covers caches, MESI, and store buffers in depth.
  • Weaker CPUs. ARM and POWER also reorder loads with loads and stores with stores.
  • Compilers. javac does little, but the JIT compilers (C1 and C2) reorder, merge, and remove memory accesses, and keep values in registers.

The JMM is the contract between these layers and Java code. It says which reorderings are allowed and which language features (volatile, synchronized, final, thread start and join) restrict them, so a correctly synchronized program behaves the same on x86, ARM, and any JIT.

Example 1: Lost updates (atomicity)

count++ is three steps: read, add one, write. Two threads can read the same value and both write the same result, losing one increment.

Java
public class LostUpdate {
    static int count;

    public static void main(String[] args) throws InterruptedException {
        Runnable inc = () -> { for (int i = 0; i < 1_000_000; i++) count++; };
        Thread a = new Thread(inc), b = new Thread(inc);
        a.start(); b.start();
        a.join(); b.join();
        System.out.println("count <= 2000000: " + (count <= 2_000_000));
        System.out.println("exactly 2000000 is not guaranteed");
    }
}
Output
count <= 2000000: true
exactly 2000000 is not guaranteed

On a multicore machine the printed value of count is usually below 2,000,000 and differs on every run. The JIT may also turn the loop into one addition of 1,000,000, which changes how many updates are lost. Both behaviors are legal because the program has a data race (defined in Example 7).

Example 2: A hoisted loop (visibility)

A loop that reads a plain field may never see another thread's write. The JIT can observe that the loop body does not change stop and transform the code:

Text
// Source (a fragment, not a full program)
while (!stop) { work++; }

// What the JIT is allowed to produce
if (!stop) { while (true) { work++; } }

This is hoisting: the read is moved out of the loop and done once. The program then runs forever on HotSpot after C2 compiles the loop, even though the main thread set stop = true. Declaring the field volatile forbids the transformation, because every volatile read must read memory.

Java
public class StopFlag {
    static volatile boolean stop;          // remove volatile and the loop may never end
    static long work;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            while (!stop) { work++; }
            System.out.println("worker saw stop");
        });
        worker.start();
        Thread.sleep(100);                 // let the loop run hot and get compiled
        stop = true;
        worker.join();
        System.out.println("worker finished: " + !worker.isAlive());
    }
}
Output
worker saw stop
worker finished: true

Without volatile, the result depends on timing and the JIT: in the interpreter the loop reads memory each time and often stops, and after compilation it often does not. "Works in testing" is therefore no evidence of correctness. Thread.sleep has no synchronization effect in the JMM; it does not make writes visible.

Example 3: Store-load reordering

The classic litmus test uses two variables and two threads. Each thread writes one variable and then reads the other.

Java
import java.util.HashSet;
import java.util.Set;

public class StoreLoad {
    static int x, y, r1, r2;

    public static void main(String[] args) throws InterruptedException {
        Set<String> seen = new HashSet<>();
        int runs = 100_000;
        for (int i = 0; i < runs; i++) {
            x = 0; y = 0;
            Thread t1 = new Thread(() -> { x = 1; r1 = y; });
            Thread t2 = new Thread(() -> { y = 1; r2 = x; });
            t1.start(); t2.start();
            t1.join(); t2.join();
            seen.add("(" + r1 + "," + r2 + ")");
        }
        Set<String> legal = Set.of("(0,1)", "(1,0)", "(1,1)", "(0,0)");
        System.out.println("runs = " + runs);
        System.out.println("all outcomes legal: " + legal.containsAll(seen));
    }
}
Output
runs = 100000
all outcomes legal: true

In any interleaving of the four statements, at least one write comes before both reads, so (0,0) looks impossible. It is still allowed and is observed on x86: each thread's write sits in its store buffer while its read of the other variable completes. Thread start costs much more than the race window, so this program rarely hits (0,0); tools that test this reliably (jcstress) run the two actions repeatedly on pre-started threads. Making x and y volatile forbids (0,0), because volatile accesses are sequentially consistent; on x86 the JIT emits a full fence (a lock-prefixed instruction) after a volatile write for this reason.

Example 4: Reordering a flag and its data (ordering)

A common bug publishes data with a plain flag. The writer's two stores may become visible in either order, and the reader's two loads may also be reordered, so the reader can see ready == true and old data.

Java
public class Publish {
    static int data;
    static volatile boolean ready;          // plain boolean here would be a bug

    public static void main(String[] args) throws InterruptedException {
        Thread reader = new Thread(() -> {
            while (!ready) { Thread.onSpinWait(); }
            System.out.println("data = " + data);
        });
        reader.start();
        data = 42;                          // ordinary write
        ready = true;                       // volatile write publishes it
        reader.join();
    }
}
Output
data = 42

With ready volatile, a reader that sees ready == true is guaranteed to see data = 42: everything the writer did before the volatile write is visible to a thread that reads the value it wrote. The exact rule is happens-before, covered in the next lesson. With a plain ready, three failures are legal: the loop is hoisted and never ends (Example 2), the stores are reordered, or the loads are reordered, and the reader prints data = 0. Thread.onSpinWait() (Java 9) is a hint to the CPU that the thread is busy-waiting; it gives no memory ordering.

Example 5: Word tearing of long and double

JLS section 17.7 allows a write to a non-volatile long or double to be split into two 32-bit writes. A reader can then see the high half of one value and the low half of another: a torn value that no thread wrote. Writes and reads of volatile long and volatile double are always atomic, and so are references and all other primitive types.

Java
public class Tearing {
    static volatile long value;              // without volatile, tearing is permitted

    public static void main(String[] args) throws InterruptedException {
        Thread writer = new Thread(() -> {
            for (int i = 0; i < 1_000_000; i++) value = (i % 2 == 0) ? 0L : -1L;
        });
        long[] torn = new long[1];
        Thread reader = new Thread(() -> {
            for (int i = 0; i < 1_000_000; i++) {
                long v = value;
                if (v != 0L && v != -1L) torn[0]++;
            }
        });
        writer.start(); reader.start();
        writer.join(); reader.join();
        System.out.println("torn reads = " + torn[0]);
    }
}
Output
torn reads = 0

-1L is all 64 bits set; a torn read would show a value such as 0x00000000FFFFFFFF. On 64-bit HotSpot, plain long writes are atomic in practice, and the JLS encourages implementations to avoid splitting them. Tearing can appear on 32-bit JVMs. Code that relies on atomic plain long writes depends on an implementation detail, not on the specification.

AtomicLong and volatile long both give atomic reads and writes, but only AtomicLong gives atomic read-modify-write operations such as incrementAndGet.

Example 6: Atomicity, visibility, and ordering are separate

volatile gives visibility and ordering for single reads and writes. It does not make a compound operation such as count++ atomic, because the read and the write are still two separate volatile accesses.

Java
import java.util.concurrent.atomic.AtomicInteger;

public class ThreeProperties {
    static volatile int volatileCount;
    static final AtomicInteger atomicCount = new AtomicInteger();
    static int lockedCount;
    static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        Runnable work = () -> {
            for (int i = 0; i < 100_000; i++) {
                volatileCount++;                          // visible, not atomic
                atomicCount.incrementAndGet();            // atomic CAS
                synchronized (lock) { lockedCount++; }    // atomic via mutual exclusion
            }
        };
        Thread a = new Thread(work), b = new Thread(work);
        a.start(); b.start();
        a.join(); b.join();
        System.out.println("volatile <= 200000: " + (volatileCount <= 200_000));
        System.out.println("atomic = " + atomicCount.get());
        System.out.println("locked = " + lockedCount);
    }
}
Output
volatile <= 200000: true
atomic = 200000
locked = 200000
Mechanism Atomic compound update Visibility Ordering
Plain field No No guarantee No guarantee
volatile field Single read or write only Yes Yes (sequentially consistent for volatile accesses)
synchronized Yes, for the whole block Yes, on unlock then lock of the same monitor Yes, across that monitor
AtomicInteger Yes, for its methods Yes Yes

AtomicInteger and compare-and-set are covered in Atomic Classes and CAS.

Example 7: Data races and a correct production pattern

Two accesses conflict when they touch the same variable from different threads and at least one is a write. A data race is a pair of conflicting accesses that is not ordered by happens-before (the ordering relation built from locks, volatile accesses, and thread start and join). A program with no data races is correctly synchronized, and the JMM guarantees it behaves as if all actions ran in one interleaved order (sequential consistency). A racy program may see stale, reordered, or (for long and double) torn values.

A common production pattern avoids races on configuration: build an immutable object, then publish it through one volatile reference. Readers never lock, and they always see a complete object.

Java
import java.util.Map;

public class ConfigHolder {
    record Config(int maxOrderQty, Map<String, Double> limits) {
        Config { limits = Map.copyOf(limits); }        // immutable copy
    }

    private static volatile Config current = new Config(100, Map.of("EUR", 1_000.0));

    static Config get() { return current; }            // one volatile read
    static void reload(Config next) { current = next; } // one volatile write

    public static void main(String[] args) throws InterruptedException {
        Thread reloader = new Thread(() ->
                reload(new Config(500, Map.of("EUR", 5_000.0, "USD", 4_000.0))));
        reloader.start();
        reloader.join();

        Config c = get();                               // read once, use the local
        System.out.println("max qty = " + c.maxOrderQty());
        System.out.println("limits = " + new java.util.TreeMap<>(c.limits()));
    }
}
Output
max qty = 500
limits = {EUR=5000.0, USD=4000.0}

The reader copies the reference into a local once and uses only the local, so it sees one consistent configuration even if a reload happens in between. Calling get() twice could mix two versions. Updates that depend on the current value (read, modify, write back) would need a lock or a compare-and-set, because two reloaders could otherwise overwrite each other.

Interview points

  • The JMM defines which values a read may see; it exists because caches, store buffers, and JIT compilers reorder memory operations.
  • x86 allows store-load reordering through store buffers; ARM also reorders load-load and store-store.
  • A plain field read in a loop may be hoisted, so the loop never sees another thread's write; volatile prevents this.
  • Thread.sleep and Thread.onSpinWait have no memory effects.
  • Non-volatile long and double writes may tear (JLS 17.7); volatile ones never do.
  • Atomicity, visibility, and ordering are separate; volatile gives the last two for single accesses, not count++.
  • A data race is two conflicting accesses not ordered by happens-before; data-race-free programs behave sequentially consistently.
  • Publish immutable snapshots through a single volatile reference, and read it once into a local.

Summary

  • Lost updates are an atomicity failure.
  • Stale reads and hoisted loops are visibility failures.
  • Seeing a flag before its data is an ordering failure.
  • Store buffers let a later load pass an earlier store on x86.
  • Word tearing is permitted for plain long and double only.
  • volatile, synchronized, atomics, and thread start and join are the tools that remove data races.