Revisiting synchronized lock upgrade: JVM‑level details of biased, lightweight and heavyweight locks
This article explains how the JVM implements synchronized by using the object header's Mark Word to transition through biased, lightweight, and heavyweight lock states, covering the underlying data structures, lock acquisition and release processes, revocation, downgrade, tuning parameters, practical verification, performance pitfalls, and a comparison with ReentrantLock.
Object Header and Mark Word
The synchronized implementation relies on the Mark Word stored in the object header. On a 64‑bit JVM with compressed oops the header occupies 12 bytes and contains fields such as lock:25, biased_lock:1, age:4 and identity_hashcode:31. The bit layout varies with lock state (no‑lock, biased, lightweight, heavyweight, GC mark).
Biased Lock
Principle
The JVM assumes that the same thread repeatedly acquires the lock. On the first acquisition it performs a CAS that replaces the threadId in the Mark Word with the current thread’s ID, entering biased mode.
// HotSpot source (biasedlocking.cpp)
if (Atomic::cmpxchg(mark_word, unlocked_value, biased_value) == unlocked_value) {
// CAS succeeded, set threadId, enter biased mode
return;
}Subsequent acquisitions merely compare the stored threadId; no CAS or system call is needed, making the operation extremely fast.
Revocation
When another thread attempts to acquire a biased lock, the JVM pauses the owning thread at a safepoint, checks whether it is still inside the synchronized block, and either re‑biases to the new thread (if the owner has exited) or upgrades to a lightweight lock.
// Revocation steps
1. Safepoint the owning thread
2. Check if it is still running inside the synchronized block
3. If exited: CAS threadId to the new thread (rebias)
4. If still running: upgrade to lightweight lock (create Lock Record on stack, new thread spins)Since JDK 6, bulk revocation traverses all biased objects at a safepoint to reduce overhead. Delayed biased‑lock activation is controlled by -XX:BiasedLockingStartupDelay=4000.
Lightweight Lock
Acquisition
At the entry of a synchronized block the thread creates a Lock Record on its stack, copies the current Mark Word into the record, and attempts a CAS to replace the object’s Mark Word with a pointer to the Lock Record.
1. Create Lock Record on stack
2. Copy Mark Word to Lock Record's Displaced Mark Word
3. CAS object header to point to Lock RecordIf the CAS succeeds, the thread holds a lightweight lock; otherwise contention is detected and the lock inflates.
Release
// Unlock
1. CAS Mark Word back to Displaced Mark Word
2. If CAS fails, inflate to heavyweight lockWhen CAS fails, the thread spins a configurable number of times (default -XX:PreBlockSpin=10, adaptive after JDK 6) before inflating.
Heavyweight Lock
ObjectMonitor Structure
class ObjectMonitor : public CHeapObj<mtInternal> {
volatile int _header; // lock state + thread ID
void* _object; // associated Java object
oop _owner; // owning thread
int _EntryList; // blocked threads list
int _WaitSet; // wait() threads count
ObjectWaiter* _WaitSetList; // wait queue
// ...
};Inflation Process
// Lightweight CAS failure → inflate → create ObjectMonitor → Mark Word points to it
[age] [ptr→Lock Record] ↓ inflate → [age] [ptr→ObjectMonitor]The inflation code creates an ObjectMonitor, attempts a CAS to install it, and then uses TryLock (CAS on _owner) to acquire the lock. Blocking uses os::ParkEvent (Linux futex or Windows WaitForSingleObject).
Lock Acquisition (enter)
void ObjectMonitor::enter(Thread* self) {
if (TryLock(self)) return; // fast path
// enqueue into _EntryList and park
self->_EntryList = _EntryList;
_EntryList = self;
for (;;) {
if (TryLock(self)) break;
self->set_suspend_equivalent();
park(false, timeout);
}
// dequeue
_EntryList = self->_EntryList;
self->_EntryList = NULL;
}Lock Release (exit)
void ObjectMonitor::exit(Thread* self) {
if (_owner != self) return; // not the owner
// wake one waiting thread if any
if (_EntryList != NULL) {
ObjectWaiter* w = _EntryList;
_EntryList = w->_next;
unpark(w->_thread);
}
_owner = NULL;
}wait/notify Mechanism
// wait(): move from EntryList to WaitSet, release lock, park, then re‑enter
void ObjectMonitor::wait(Thread* self, jlong millis) {
ObjectWaiter node(self);
self->_WaitSetList = _WaitSetList;
_WaitSetList = &node;
_owner = NULL; // release
self->set_suspend_equivalent();
park(true, millis);
// after notify, re‑enter EntryList and try to reacquire
self->_EntryList = _EntryList;
_EntryList = self;
self->_WaitSetList = NULL;
for (;;) {
if (TryLock(self)) break;
park(false, 0);
}
}
// notify(): move one waiter back to EntryList and unpark
void ObjectMonitor::notify(Thread* self) {
ObjectWaiter* iter = _WaitSetList;
if (iter != NULL) {
_WaitSetList = iter->_next;
iter->_thread = NULL;
iter->_next = _EntryList;
_EntryList = iter;
unpark(iter->_thread);
}
}Lock Downgrade
When a heavyweight lock is released and there is no contention ( _EntryList and _WaitSet empty), the JVM can downgrade step‑by‑step: heavyweight → lightweight → unbiased (or biased after a later acquisition). Downgrade never skips intermediate states.
Upgrade Path Diagram
┌─────────────────────────────────────────────────────────────────┐
│ [no‑lock] ─CAS→ [biased] ─competition→ [lightweight] │
│ ↑ │ │ │
│ │ │ revoke → biased│ spin fail → heavyweight │
│ │ ↓ (rebias) ↓ │
│ │ [biased (new thread)] [heavyweight] │
│ └───────────────────────────────────────────────────────┘
│ (downgrade: heavyweight→lightweight→no‑lock→biased)
└─────────────────────────────────────────────────────────────────┘JVM Tuning Parameters
Biased lock: -XX:+UseBiasedLocking, -XX:BiasedLockingStartupDelay=4000, bulk revocation thresholds ( -XX:BiasedLockingBulkRebiasThreshold, -XX:BiasedLockingBulkRevokeThreshold).
Lightweight/spin: -XX:PreBlockSpin=10, adaptive spin controlled by -XX:+UseAdaptiveSizePolicy.
Heavyweight: object alignment -XX:ObjectAlignmentInBytes=16, view lock state with jcmd <pid> Thread.print or JFR.
Practical Verification
Use jcmd <pid> Thread.print or Java Flight Recorder ( -XX:+FlightRecorder) to observe lock events.
Demo code below shows a thread acquiring a biased lock, a second thread causing revocation and upgrade, and the corresponding JFR events ( biased_lock, biased_lock_revoke, fast_enter).
public class LockEscalationDemo {
private static final Object lock = new Object();
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> {
synchronized (lock) {
System.out.println("t1 got lock");
try { Thread.sleep(2000); } catch (Exception e) {}
}
});
Thread t2 = new Thread(() -> {
synchronized (lock) {
System.out.println("t2 got lock");
}
});
t1.start();
Thread.sleep(100); // t1 acquires biased lock first
t2.start();
t1.join();
t2.join();
}
}JFR records show:
t1 acquisition: biased_lock event.
t2 acquisition: biased_lock_revoke followed by fast_enter (lightweight).
Performance Bottlenecks and Optimizations
Avoid long‑running synchronized blocks (e.g., I/O or Thread.sleep) that trigger upgrades.
Reduce lock scope; perform expensive work outside the synchronized region.
Lock coarsening: the JVM may merge adjacent synchronized blocks.
Minimize contention with finer‑grained locks or segment locks (e.g., an array of lock objects).
Disable biased locking in high‑contention scenarios with -XX:-UseBiasedLocking.
Comparison with ReentrantLock
Underlying implementation : synchronized uses ObjectMonitor (JVM C++); ReentrantLock uses AQS (Java).
Lock upgrade : synchronized automatically upgrades biased → lightweight → heavyweight; ReentrantLock has no upgrade mechanism.
Interruptibility : synchronized is not interruptible; ReentrantLock supports lockInterruptibly().
Fairness : synchronized is non‑fair (JVM internal); ReentrantLock can be configured fair or non‑fair.
Condition variables : synchronized provides a single wait/notify pair; ReentrantLock supports multiple Condition objects.
Low‑contention performance : biased lock makes synchronized extremely fast; ReentrantLock always enqueues in AQS.
High‑contention performance : synchronized incurs monitor park overhead; ReentrantLock uses AQS spin + park.
Granularity : synchronized locks at object level; ReentrantLock locks at block (code) level.
Why the JVM Designed It This Way
Biased lock covers the majority of single‑threaded lock operations (≈60‑70 %); one CAS and one compare give near‑zero cost.
Lightweight lock handles low‑contention multithreaded cases; spinning avoids a thread‑switch, and only on CAS failure does the thread park.
Heavyweight lock is the fallback for high contention, using OS mutexes to guarantee fairness at the expense of kernel‑mode switches.
The overall strategy is progressive optimization: start with the cheapest lock and fall back to more robust mechanisms only when runtime data demands it. Writing code that minimizes contention allows the JVM to stay in low‑cost lock states.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Programmer1970
Formerly called 'Code to 35'. Add our main WeChat ID to access a wealth of shared resources (algorithms, interview prep, tech stacks: Java, Python, Go, big data). We mainly share serious development techniques, focusing on output-driven input. Occasionally we post life snippets and gossip. Our aim is to attract precise traffic and test advertising opportunities.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
