Demystifying Java Locks: From Pessimistic vs Optimistic to ReentrantLock Internals
This article explains the classification of Java locks—including pessimistic, optimistic, fair, and unfair variants—details their state transitions from biased to heavyweight, and walks through the source code of ReentrantLock and Condition to illustrate how synchronization, queuing, and thread parking are implemented in the JVM.
Lock Classification
In Java, most locks are pessimistic; synchronized (including biased, lightweight, and heavyweight) and the
Lockimplementations provided by the JDK all follow this model. Optimistic locking is not a lock at all—it is a CAS‑based algorithm typically implemented with atomic classes from
java.util.concurrent.atomic.
Pessimistic vs Optimistic Locks
Pessimistic locks assume contention and block threads, while optimistic locks rely on repeated CAS attempts and only retry when a conflict is detected.
Fair and Unfair Locks
A fair lock grants access to the longest‑waiting thread when the lock is released; an unfair lock may allow a later thread to acquire the lock first, leading to random or priority‑based ordering.
final void lock() {
if (this.compareAndSetState(0, 1)) {
this.setExclusiveOwnerThread(Thread.currentThread());
} else {
this.acquire(1);
}
}The code above shows how a non‑fair lock first tries to acquire the lock via CAS; if it fails, it falls back to the AQS
acquiremethod, which may enqueue the thread.
Biased → Lightweight → Heavyweight
When a synchronized block is entered for the first time, the lock object becomes a biased lock, favoring the first thread that acquires it. If a second thread contends, the lock upgrades to a lightweight (spin) lock, where threads repeatedly attempt CAS on the object header. Prolonged spinning leads to a heavyweight lock, which puts contending threads to sleep instead of busy‑waiting.
Exclusive vs Shared Locks
Exclusive locks allow only the head thread to hold the lock; subsequent threads are queued. Shared locks permit multiple readers to hold the lock simultaneously, but exclusive mode is used for writes.
How to Implement Synchronization
Four common strategies are presented:
Pure spin‑lock (CPU intensive).
Spin‑lock with
Thread.yield()to give up the CPU when acquisition fails.
Spin‑lock combined with
Thread.sleep()(requires careful sleep duration selection).
Spin‑lock combined with
LockSupport.park()(used by
ReentrantLock).
volatile int status = 0;
Queue parkQueue;
void lock() {
while (!compareAndSet(0, 1)) {
park();
}
// lock acquired
}
void unlock() {
lock_notify();
}
void park() {
parkQueue.add(currentThread);
releaseCpu();
}
void lock_notify() {
Thread t = parkQueue.header();
unpark(t);
}ReentrantLock Source Analysis
ReentrantLock uses the fourth strategy (park + spin). The following snippet shows a simple runnable that repeatedly acquires and releases a fair
ReentrantLock:
public class MyRunnable implements Runnable {
private int num = 0;
private ReentrantLock lock = new ReentrantLock(true);
@Override
public void run() {
while (num < 20) {
lock.lock();
try {
num++;
Log.e("zzf", Thread.currentThread().getName() + " got lock, num is " + num);
} finally {
lock.unlock();
}
}
}
}The constructors of
ReentrantLockcreate either a non‑fair (
NonfairSync) or a fair (
FairSync) synchronizer. In the example,
new ReentrantLock(true)selects the fair implementation.
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}Calling
lock()delegates to
sync.lock(), which invokes
acquire(1). The
acquiremethod first attempts
tryAcquire; if it fails, the thread is enqueued via
addWaiter(Node.EXCLUSIVE)and then waits in the AQS queue.
protected final boolean tryAcquire(int var1) {
Thread current = Thread.currentThread();
int state = getState();
if (state == 0) {
if (!hasQueuedPredecessors() && compareAndSetState(0, var1)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) {
int next = state + var1;
if (next < 0) throw new Error("Maximum lock count exceeded");
setState(next);
return true;
}
return false;
}The internal
Nodeclass defines the queue nodes and several status constants (CANCELLED, SIGNAL, CONDITION, PROPAGATE). The AQS queue is a FIFO linked list of these nodes.
static final class Node {
static final Node SHARED = new Node(); // shared mode
static final Node EXCLUSIVE = null; // exclusive mode
static final int CANCELLED = 1;
static final int SIGNAL = -1;
static final int CONDITION = -2;
static final int PROPAGATE = -3;
volatile int waitStatus;
volatile Node prev;
volatile Node next;
volatile Thread thread;
Node nextWaiter;
}The method
hasQueuedPredecessors()determines whether the current thread must wait in the queue.
public final boolean hasQueuedPredecessors() {
Node t = tail;
Node h = head;
Node s;
return h != t && ((s = h.next) == null || s.thread != Thread.currentThread());
}When a thread cannot acquire the lock immediately, it is added to the queue via
addWaiterand then waits in
acquireQueued, which may park the thread using
LockSupport.park()until it becomes the head of the queue.
private Node addWaiter(Node mode) {
Node node = new Node(mode);
for (;;) {
Node oldTail = tail;
if (oldTail != null) {
node.prev = oldTail;
if (compareAndSetTail(oldTail, node)) {
oldTail.next = node;
return node;
}
} else {
initializeSyncQueue();
}
}
}Condition Support
Beyond the main synchronization queue,
ConditionObjectmaintains a separate condition queue. Threads that call
await()are moved from the sync queue to the condition queue, release the lock, and park until signalled.
public final void await() throws InterruptedException {
if (Thread.interrupted()) throw new InterruptedException();
Node node = addConditionWaiter();
int savedState = fullyRelease(node);
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
LockSupport.park(this);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) break;
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null) unlinkCancelledWaiters();
if (interruptMode != 0) reportInterruptAfterWait(interruptMode);
}The
signal()method transfers the first waiting node from the condition queue back to the sync queue, where it can contend for the lock again.
public final void signal() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
Node first = firstWaiter;
if (first != null)
doSignal(first);
}
private void doSignal(Node first) {
do {
if ((firstWaiter = first.nextWaiter) == null)
lastWaiter = null;
first.nextWaiter = null;
} while (!transferForSignal(first) && (first = firstWaiter) != null);
}Illustration
The diagram above visualizes the relationship between the synchronization queue and the condition queue during lock acquisition, release, and signalling.
Architect's Guide
Dedicated to sharing programmer-architect skills—Java backend, system, microservice, and distributed architectures—to help you become a senior architect.
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.