How Synchronization and volatile Establish Happens-Before in Java
The problem: without synchronization, the Java Memory Model gives no guarantee that a write made by one thread will ever be visible to another. The JVM is free to cache values in CPU registers or reorder memory operations, so thread A’s writes may stay entirely local.
The solution: the JLS defines happens-before relationships — specific language constructs that force the JVM to make all preceding writes visible before allowing subsequent reads. Two of the most common: volatile fields and synchronized blocks.
This post walks through two small programs that demonstrate each mechanism in action.
Publishing data through volatile
Suppose thread A builds a result and needs to hand it off to thread B. Thread B waits for a flag, then reads the result.
Without any synchronization, the JLS does not guarantee that thread B sees both the flag and the published data — even if thread B eventually notices the flag, the associated value might still be stale.
With a volatile flag, the rule is precise (§17.4.5 of the JLS): a write to a volatile field happens-before every subsequent read of that same field. This isn’t just about the flag — all writes performed before the volatile write are also guaranteed visible to any thread that reads it.
public class PublishThroughVolatile {
private static volatile boolean ready = false;
private static String data = "";
public static void main(String[] args) throws Exception {
Thread producer = new Thread(() -> {
try { Thread.sleep(200); } catch (InterruptedException e) {}
System.out.println("[producer] preparing result...");
data = "Hello from the other thread"; // #1
ready = true; // #2 volatile write
System.out.println("[producer] published: ready=true, data='" + data + "'");
});
Thread consumer = new Thread(() -> {
while (!ready) { /* spin — must re-read main memory */ }
System.out.println("[consumer] saw ready=true, data='" + data + "'");
});
producer.start();
consumer.start();
producer.join();
consumer.join();
}
}
Two key lines: the volatile on line 5 makes ready a visibility barrier, and the JLS guarantees that every write before ready = true (line 12) is visible once another thread observes ready == true.
The consumer printed data='Hello from the other thread' — not an empty string. The volatile write on line 12 established a happens-before that flushed the producer’s earlier writes (line 11) into main memory, so the consumer saw both results atomically.
Atomic counters with synchronized
Volatile handles one-directional publishing elegantly, but it doesn’t help when multiple threads read-modify-write a shared variable. count++ is three operations: read, increment, write — and without synchronization they interleave, causing lost updates.
A synchronized block solves both problems (§17.1 of the JLS):
- Mutual exclusion — only one thread executes the critical section at a time
- Visibility — unlocking a monitor happens-before every subsequent lock on that same monitor, so all writes performed inside the synchronized block become visible to any thread that later acquires the same lock
public class AtomicCounter {
private static int count = 0;
private static final Object lock = new Object();
public static void main(String[] args) throws Exception {
int threads = 10;
int iterations = 50_000;
Thread[] workers = new Thread[threads];
for (int i = 0; i < threads; i++) {
final int id = i;
workers[i] = new Thread(() -> {
for (int j = 0; j < iterations; j++) {
synchronized (lock) { // acquires monitor
count++; // read-modify-write is safe
} // releases monitor
}
});
}
long start = System.nanoTime();
for (Thread t : workers) t.start();
for (Thread t : workers) t.join();
long ms = System.nanoTime() - start;
System.out.println("Expected: " + (threads * iterations));
System.out.println("Got: " + count);
System.out.printf("Time: %.1f ms%n", ms / 1_000_000.0);
}
}
The counter reached exactly 500,000 — the right answer with zero lost updates. Without synchronized, a typical run would show a value like 498,732 (lost updates from interleaved read-modify-write cycles). The unlock-then-lock sequence on line 17 flushes all writes to main memory and pulls in the latest state, establishing happens-before for every subsequent acquire.
Takeaway
Volatile gives you visibility of a single flag and everything published before it — perfect for one-way handoff. Synchronized gives you visibility plus mutual exclusion, making it the right tool when multiple threads mutate shared state together. Both rely on the same underlying mechanism (the JLS lock semantics) but serve different needs.