What volatile does not do
synchronized, intrinsic locks and volatile — and the guarantee volatile gives you, which is narrower than almost everyone assumes.
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
- Run
SyncVsVolatile. Record all four counts and timings. Confirm volatile is wrong and slower. javap -ca synchronized block and a synchronized method. Findmonitorenter/monitorexitin one andACC_SYNCHRONIZEDin the other, plus the generated exception-table unlock.- Write a class with a non-final lock field. Reassign it from one thread mid-run and demonstrate that mutual exclusion disappears.
- 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
BLOCKEDthreads. - Take a
Collections.synchronizedList, iterate it from one thread while another adds, and get theConcurrentModificationException. Then fix it with client-side locking.
Exit questions
- What are the two bytecode instructions for a synchronized block, and what does the compiler add?
- How is a synchronized method implemented differently?
- What does a synchronized instance method lock? A static one?
- Why must intrinsic locks be reentrant?
- Name three JIT lock optimisations and what each requires.
- State the two rules of lock scope.
- What is an alien method call, and why is it dangerous under a lock?
- Why is
volatileon a counter worse than doing nothing? - What does
volatileon a collection reference actually protect? - What is word tearing, and which types does it affect?
- Why is
synchronized ("lock")a bug? - 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.