The idea: a lock is two bits in the object header

Any Java object can be a lock: synchronized (lock) { ... } compiles to monitorenter and monitorexit. HotSpot does not give every object a full mutex. Instead, the first 64 bits of every object, the mark word, end in a 2-bit lock tag. The tag says which of three states the lock is in, and the JVM moves between them only when it has to.

Three rows of the 64-bit mark word. Unlocked: unused 25 bits, hash 31, age 4, lock tag 01. Fast-locked: the same fields, lock tag 00. Inflated: a 62-bit pointer to an ObjectMonitor, lock tag 10
Fast locking only changes the tag; inflation replaces the header with a pointer to an ObjectMonitor.
TagStateWho knows the owner
01unlockednobody holds it
00fast-locked (thin, lightweight)the owner's lock stack
10inflatedthe ObjectMonitor's _owner field

Thin locking: one CAS

To enter, a thread does one compare-and-swap on the mark word: if the tag is still 01, make it 00. If that works, it pushes the object on its own lock stack, a small per-thread array (8 entries). To exit, it does the reverse CAS and pops. No system call, no extra memory: an uncontended synchronized costs two CAS instructions, a few nanoseconds each. (Demo 1: no contention, two CAS.) See Compare-and-Swap for how a CAS works.

A thread that enters a lock it already holds sees the object on top of its own lock stack, so it just pushes it again. The header is not touched. (Demo 2: recursion on the lock stack.)

Inflation: when a second thread has to wait

If T2's CAS fails because T1 holds the lock, T2 spins for a short while. If T1 is still inside, T2 inflates the lock: the JVM allocates an ObjectMonitor in native memory, records T1 as _owner (and T1's extra lock stack entries as _recursions), and a CAS puts the monitor's address in the mark word with tag 10. T2 spins on the monitor a little more, then adds itself to the entry queue and parks. When T1 exits it must unpark T2, which then takes _owner. (Demo 3: contention inflates, T2 parks; Demo 5: T2 contends with a recursive owner.)

State diagram. 01 unlocked to 00 fast-locked by CAS and back by CAS; 00 loops to itself on re-enter; 00 to 10 inflated on contention or wait(); 10 back to 01 by deflation when idle. Under 00: 1 CAS to enter, 1 to exit, no OS call. Under 10: park and unpark are futex syscalls, a context switch per wait
Most locks stay on the cheap 01 ↔ 00 edge; contention moves them to 10 until deflation.

Park and unpark are futex system calls on Linux (see How a Mutex Works: the Futex), and each wait costs two context switches: microseconds instead of nanoseconds. The spinning before parking is a bet that the owner will exit soon; Mutex vs Spinlock shows when that bet pays.

Deflation

Exiting an inflated lock leaves the monitor in place: tag 10, _owner none. The MonitorDeflationThread runs in the background and deflates idle monitors (no owner, no thread queued or waiting): it restores a plain mark word and frees the monitor. After that, the next enter is a thin lock again. (Demo 4: idle monitor deflated, thin again.)

What synchronized costs

SituationWorkRough cost
no contentionCAS to enter, CAS to exitnanoseconds
re-enterpush / pop, or _recursions ± 1almost nothing
short contentionspin until the owner exitsCPU time while spinning
real contentioninflate once, then park and unpark per waitmicroseconds per hand-over

So synchronized is cheap when threads rarely meet, and the cost of a busy lock is the waiting, not the lock instruction. Inflation also matters for virtual threads: before JDK 24 a virtual thread that blocked inside synchronized pinned its carrier (see Java Virtual Threads vs Platform Threads). What a lock guarantees about visibility is on Java Memory Model, and the entry queue and wait set are drawn on Monitor & Condition Variables.

Biased locking and stack locking

Older JVMs had two other schemes. Biased locking let the first thread "own" an object with no CAS at all, but revoking the bias needed a safepoint; it was disabled in JDK 15 (JEP 374) and removed in JDK 18. Stack locking (the legacy mode until JDK 22) copied the mark word into a lock record on the owner's stack and CAS-ed a pointer to it into the header, so the header was displaced while locked. The lightweight locking shown here (default since JDK 23, -XX:LockingMode=2) keeps the header in place and records ownership on the per-thread lock stack instead.

What the page leaves out

Object.wait(), notify() and the wait set (calling wait() always inflates); identity hash codes; the "anonymous owner" the real inflater sets until the holder claims the monitor; the separate _cxq and _EntryList queues and successor hand-off; adaptive spin counts; lock stack overflow past 8 entries; compact object headers (JEP 450) and the side table they use for monitors; and JIT tricks such as lock elision after escape analysis and lock coarsening.