Skip to content
Path to Engineer
All lessons
JavaConcurrency16 min read

What volatile does not do

synchronized, intrinsic locks and volatile — and the guarantee volatile gives you, which is narrower than almost everyone assumes.

Day 66 of the 488-day pathway. Published in full — nothing held back.

synchronized, intrinsic locks, volatile — and what volatile does not do

Day 065 was the specification. Today is the implementation: what the JVM actually emits, what a monitor costs, and how to choose a lock's scope — which is where most real concurrency bugs live, not in the keyword itself.


Part 1 — L-066 · synchronized

Every object is a lock

Java's oldest concurrency primitive is built into every object header. The mark word (Day 025) holds, among other things, the lock state. This is called an intrinsic lock or monitor.

synchronized (someObject) { ... }        // lock the monitor of someObject

synchronized void m() { ... }            // equivalent to synchronized (this) { ... }

static synchronized void s() { ... }     // equivalent to synchronized (MyClass.class) { ... }

Those last two equivalences matter more than they look. A synchronized instance method locks this, which is a public object — anyone holding a reference to your object can synchronized on it and interfere with your locking. And a static synchronized method locks the Class object, which is even more public. That is the argument for a private lock:

public class Account {
    private final Object lock = new Object();      // ✅ nobody else can acquire it
    private long balance;

    public void deposit(long amount) {
        synchronized (lock) { balance += amount; }
    }
}

What the bytecode shows

Run javap -c -p (Day 024) on a synchronized block and you see the two instructions:

   monitorenter
   ...body...
   monitorexit
   ...
 Exception table:
   any  →  monitorexit ; athrow          ← the compiler generates the unlock-on-throw path

The lock is always released on exception. That is why you never write a finally around a synchronized block — the compiler wrote it for you. (Contrast ReentrantLock tomorrow, where you must write it yourself.)

For a synchronized method there are no monitorenter/monitorexit instructions at all; instead the method has the ACC_SYNCHRONIZED flag and the JVM does the locking on invocation. Same semantics, different mechanism — a nice detail to know when someone asks how synchronized is implemented.

Reentrancy

synchronized void outer() { inner(); }        // holds the lock
synchronized void inner() { }                 // acquires it AGAIN — fine

Intrinsic locks are reentrant: the monitor records the owning thread and a hold count. A thread that already owns the lock increments the count instead of blocking; the lock is released only when the count returns to zero.

Without reentrancy, any synchronized method calling another synchronized method on the same object would deadlock with itself — and, worse, so would every subclass calling super. Reentrancy is not a convenience, it is a requirement for locks to compose with inheritance.

What a lock actually costs

The naive picture — "a lock is a syscall, locks are slow" — has been wrong for twenty years. The JVM optimises hard:

Optimisation When Cost
Biased locking one thread ever locks it ~0 — removed in JDK 15+ (JEP 374)
Thin / lightweight lock low contention a CAS on the mark word, ~20 ns
Fat / heavyweight lock actual contention OS monitor, park/unpark, ~µs
Lock elision JIT proves the object never escapes removed entirely
Lock coarsening adjacent blocks on the same lock merged into one

Two consequences you can state in an interview:

An uncontended lock is cheap — one CAS. So "I removed synchronization for performance" is usually premature; the cost that matters is contention, not locking.

Lock elision is real. This is why StringBuffer inside a method performs like StringBuilder (Day 029): escape analysis proves the buffer never leaves the method, so the JIT deletes the locks. Do not rely on it, but know it exists — it explains benchmark results that otherwise look wrong.

Lock scope — where the real bugs are

The keyword is easy. Choosing what it protects is not.

// 💀 Too narrow: each call is atomic, the sequence is not
synchronized boolean contains(K k) { return map.containsKey(k); }
synchronized void put(K k, V v)    { map.put(k, v); }
// caller:
if (!cache.contains(k)) cache.put(k, compute(k));      // ← RACE between the two calls

// 💀 Too wide: holds the lock across I/O
synchronized void handle(Request r) {
    Response resp = httpClient.call(r);                // 2 seconds, lock held
    state.update(resp);
}

// ✅ Right: the lock covers the invariant, and nothing slow
void handle(Request r) {
    Response resp = httpClient.call(r);                // outside
    synchronized (lock) { state.update(resp); }        // inside: fast, and complete
}

Two rules:

The lock must cover the whole invariant, not each individual operation. Never hold a lock across I/O, a network call, or a callback into unknown code.

The second is how thread pools die: 200 threads all blocked on one monitor held by a thread waiting on a slow HTTP call. The thread dump shows 199 BLOCKED and one RUNNABLE, which is exactly the signature to recognise (Day 076).

Calling unknown code — a listener, a callback, an overridden method — while holding a lock is called an alien method call, and it is a deadlock waiting to happen, because that code may acquire locks you know nothing about in an order you cannot control (Day 071).

Client-side locking and the compound-operation trap

Day 053 made this point for ConcurrentHashMap; it generalises. A thread-safe object guarantees each method is atomic. It guarantees nothing about sequences.

List<String> list = Collections.synchronizedList(new ArrayList<>());

// 💀 check-then-act across two atomic calls
if (!list.contains(x)) list.add(x);

// ✅ lock the same monitor the list uses
synchronized (list) {
    if (!list.contains(x)) list.add(x);
}

// ✅ also required for iteration
synchronized (list) { for (String s : list) use(s); }

The iteration case catches people out: without the external lock you get ConcurrentModificationException (Day 052's modCount) despite the list being "synchronized".


Part 2 — volatile, precisely

What it is

volatile marks a field as one whose reads and writes must go to main memory, and inserts memory barriers (fences) around the access so neither the compiler nor the CPU may reorder across it.

Concretely, on the JIT level:

volatile write:   [StoreStore]  write  [StoreLoad]   ← StoreLoad is the expensive one
volatile read:    read  [LoadLoad] [LoadStore]

The StoreLoad barrier after a volatile write (on x86, a lock addl $0,(%rsp) or mfence) costs roughly 20–100 ns because it drains the store buffer. A volatile read, by contrast, is nearly free on x86 — the barriers are no-ops there; only the compiler restrictions remain. So a volatile field read in a hot loop is cheap; a volatile field written in a hot loop is not.

The three legitimate uses

1. A status / shutdown flag — one writer, many readers, no compound operation.

private volatile boolean shutdown;
public void stop()  { shutdown = true; }
public void run()   { while (!shutdown) { doWork(); } }

2. Safe publication of an immutable object — the piggyback pattern from Day 065.

private volatile Config current;                       // Config is immutable
public void reload(Config c) { current = c; }          // publishes everything inside c
public Config get()          { return current; }

This is a genuinely elegant pattern: readers are lock-free, the writer is atomic because reference assignment is atomic, and the final-field rule plus the volatile write guarantee full visibility. Use it for hot-reloadable configuration.

3. The double-checked locking field (Day 065) — and only because there is no better option in that specific shape.

The three things it does not do

It does not make compound operations atomic. count++, if (x == null) x = ..., balance -= amount — all broken (Day 065's exit-gate item).

It does not protect the object it points to. volatile List<String> list makes the reference volatile. list.add(x) is completely unsynchronized. This is a frequent misunderstanding — the keyword applies to the field, not to what it references.

It does not make an array's elements volatile. volatile int[] arr makes arr volatile; arr[0] is a plain access. For volatile element semantics you need AtomicIntegerArray or a VarHandle.

Long and double — the 64-bit tearing rule

long x;                 // reads/writes are NOT guaranteed atomic on a 32-bit JVM
volatile long y;        // guaranteed atomic

The JLS permits a 64-bit non-volatile read or write to be split into two 32-bit halves, so another thread can observe a value that was never written — the high half of one write with the low half of another. This is called word tearing. Every 64-bit JVM in practice makes them atomic anyway, but the specification does not require it, and volatile does. If you have a shared non-atomic long counter, this is a second reason it is broken.

Code to type — the decision made concrete

public class SyncVsVolatile {

    static class Unsafe    { int c; void inc() { c++; } }
    static class Volatile  { volatile int c; void inc() { c++; } }
    static class Synced    { int c; synchronized void inc() { c++; } }
    static class Atomic    { java.util.concurrent.atomic.AtomicInteger c =
                                new java.util.concurrent.atomic.AtomicInteger();
                             void inc() { c.incrementAndGet(); } }

    static final int THREADS = 4, PER_THREAD = 500_000;

    public static void main(String[] args) throws Exception {
        run("unsafe   ", new Unsafe()::inc,   () -> 0);
        // …run each and print both the count and the elapsed time
        var u = new Unsafe();    time("plain    ", u::inc,   () -> u.c);
        var v = new Volatile();  time("volatile ", v::inc,   () -> v.c);
        var s = new Synced();    time("synced   ", s::inc,   () -> s.c);
        var a = new Atomic();    time("atomic   ", a::inc,   () -> a.c.get());
    }

    static void time(String name, Runnable inc, java.util.function.IntSupplier read)
            throws Exception {
        Thread[] ts = new Thread[THREADS];
        long t0 = System.nanoTime();
        for (int i = 0; i < THREADS; i++) {
            ts[i] = new Thread(() -> { for (int j = 0; j < PER_THREAD; j++) inc.run(); });
            ts[i].start();
        }
        for (Thread t : ts) t.join();
        long ms = (System.nanoTime() - t0) / 1_000_000;
        int expected = THREADS * PER_THREAD;
        System.out.printf("%s count=%,9d  expected=%,9d  %s  %4d ms%n",
                name, read.getAsInt(), expected,
                read.getAsInt() == expected ? "OK " : "BAD", ms);
    }

    static void run(String n, Runnable r, java.util.function.IntSupplier s) {}
}

Expected shape of the output:

plain     count=  731,204  expected=2,000,000  BAD    12 ms
volatile  count= 1,182,940 expected=2,000,000  BAD    91 ms
synced    count=2,000,000  expected=2,000,000  OK    145 ms
atomic    count=2,000,000  expected=2,000,000  OK     78 ms

Read that table carefully — it contains the whole day.

volatile is both wrong and slower than plain. You paid for the barriers and got no correctness. That combination is why "just make it volatile" is such a damaging habit: it looks like a fix, it costs performance, and it leaves the bug in place while making it rarer and therefore harder to find.

Atomic beats synchronized at this contention level, because CAS avoids parking threads. At very high contention the ranking can flip toward LongAdder — Day 068 measures it.


Common mistakes

Mistake Consequence
volatile on a counter Wrong and slow
synchronized on a mutable field reference The lock object changes; no exclusion at all
synchronized (this) in a public class Callers can lock you out
Locking a String literal or a boxed Integer Interned/cached — you are sharing a lock globally
Lock scope too narrow Compound operations race
Lock held across I/O Thread pool starves
Alien method call under a lock Unbounded lock hold and deadlock risk
volatile on a collection reference Only the reference is protected
Double-checked locking without volatile Half-built object escapes

The boxed-Integer row deserves a line of its own: synchronized (Integer.valueOf(1)) locks a JVM-wide cached object (Day 028), so two unrelated classes can accidentally share a lock. Same for synchronized ("lock"). Always lock a private final Object.


Interview questions

Q: What does synchronized do? Acquires an object's intrinsic monitor for mutual exclusion, and establishes happens-before — release happens-before the next acquire of the same monitor — so it fixes atomicity and visibility.

Q: Is it reentrant, and why does that matter? Yes, via an owner and hold count. Without it, a synchronized method calling another on the same object — including super calls — would self-deadlock.

Q: synchronized vs volatile? Volatile: visibility and ordering for a single field, no blocking, no atomicity for compound operations. Synchronized: mutual exclusion over a region plus the same visibility guarantees. Use volatile for a flag or a published immutable reference; use a lock or an atomic for anything read-modify-write.

Q: Is locking expensive? Uncontended, no — one CAS on the mark word, and the JIT may elide or coarsen it. Contended, yes — threads park and the OS gets involved. Optimise contention, not the keyword.

Q: What is wrong with synchronized (this)? this is public, so external code can acquire your lock and affect your class's liveness. Prefer a private final lock object.

Q: Why must a lock object be final? If the field can be reassigned, two threads may synchronize on different objects and get no mutual exclusion whatsoever — silently.


Mini task

  1. Run SyncVsVolatile. Record all four counts and timings. Confirm volatile is wrong and slower.
  2. javap -c a synchronized block and a synchronized method. Find monitorenter/monitorexit in one and ACC_SYNCHRONIZED in the other, plus the generated exception-table unlock.
  3. Write a class with a non-final lock field. Reassign it from one thread mid-run and demonstrate that mutual exclusion disappears.
  4. Write the "too wide" version that holds a lock across a 500 ms sleep, hit it with 50 threads, and take a thread dump. Count the BLOCKED threads.
  5. Take a Collections.synchronizedList, iterate it from one thread while another adds, and get the ConcurrentModificationException. Then fix it with client-side locking.

Exit questions

  1. What are the two bytecode instructions for a synchronized block, and what does the compiler add?
  2. How is a synchronized method implemented differently?
  3. What does a synchronized instance method lock? A static one?
  4. Why must intrinsic locks be reentrant?
  5. Name three JIT lock optimisations and what each requires.
  6. State the two rules of lock scope.
  7. What is an alien method call, and why is it dangerous under a lock?
  8. Why is volatile on a counter worse than doing nothing?
  9. What does volatile on a collection reference actually protect?
  10. What is word tearing, and which types does it affect?
  11. Why is synchronized ("lock") a bug?
  12. Give the three legitimate uses of volatile.

Articulation drill

Two minutes: "When would you use volatile instead of synchronized?"

Lead with the decision rule — single field, no compound operation, one writer — then the three legitimate uses, then the counter as the counterexample. Close on cost: a volatile read is nearly free, a volatile write is a barrier, an uncontended lock is one CAS. Numbers make it credible.


Previous: Day 065 · Next: Day 066A — ➕ ThreadLocal

An added day next: the third way to make code thread-safe — don't share the data at all. It is how Spring knows who the current user is, and it is also the source of a memory leak that only appears under a thread pool.