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.
javacdoes 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.
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");
}
}count <= 2000000: true
exactly 2000000 is not guaranteedOn 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:
// 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.
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());
}
}worker saw stop
worker finished: trueWithout 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.
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));
}
}runs = 100000
all outcomes legal: trueIn 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.
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();
}
}data = 42With 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.
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]);
}
}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.
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);
}
}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.
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()));
}
}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;
volatileprevents this. Thread.sleepandThread.onSpinWaithave no memory effects.- Non-volatile
longanddoublewrites may tear (JLS 17.7); volatile ones never do. - Atomicity, visibility, and ordering are separate;
volatilegives the last two for single accesses, notcount++. - 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
volatilereference, 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
longanddoubleonly. volatile,synchronized, atomics, and thread start and join are the tools that remove data races.
