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 Lock implementations 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 acquire method, 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 ReentrantLock create 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 acquire method 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 Node class 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 addWaiter and 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, ConditionObject maintains 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.
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.
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.
