Mastery
Mastery/Java/E. Concurrency
T1 · high-leverage

synchronized and volatile: visibility vs. atomicity -- two different problems

volatile guarantees visibility (a write from one thread is immediately visible to others -- no thread caches a stale value) but does nothing for atomicity of compound operations like count++ (which is really read-modify-write, three separate steps). synchronized gives you both: mutual exclusion makes the compound operation atomic, and entering/exiting a monitor also establishes the same visibility guarantee.

(The volatile field is declared inside a small wrapper class below rather than as a loose top-level variable -- a JShell/notebook-specific detail: JShell's REPL-style top-level variables aren't backed by real class fields, so volatile on one of them doesn't actually get the real semantics. Once it's a genuine field of a genuine class, it behaves exactly as documented. The spin loop is also bounded so it can't hang forever even if something unexpected happens -- it should flip almost instantly.)

java
class Flags {
    volatile boolean ready = false;
}
Flags flags = new Flags();
int maxSpins = 200_000_000;

Thread reader = new Thread(() -> {
    int spins = 0;
    while (!flags.ready && spins < maxSpins) spins++;
    System.out.println("  reader saw ready=" + flags.ready + " after " + spins + " spins");
});
reader.start();
Thread.sleep(50);
flags.ready = true;   // without volatile, the reader could keep spinning on a cached stale value
reader.join();

But volatile alone does NOT make counter++ safe under concurrent writers -- watch real lost updates:

java
class Counters {
    volatile int volatileCounter = 0;
    int synchronizedCounter = 0;
    final Object lock = new Object();
}

Counters c = new Counters();
int nThreads = 8, iterations = 50_000;

Thread[] threads = new Thread[nThreads];
for (int i = 0; i < nThreads; i++) {
    threads[i] = new Thread(() -> {
        for (int j = 0; j < iterations; j++) c.volatileCounter++;
    });
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();

int expected = nThreads * iterations;
System.out.println("expected: " + expected + ", volatile counter actual: " + c.volatileCounter
    + (c.volatileCounter != expected ? "  <- LOST UPDATES, volatile did not help" : ""));
java
Thread[] threads2 = new Thread[nThreads];
for (int i = 0; i < nThreads; i++) {
    threads2[i] = new Thread(() -> {
        for (int j = 0; j < iterations; j++) {
            synchronized (c.lock) { c.synchronizedCounter++; }
        }
    });
}
for (Thread t : threads2) t.start();
for (Thread t : threads2) t.join();

System.out.println("expected: " + expected + ", synchronized counter actual: " + c.synchronizedCounter + "  <- correct");

Interview angle

One of the most reliable ways to separate candidates who've genuinely worked with concurrent Java from those who've only read about it: "does volatile make count++ thread-safe?" The correct, confident "no — it's three unsynchronized steps, and volatile only guarantees visibility, not atomicity" answer, with a real explanation of why, is a strong senior-level signal.

In the industry

Real production bugs from this distinction are usually silent and rare-looking — a counter that's very occasionally slightly wrong under load, not a crash — which makes them notoriously hard to root-cause without already knowing this exact rule. AtomicInteger/AtomicLong exist specifically to give you compound-operation atomicity without full lock overhead, and are the default choice over hand-rolled synchronized counters in performance-conscious concurrent code.