Why Java Replaced synchronized with Lock and How AQS Powers Modern Concurrency
This article explains why Java introduced the Lock API after Java 1.5, compares it with synchronized, details the explicit lock features, walks through the AQS (AbstractQueuedSynchronizer) implementation, and shows how ReentrantLock, fairness, reentrancy, and conditions are built on top of AQS.
Java SDK Why Design Lock
Imagine a world where Java concurrency only had synchronized; the code would be simple but limited to three usage patterns.
public class ThreeSync {
private static final Object object = new Object();
public synchronized void normalSyncMethod() {
// critical section
}
public static synchronized void staticSyncMethod() {
// critical section
}
public void syncBlockMethod() {
synchronized (object) {
// critical section
}
}
}Before Java 1.5 this was the case, but Doug Lea introduced a new wheel—Lock—to solve problems that synchronized could not handle.
We often say “don’t reinvent the wheel,” yet a new wheel is needed when the old one cannot solve certain scenarios.
Coffman identified four conditions that can cause deadlock; the non‑preemptive condition means a thread that has acquired a resource cannot be forced to release it until it voluntarily does so.
Thread has obtained a resource and cannot be preempted before it releases the resource.
To break this condition a lock must be able to release resources when it cannot acquire further ones, something synchronized cannot do because a thread that fails to acquire the monitor simply blocks.
Explicit Lock
The new wheel must provide non‑blocking capabilities. The following table lists three key features and their APIs:
These features make Lock more flexible but also a bit more complex to use.
Lock Usage Paradigm
Just like synchronized has a standard pattern, Lock follows a similar paradigm:
Lock lock = new ReentrantLock();
lock.lock();
try {
// critical section
} finally {
lock.unlock();
}Two best practices are emphasized:
Standard 1—Release the lock in finally
Ensures the lock is always released after acquisition.
Standard 2—Acquire the lock outside the try block
Prevents situations where an exception occurs before the lock is held, which would make finally attempt to release a lock that was never acquired.
try {
// code that may throw
} finally {
// release lock
}Different lock implementations share the same template methods; follow the paradigm to avoid problems.
How Does Lock Actually Work?
If you are familiar with synchronized, you know the JVM emits monitorenter and monitorexit bytecode instructions to mark entry and exit of a critical section.
From the paradigm perspective: lock.lock() is equivalent to monitorenter. lock.unlock() is equivalent to monitorexit.
The implementation relies on a volatile state field and CAS operations inside the synchronizer.
AQS Implementation Analysis
When lock.tryLock() fails, it calls the custom tryAcquire() method, which attempts to set state via CAS. If it fails, the thread is placed into a FIFO queue (the synchronizer queue) and blocked.
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}The queue is built from Node objects. Each node holds the thread reference, wait status, and links to predecessor and successor nodes.
When a thread cannot acquire the lock, it creates a node and appends it to the tail of the queue using CAS to ensure thread‑safety.
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}If the tail is null, a sentinel node is created to avoid edge‑case problems.
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) {
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}The waiting thread eventually parks via LockSupport.park() and is unparked when the lock is released.
private static final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}When the lock is released, the head node’s successor is unparked:
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}Interruptible Lock Acquisition
The method lockInterruptibly() checks the interrupt status first and then attempts a non‑blocking acquire; if it fails, it enters a loop that parks the thread but throws InterruptedException when interrupted.
public final void acquireInterruptibly(int arg) throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
if (!tryAcquire(arg))
doAcquireInterruptibly(arg);
}Timed Lock Acquisition
tryLock(long time, TimeUnit unit)computes a deadline, repeatedly attempts acquisition, and parks with a timeout. If the timeout expires, it returns false.
public final boolean tryAcquireNanos(int arg, long nanosTimeout) throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
return tryAcquire(arg) || doAcquireNanos(arg, nanosTimeout);
}Condition
A Condition is essentially a separate wait queue associated with a lock. Each condition maintains a singly‑linked list of waiting nodes.
private transient Node firstWaiter;
private transient Node lastWaiter;When a thread calls await(), it creates a node with waitStatus = CONDITION, adds it to the condition queue, releases the lock, and then parks.
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);
}Signalling moves a node from the condition queue to the synchronizer queue:
public final void signal() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
Node first = firstWaiter;
if (first != null)
doSignal(first);
}If the node cannot be transferred (e.g., cancelled), the method continues with the next waiter.
ReentrantLock and AQS
ReentrantLock is built on top of AQS. It has two internal synchronizer subclasses: NonfairSync and FairSync. The default constructor uses NonfairSync for higher throughput.
public ReentrantLock() {
sync = new NonfairSync();
}Fair locks check whether there are queued predecessors before acquiring the lock, while non‑fair locks try to acquire immediately.
// Fair lock acquisition
if (!hasQueuedPredecessors() && tryAcquire(arg))
return true;
// Non‑fair lock acquisition
if (tryAcquire(arg))
return true;ReentrantLock is re‑entrant: the owning thread can acquire the lock multiple times, incrementing the internal state each time, and must release it the same number of times.
if (current == getExclusiveOwnerThread()) {
int c = getState() + acquires;
if (c < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(c);
return true;
}Conclusion
This long article explained why Java needed a new lock abstraction, how to use Lock correctly, the inner workings of AQS, and how common classes like ReentrantLock are built on top of it. It also covered exclusive acquisition, release, interruptibility, timeout, conditions, fairness, and re‑entrancy.
Soul Questions
Why does AQS provide both setState() and compareAndSetState()? The former is used when the current thread already owns the lock and no race exists, making it safe; compareAndSetState() is used when multiple threads may contend for the state.
Does the following transfer code risk deadlock?
class Account {
private int balance;
private final Lock lock = new ReentrantLock();
void transfer(Account tar, int amt) {
while (true) {
if (this.lock.tryLock()) {
try {
if (tar.lock.tryLock()) {
try {
this.balance -= amt;
tar.balance += amt;
} finally {
tar.lock.unlock();
}
}
} finally {
this.lock.unlock();
}
}
}
}
}The loop repeatedly attempts to acquire both locks with tryLock(). If both locks cannot be obtained simultaneously, the thread releases any acquired lock and retries, so it does not block and therefore avoids deadlock, though it may cause livelock under high contention.
References
Java Concurrency in Practice
Java Concurrency: The Art of Multiprocessor Programming
https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html
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.
Programmer DD
A tinkering programmer and author of "Spring Cloud Microservices in Action"
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.
