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:
| bits | state | what the rest of the mark word holds |
|---|---|---|
01 | unlocked | hash and age, as usual |
00 | lightweight locked | hash and age, untouched. the owner is on its thread's lock stack |
10 | inflated | a pointer to the ObjectMonitor. hash and age moved into it |
11 | marked | used 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):
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.
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.)
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:
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 Waitthat 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:
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);
}
}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:
$ 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.
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.
- 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).