synchronized and volatile
Monitors, intrinsic locks, reentrancy, visibility versus atomicity, and the double-checked locking that was broken before Java 5.
Java's first two concurrency tools are keywords. synchronized gives you mutual exclusion and visibility together; volatile gives you visibility alone. Most concurrency bugs in application code are one of three shapes: using neither where one was needed, using volatile where synchronized was needed, or holding a lock while doing something slow. This lesson is about telling those apart, and about what the JVM actually does when a thread reaches a synchronized block, because the cost model explains every rule.
Monitors
Every Java object has a monitor — an intrinsic lock. synchronized acquires it:
class Counter {
private int count;
public synchronized void increment() { count++; } // locks `this`
public synchronized int get() { return count; }
}
class Registry {
private final Object lock = new Object();
private final Map<String, Item> items = new HashMap<>();
public void put(Item i) {
synchronized (lock) { items.put(i.id(), i); } // locks a private object
}
}While one thread holds the monitor, every other thread that tries to acquire it blocks until it is released. Release happens at the end of the block or method, on normal exit or exception. A synchronized method locks this (or the Class object, if static); a synchronized block locks whatever expression you give it.
Two properties follow from the happens-before rule: everything written inside a synchronized block is visible to the next thread that acquires the same monitor, and — because only one thread is inside at a time — compound operations inside it are atomic with respect to each other. count++ under a lock is safe.
Reentrancy. A thread that holds a monitor can acquire it again — a synchronised method calling another synchronised method on the same object does not deadlock itself. The monitor counts nesting and releases at zero.
Under the hood: what a monitor is
A synchronized block compiles to monitorenter and monitorexit bytecodes, with an exception-table entry that runs monitorexit on any throw, which is how the release on exception is guaranteed. A synchronized method has no bytecodes for it at all; a flag on the method tells the JVM to acquire and release around the call. What those instructions do depends on how contended the object is:
| State of the object's mark word | Acquire cost | When |
|---|---|---|
| Unlocked | one compare-and-swap that stores a pointer to a lock record on the thread's stack into the header (a thin or lightweight lock) | no other thread holds it: the common case, ~20 ns |
| Thin-locked by another thread | the CAS fails; the JVM inflates the lock into an ObjectMonitor | first contention on this object |
| Inflated | try the monitor's owner field; on failure spin briefly, then enqueue and park() in the OS | contended |
Inflation is the expensive transition: the monitor is a separate C++ structure with an owner, a recursion count, an entry list of threads waiting to acquire, and a wait set of threads in wait(). Once inflated, an object's lock stays inflated until the JVM deflates it during a later safepoint or asynchronously. Biased locking, which once made uncontended re-acquisition by the same thread free, was removed in Java 15 (JEP 374); the thin-lock CAS is now the floor, and Java 21+ moved the lock record off the object header into a per-thread lock stack (JEP 450, "Lightweight locking") so the header can hold a compressed class pointer.
The JIT removes locks it can prove pointless. Lock elision: an object that escape analysis shows never leaves the thread is never really locked, which is why StringBuffer in a local variable costs the same as StringBuilder. Lock coarsening: adjacent synchronized blocks on the same object are merged into one. Neither helps a lock that is genuinely shared.
Lock on a private object
synchronized (this) and synchronized methods expose your lock: any code with a reference to your object can synchronized (yourObject) and block your methods, or deadlock with them. A private final Object lock cannot be reached from outside. Never lock on a String literal (interned, shared JVM-wide), a boxed primitive (cached: Integer.valueOf(1) is one object for the whole JVM), or anything a caller can obtain. Value-based classes (Integer, Optional, records in future) may become unlockable entirely under Project Valhalla; javac already warns.
What to protect
The rule is every access to shared mutable state, reads included, under the same lock. A synchronised set with an unsynchronised get is a data race: the getter may see a stale value forever. Either both are synchronised, or the field is volatile and the operations are single reads and writes, or the object is immutable and there is nothing to protect.
Locking two things that must change together needs one lock covering both. Two locks, one per field, protect each field and not the invariant between them.
volatile
A volatile field is always read from and written to main memory (conceptually), and its reads and writes establish happens-before. On x86 that is a plain load for a read and a store plus a full fence for a write; the read side is nearly free, the write side is not. That is exactly enough for:
- A flag another thread polls:
volatile boolean running. - A reference to an immutable snapshot that one thread replaces and others read:
volatile Config current. Writers build a newConfig, assign it; readers get either the old or the new, never a mix. - A one-time initialisation check (the double-checked locking pattern, below).
And not enough for anything that reads a value and writes back something derived from it: counters, "check then act", "put if absent". Those need a lock or an atomic.
| Need | volatile | synchronized |
|---|---|---|
| Visibility of a single field | yes | yes |
| Atomic read-modify-write | no | yes |
| Protect an invariant across several fields | no | yes |
| Blocking | never | can block |
| Cost | a fence on write | CAS, plus parking under contention |
Double-checked locking
private volatile Expensive instance;
Expensive get() {
Expensive e = instance; // first check, no lock
if (e == null) {
synchronized (this) {
e = instance; // second check, under lock
if (e == null) instance = e = new Expensive();
}
}
return e;
}Without volatile, this was broken before Java 5 and remains subtly wrong in code that copies it without the keyword: a thread could see a non-null instance whose constructor writes were not yet visible, because the reference store and the field stores may be reordered. With volatile, the write of the reference happens-before any read that sees it, which carries the constructor's writes with it. The local e is there so the fast path reads the volatile once, not twice. For a singleton, the holder idiom is simpler — static class Holder { static final Expensive I = new Expensive(); } — because class initialisation is guaranteed thread-safe by the JVM (the initialisation lock from the JDK lesson). Or, in Spring, a bean.
Contention and the cost of holding a lock
An uncontended synchronized is cheap. The cost appears under contention: threads inflate the monitor, queue, get parked and unparked by the OS (microseconds each), and throughput collapses. What you do while holding the lock decides the contention:
synchronized (lock) {
Data d = fetchFromDatabase(id); // 20 ms under a lock: every other caller waits 20 ms
cache.put(id, d);
}Hold locks for nanoseconds, not milliseconds. Never do I/O, never call out to another service, never call unknown code (a listener, a callback) while holding a lock — the unknown code may try to take another lock, and now you have a deadlock. Compute outside, lock only to publish.
Walkthrough: the singleton that serialised the service
A @Service had public synchronized Report build(String id) "to be safe", and build ran a 20 ms query. Load test at 200 concurrent requests:
- Request 1 acquires the monitor and runs the query. Requests 2 to 200 arrive within a millisecond, fail the thin-lock CAS, inflate the monitor, and park.
- Throughput is one
buildper 20 ms: 50 requests per second, on a 32-core box, with CPU at 3%. - The 200th request waits for 199 others: 4 seconds of latency on a 20 ms operation. Tomcat's pool is full; the 201st request queues in the accept backlog.
- The thread dump shows one thread
RUNNABLEin the JDBC driver and 199BLOCKED (on object monitor)at the same line,- waiting to lock <0x...> (a com.acme.ReportService). That line, repeated, is the whole diagnosis. - Remove
synchronized: the method touched no shared mutable state. Throughput goes to whatever the database can do. When there is shared state, the fix is to compute the report outside the lock and lock for the microsecond it takes to store it.
Deadlock
Thread A holds lock 1 and wants lock 2; thread B holds 2 and wants 1. Neither proceeds, forever, and the JVM does not break it (it will report it in a thread dump). The cure is lock ordering: if you must hold two locks, every thread acquires them in the same global order. The diagnosing lesson walks through one.
Try it yourself
Safe or not?
For each, say whether the class is thread-safe and why:
class A { private int n; synchronized void inc() { n++; } int get() { return n; } }
class B { private volatile int n; void inc() { n++; } int get() { return n; } }
class C { private final Map<String,String> m = new HashMap<>(); synchronized void put(String k, String v) { m.put(k, v); } synchronized String get(String k) { return m.get(k); } }
class D { private volatile Map<String,String> m = Map.of(); void replace(Map<String,String> fresh) { m = Map.copyOf(fresh); } String get(String k) { return m.get(k); } }Answer
A: no. get() reads without the lock, so it may see a stale n indefinitely; make it synchronized too. B: no. n++ is three operations; volatile makes each visible and does nothing about the interleaving; use AtomicInteger. C: yes. Every access is under the same lock, and the map never escapes. D: yes. The map is immutable, the reference is volatile, and replace swaps a whole snapshot; readers see the old or the new map, never a half-state. D is also the fastest of the four.
Why does it deadlock?
class Ledger {
synchronized void transfer(Ledger to, long amt) { debit(amt); to.credit(amt); }
synchronized void credit(long amt) { balance += amt; }
}Two threads run x.transfer(y, 5) and y.transfer(x, 5) at the same time. What happens?
Answer
Thread 1 holds x's monitor (inside x.transfer) and calls y.credit, which needs y's monitor. Thread 2 holds y's and needs x's. Each waits for the other forever; a thread dump prints Found one Java-level deadlock. Fix: lock both in a fixed order before touching either (synchronized on the lower System.identityHashCode, or an id), or make credit an atomic operation that needs no lock while transfer holds one, or use tryLock with a timeout from the next lesson.
The cost of the write
A metrics class increments a volatile long hits on every request, a million times a second across 16 threads. Why is it slow, and what fixes it?
Answer
Two reasons. hits++ on a volatile is not atomic, so the count is wrong as well as slow. And each volatile write is a full fence that drains the store buffer, on a cache line all 16 cores are fighting over, so every increment stalls its core for tens of nanoseconds and bounces the line between caches. LongAdder (next-but-one lesson) gives each thread its own cell and sums on read; it is both correct and an order of magnitude faster.
Misconceptions
- "
synchronizedis slow." Uncontended, it is one CAS. It is contention that is slow, and contention comes from holding the lock too long, not from the keyword. - "
volatileis a lightweightsynchronized." It gives visibility of one variable and no mutual exclusion. Avolatilecounter is a bug, not an optimisation. - "Locking on
thisis the normal way." It publishes your lock to every caller. A private lock object costs nothing and cannot be taken from outside. - "Only writes need the lock." A read outside the lock is a data race; it may return a stale value forever. Every access, reads included.
- "The JVM will notice a deadlock and recover." It detects and reports one in a thread dump. Nothing recovers; the threads stay blocked until the process restarts.
Going deeper
- JLS §17.1 (synchronization) and §8.3.1.4 (
volatilefields); JVMS §2.11.10 formonitorenter/monitorexit. - JEP 374 (deprecate and remove biased locking) and JEP 450 (compact object headers / lightweight locking) for how the lock lives in the header today.
- Aleksey Shipilëv, "JVM Anatomy Quark #19: Lock elision" and "#22: Safepoint polls".
- Java Concurrency in Practice, chapters 2 and 3, which this lesson condenses.
jcmd <pid> Thread.printon a contended service: count theBLOCKED (on object monitor)lines and the address they share.