Concurrent code that "works on my machine" can fail on another CPU or JIT; the memory model tells you what is actually guaranteed.
Compilers, the JIT and CPUs all reorder and cache memory operations for speed. Within one thread you can't tell; across threads you can. The Java Memory Model (JLS chapter 17) defines which writes another thread is guaranteed to see.
The central idea is happens-before. If action A happens-before action B, then B sees A's effects (and everything before A). Key rules:
- Program order: each action in a thread happens-before later actions in that thread.
- Monitor lock: unlocking a monitor happens-before every later lock of the same monitor.
volatile: a write to a volatile field happens-before every subsequent read of that field.Thread.start()happens-before the started thread's actions; a thread's actions happen-before another thread returns fromjoin()on it.- Transitivity ties them together.
java.util.concurrentclasses document their own edges (e.g. putting into aBlockingQueuehappens-before taking it out).
Two conflicting accesses (at least one a write) not ordered by happens-before form a data race, and the reading thread may see stale values indefinitely. A classic failure: a boolean running flag polled in a loop without volatile — the JIT may hoist the read out of the loop and spin forever.
volatile gives visibility and ordering but not atomicity: volatile int x; x++ is still racy. Use atomics for that.
final fields have a special guarantee: if the object reference doesn't escape during construction, every thread sees the final fields' constructed values without synchronization. That is what makes immutable objects safe to share.
Double-checked locking for lazy singletons works only if the field is volatile; the simpler alternative is the holder-class idiom, which relies on the class-initialization guarantee.
Example
public class Visibility {
// Without volatile, the worker may never observe running = false.
private static volatile boolean running = true;
// Lazy, thread-safe singleton via class initialization (no locks needed)
static final class Config {
private Config() { System.out.println("loading config"); }
private static final class Holder { static final Config INSTANCE = new Config(); }
static Config get() { return Holder.INSTANCE; }
}
public static void main(String[] args) throws InterruptedException {
Thread worker = Thread.ofPlatform().start(() -> {
long spins = 0;
while (running) spins++; // volatile read each iteration
System.out.println("stopped after " + (spins > 0 ? "some" : "no") + " spins");
});
Thread.sleep(100);
running = false; // volatile write: happens-before the next read
worker.join(); // worker's actions happen-before join returns
System.out.println(Config.get() == Config.get()); // loading config, then true
}
}Remember
- Without a happens-before edge, another thread may see stale or reordered values
- Edges come from locks, volatile, thread start/join and java.util.concurrent classes
volatilegives visibility, not atomicity; final fields make safe immutable objects
Watch out for
- Polling a non-volatile flag from another thread, which can loop forever after JIT optimisation.
- Double-checked locking without
volatileon the instance field.