synchronized acquires an object's monitor (its intrinsic lock) for the duration of a region, guaranteeing that only one thread holds it at a time and that the holder's memory writes become visible to the next holder.
The monitor
Every Java object has a monitor managed by the JVM, with state kept in the object header's mark word. A monitor has an owning thread, a recursion count, an entry set (threads blocked trying to acquire it), and a wait set (threads that called wait()).
A synchronized block compiles to a pair of bytecodes:
public void m() {
synchronized (lock) { // -> monitorenter
doWork();
} // -> monitorexit (also on exceptional exit)
}
On monitorenter the thread tries to take ownership; if another thread owns it, the entrant goes BLOCKED and queues in the entry set. On monitorexit it decrements the recursion count, releasing the lock when it hits zero. The compiler emits an extra monitorexit in an implicit handler so the lock is released even if the body throws.
Two guarantees, not one
People remember the mutual exclusion and forget the memory effect — both come from the JMM:
- Mutual exclusion: at most one thread in the region per monitor.
- Happens-before: an unlock (
monitorexit) happens-before every subsequent lock (monitorenter) of the same monitor. So everything a thread wrote before releasing is visible to the next thread that acquires.
This is why synchronized fixes both atomicity and visibility, unlike volatile.
How the JVM optimizes it
Uncontended locking is cheap. HotSpot historically used biased locking (removed/disabled by default since JDK 15, JEP 374) and still uses thin/lightweight locks via a CAS on the mark word, inflating to a heavyweight OS monitor only under real contention. So the cost of synchronized on an uncontended path is close to a single atomic instruction.