AbstractQueuedSynchronizer: What's Underneath Lock and Semaphore
Objective
The companion concept on concurrency utilities
covers ReentrantLock, Semaphore, and CountDownLatch from the outside — call
lock(), call acquire(), call await(). This concept is about what's underneath
all three: java.util.concurrent.locks.AbstractQueuedSynchronizer (AQS), a single
class that implements the hard, easy-to-get-wrong part every blocking synchronizer
needs — a thread-safe queue of blocked threads, and the logic to park and unpark
them correctly — so that ReentrantLock, Semaphore, CountDownLatch,
ReentrantReadWriteLock, and FutureTask don't each reimplement it from scratch.
A subclass of AQS only has to answer one narrow question in a handful of protected
methods: given the current state, does this specific synchronizer let the calling
thread proceed right now?
Use Cases
- Understanding why
ReentrantLock.tryAcquire()andSemaphore.tryAcquireShared()behave the way they do under contention, instead of treating them as black boxes. - Building a custom synchronizer when none of the standard
java.util.concurrentclasses fit the exact wait/release shape needed — a one-shot latch, a resource gate with custom admission rules — without hand-writing thread parking and a wait queue. - Reading a thread dump or stack trace that mentions
AbstractQueuedSynchronizer$NodeorConditionObjectand knowing what produced it — any blockedReentrantLock.lock(),Semaphore.acquire(), orCountDownLatch.await()call routes through AQS internals. - Recognizing the same
getState()/compareAndSetState()pattern across unrelated JDK classes and knowing it's the same underlying mechanism, not three independent implementations.
Deep Dive
The shape every synchronizer shares
A lock, a semaphore, a latch, and a barrier look unrelated from their public APIs, but structurally they are the same three ingredients:
- Some state that determines whether a thread may proceed — held/unheld for a lock, permits remaining for a semaphore, count-to-zero for a latch.
- Acquire and release operations that check that state and, if it permits proceeding, update it — otherwise the calling thread must wait.
- A queue of blocked threads waiting for the state to become favorable, which must be woken (some or all of them) whenever a release makes progress possible.
Every one of these classes could reimplement thread parking, an internal wait queue, and the race-prone logic to atomically check-state/enqueue/park — and get it subtly wrong under contention. AQS exists so that this happens exactly once. It owns:
- A single
intstate field, exposed to subclasses only through threeprotected finalmethods:getState(),setState(int), andcompareAndSetState(int expect, int update). - A FIFO queue of threads waiting to acquire, managed entirely inside AQS — a subclass never touches the queue directly.
- The blocking/unblocking machinery:
acquire(int)/release(int)for exclusive mode,acquireShared(int)/releaseShared(int)for shared mode, plus interruptible and timed variants of each.
What a subclass supplies is only the meaning of acquire and release for its
specific synchronizer, by overriding a small subset of protected hook methods
that AQS calls back into:
java// exclusive mode (e.g. a lock — only one thread can hold it)
protected boolean tryAcquire(int arg) { ... }
protected boolean tryRelease(int arg) { ... }
protected boolean isHeldExclusively() { ... }
// shared mode (e.g. a semaphore or latch — many threads can hold it at once)
protected int tryAcquireShared(int arg) { ... } // negative = failed
protected boolean tryReleaseShared(int arg) { ... }The canonical loop AQS runs internally, in outline, is: check whether the current
state permits acquisition via the try* hook; if not, enqueue the calling thread
(if not already queued) and park it; once unparked, retry the hook. release
updates the state via the hook and, if the hook reports the release may have
unblocked someone, wakes queued threads so they retry tryAcquire/
tryAcquireShared. None of that loop, the queue, or the parking is something a
subclass writes — it only writes the try* methods.
A minimal worked example: a one-shot latch on AQS
A binary latch — closed until someone opens it, then permanently open — needs
exactly one bit of state. Encode it as AQS state 0 (closed) or 1 (open), and
delegate to a private inner Sync rather than extending AQS directly (the same
pattern every synchronizer in the JDK follows, so the latch's own public surface
stays just await()/signal() instead of leaking AQS's full public API):
javaimport java.util.concurrent.locks.AbstractQueuedSynchronizer;
public class OneShotLatch {
private final Sync sync = new Sync();
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(0); // arg is unused here
}
public void signal() {
sync.releaseShared(0); // arg is unused here
}
private static class Sync extends AbstractQueuedSynchronizer {
protected int tryAcquireShared(int ignored) {
// Succeed (return >= 0) only once the latch is open.
return (getState() == 1) ? 1 : -1;
}
protected boolean tryReleaseShared(int ignored) {
setState(1); // latch is now open, permanently
return true; // let every queued (and future) acquirer proceed
}
}
}Tracing what happens: a thread calling await() invokes
acquireSharedInterruptibly(0), which calls tryAcquireShared. While
getState() == 0, that returns -1 (failure), so AQS parks the calling thread on
its internal queue. When some other thread calls signal(), releaseShared(0)
calls tryReleaseShared, which sets the state to 1 and returns true. AQS
reads that true as "a blocked thread might now succeed" and wakes the queued
threads, each of which re-runs tryAcquireShared — now returning 1 because
getState() == 1 — and proceeds. A thread that calls await() after signal()
already ran never blocks at all: tryAcquireShared succeeds on the first try.
That is the entire latch — about a dozen lines, no hand-written queue, no
hand-written wait()/notifyAll().
ReentrantLock on AQS: state as hold count
ReentrantLock is the exclusive-mode case: only one thread may hold it, but that
thread may re-acquire it (each lock() needs a matching unlock()). It maps onto
AQS by using the state as a hold count rather than a boolean, plus one extra
field of its own — the owning thread — that AQS doesn't track:
java// simplified from the non-fair ReentrantLock.Sync.tryAcquire
protected boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// lock is free — try to claim it
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) {
// already ours — this is a reentrant acquisition, just bump the count
setState(c + acquires);
return true;
}
return false; // held by another thread
}State 0 means unheld. The first lock() call sees c == 0 and uses
compareAndSetState(0, 1) — not a plain setState, because another thread could
be racing to claim the same lock at the same instant, and only one
compare-and-set can win. A second lock() call from the same thread (reentrant
acquisition — the scenario the Trade-offs section of the companion concept
mentions: lock() calls must be balanced by unlock() calls) sees current == getExclusiveOwnerThread() and simply increments the count instead of blocking on
itself. tryRelease is the mirror image: it decrements the count and only
reports the lock as released (letting AQS wake a queued thread) once the count
reaches zero again — so a doubly-locked ReentrantLock needs two unlock()
calls before anyone else can acquire it. Lock.newCondition() returns a new ConditionObject() — a non-static inner class AQS itself provides — which is why
ReentrantLock gets multiple independent wait sets almost for free.
Semaphore and CountDownLatch follow the same pattern in shared mode instead
of exclusive: Semaphore's state is permits remaining, and its
tryAcquireShared/tryReleaseShared loop on compareAndSetState exactly like
OneShotLatch above, retrying under contention instead of blocking, until either
there aren't enough permits left or the compare-and-set wins. CountDownLatch's
state is the countdown value itself. FutureTask and
ReentrantReadWriteLock build on AQS too — FutureTask encodes task status
(not-started/running/completed/cancelled) as the state, and
ReentrantReadWriteLock splits its one int state into two 16-bit halves, one
for the read-lock count and one for the write-lock count, routing reader threads
through the shared-acquire path and the writer through the exclusive-acquire path
of the very same AQS instance.
Trade-offs
- AQS is an implementation detail you inherit correctness from, not an API you
call day to day — reaching for it directly only pays off when no existing
class in
java.util.concurrent(Semaphore,CountDownLatch,ReentrantLock,BlockingQueue) already expresses the wait condition needed; building a custom synchronizer is strictly harder to get right than composing existing ones. - A synchronizer built on AQS should delegate to it via a private inner
Sync, not extend it directly — extending AQS publicly exposes methods likeacquire(int)/release(int)on the synchronizer's own type, letting callers manipulate the queue or state directly and corrupt invariants the synchronizer is supposed to guarantee:java// fragile: OneShotLatch IS an AQS, so its acquire/release are public too public class OneShotLatch extends AbstractQueuedSynchronizer { ... } new OneShotLatch().acquireShared(0); // callers can bypass await()/signal() // robust: OneShotLatch HAS an AQS, hidden in a private field public class OneShotLatch { private final Sync sync = new Sync(); // Sync extends AQS, but stays private public void await() throws InterruptedException { sync.acquireSharedInterruptibly(0); } } - The
try*hook methods must usecompareAndSetState, notsetState, when another thread could be racing to change the same state —setStateis a plain write; two threads both observing an unheld lock and both callingsetState(0, 1)-style logic with a plain write can both believe they acquired it.compareAndSetState(expected, new)only succeeds if the state still matches what was last observed, which is exactly why theReentrantLockandSemaphoresnippets above retry in a loop instead of writing unconditionally. - Choosing exclusive vs. shared mode is a one-way design decision — a
synchronizer that only implements
tryAcquire/tryRelease(exclusive) has no path for multiple threads to hold it simultaneously, and one that only implementstryAcquireShared/tryReleaseSharedhas no concept of a single owning thread; retrofitting the other mode later means adding the missing pair of hook methods and re-reasoning about the state encoding from scratch, the wayReentrantReadWriteLockhad to split its state into two halves to support both modes on one instance.