skip to content
everything.jvm.lockingInternals

how locks work inside

lightweight locking, inflation, and what happened to biased locking.

chapter
chapter 13.10
reading time
6 min read

every java object can be a lock, and almost none of them ever are. so HotSpot makes the common case nearly free: an uncontended synchronized is lightweight locking, one CAS that flips two bits in the mark word, plus a push onto the thread's lock stack. only when threads actually fight over a lock (or someone calls wait()) does it inflate into a full ObjectMonitor with queues.

biased locking, the old trick that made single-thread locking even cheaper, was disabled in java 15 and deleted in 18.

the picture

you know how synchronized has a reputation for being slow, and then you measure it and it's a few nanoseconds? both are true. it depends entirely on whether anyone else wanted the lock.

think of a meeting room. if nobody else is around, you flip the sign on the door to "occupied" and walk in: that's lightweight locking, one flip of a sign. if someone shows up while you're inside, flipping signs isn't enough anymore: now you need a receptionist with a waiting list and a way to tap the next person on the shoulder when the room frees up. that's the monitor. the receptionist is expensive, so they only get hired when there's actually a queue, and they go home again when the room has been quiet for a while.

the lock bits

the last two bits of every object's mark word say how it's locked. on jdk 25:

bitsstatewhat the rest of the mark word holds
01unlockedhash and age, as usual
00lightweight lockedhash and age, untouched. the owner is on its thread's lock stack
10inflateda pointer to the ObjectMonitor. hash and age moved into it
11markedused by the GC, never seen by your code

these aren't from a diagram, they're read off a live object with Unsafe.getLong(lock, 0):

the last 8 bits of the mark word, jdk 25
unlocked:            ...00000001  (lock bits 01)
synchronized:        ...00000000  (lock bits 00)
nested synchronized: ...00000000  (lock bits 00)
released:            ...00000001  (lock bits 01)
other thread waits:  ...00000010  (lock bits 10)  mark=0x78a10c6a02
after notify+join:   ...00000010  (lock bits 10)
1.5 s later:         ...00000010  (lock bits 10)

one lock, two threads

step through every state a lock goes through on jdk 25, in order.

the object: lockunlocked
mark word
…0000 0001lock bits 01
thread A
running
lock stack
empty
thread B
running
lock stack
empty
ObjectMonitornot allocated
owner·entry queue·wait set·saved hdr·

nobody holds the lock. the mark word's last two bits are 01. (if the object had a hash, it would be in the bits above, untouched by everything that follows until inflation.)

step 1/8
step through it. watch the mark word: lightweight locking only ever touches the last two bits, inflation replaces the whole word with a pointer to an ObjectMonitor. the monitor address is the real one from the run above, where a wait() caused the inflation.

two details from that walk-through worth keeping. first, inflation is sticky: once a lock has a monitor, it keeps using it until the Monitor Deflation Thread (one of the 18 threads in an idle JVM) decides it's idle. the real object above was still inflated 1.5 seconds after everyone was done with it. second, wait() inflates on its own, contention or not, because only monitors have a wait set:

java -Xlog:monitorinflation=trace (excerpt)
inflate: object=0x...7018409410, mark=0x...c687aa02, type='java.io.PrintStream' cause=Monitor Enter
inflate: object=0x...7018422c50, mark=0x...c687aca2, type='java.lang.Object' cause=Monitor Wait
inflate: object=0x...7018479730, mark=0x...c687ad82, type='java.lang.Thread' cause=Monitor Wait

that last one is t.join(), which is implemented with wait() on the Thread object. and since monitors are what wait/notify use, calling them without owning the lock fails fast:

Main.java
void main() throws InterruptedException {
    Object lock = new Object();
    IO.println("holds lock? " + Thread.holdsLock(lock));
    synchronized (lock) {
        IO.println("holds lock? " + Thread.holdsLock(lock));
        lock.wait(10);   // fine: we own the monitor
    }
    try {
        lock.wait(10);   // not inside synchronized (lock)
    } catch (IllegalMonitorStateException e) {
        IO.println(e);
    }
}
output
holds lock? false
holds lock? true
java.lang.IllegalMonitorStateException: current thread is not owner

how we got here

HotSpot has rewritten its locking three times. the history explains a lot of advice you'll still find online.

hover a stage.

you can check both ends of that story on jdk 25 yourself:

terminal
$ java -XX:+UseBiasedLocking -version
Unrecognized VM option 'UseBiasedLocking'
Error: Could not create the Java Virtual Machine.
 
$ java -XX:LockingMode=1 -version
OpenJDK 64-Bit Server VM warning: Option LockingMode was deprecated in version 24.0 and will likely be removed in a future release.

and java -XX:+PrintFlagsFinal -version shows LockingMode = 2: lightweight.

quick check
a lock is only ever taken by one thread, millions of times. on jdk 25, what does each acquisition cost?
one level deeper:what a lightweight lock actually does+

locking: read the mark word; if the low bits are 01, CAS it to the same value with 00; on success, push the object on the current thread's lock stack (a small fixed-size array inside the thread). nested locking of the same object is detected by looking at the top of the lock stack, and just pushes again. unlocking pops, and if this was the last entry for the object, CASes the bits back to 01.

if the CAS fails (someone else holds it), or the lock stack is full, or the code needs wait(), the lock inflates: an ObjectMonitor is allocated (or, with compact headers, looked up in a side table), the original mark word is saved in it, and the header points at the monitor. contended acquisition then spins briefly (adaptive spinning: many locks are released within microseconds, so a short spin beats parking) before parking the thread in the monitor's entry queue.

keylines
  • uncontended synchronized = one CAS on the lock bits (01 → 00) and a push on the thread's lock stack.
  • contention or wait() inflates to an ObjectMonitor: lock bits 10, the header points at the monitor.
  • monitors deflate later, in the background, not when the lock is released.
  • biased locking: off in 15, gone in 18. lightweight locking: added in 21, default in 23.
  • on jdk 25, synchronized is fine for virtual threads too (JEP 491, java 24).