synchronized and volatile

Monitors, intrinsic locks, reentrancy, visibility versus atomicity, and the double-checked locking that was broken before Java 5.

12 min read🧵 Java Concurrency

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:

java
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 wordAcquire costWhen
Unlockedone 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 threadthe CAS fails; the JVM inflates the lock into an ObjectMonitorfirst contention on this object
Inflatedtry the monitor's owner field; on failure spin briefly, then enqueue and park() in the OScontended

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.

unlockedheader: hash, age thin lockone CAS, on the stack inflated monitor owner · recursionsentry list: waitingwait set: wait()park / unpark (OS) acquirecontended deflate, once nobody is waiting
Uncontended locking is one CAS. The first contention inflates; from then on, waiting means parking in the operating system.

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 new Config, 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.

Needvolatilesynchronized
Visibility of a single fieldyesyes
Atomic read-modify-writenoyes
Protect an invariant across several fieldsnoyes
Blockingnevercan block
Costa fence on writeCAS, plus parking under contention

Double-checked locking

java
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:

java
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:

  1. 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.
  2. Throughput is one build per 20 ms: 50 requests per second, on a 32-core box, with CPU at 3%.
  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.
  4. The thread dump shows one thread RUNNABLE in the JDBC driver and 199 BLOCKED (on object monitor) at the same line, - waiting to lock <0x...> (a com.acme.ReportService). That line, repeated, is the whole diagnosis.
  5. 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:

java
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?

java
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

  • "synchronized is 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.
  • "volatile is a lightweight synchronized." It gives visibility of one variable and no mutual exclusion. A volatile counter is a bug, not an optimisation.
  • "Locking on this is 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 (volatile fields); JVMS §2.11.10 for monitorenter/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.print on a contended service: count the BLOCKED (on object monitor) lines and the address they share.
Progress is saved on this device and to your account when signed in.