AbstractQueuedSynchronizer (AQS)
AQS is the framework underneath every lock in java.util.concurrent. It provides a single int state variable, a CLH-style wait queue, and the park/unpark plumbing. Subclasses define what the state means and when acquire/release succeeds. ReentrantLock, Semaphore, CountDownLatch, ReadWriteLock, FutureTask, ThreadPoolExecutor's worker locks, and StampedLock are all AQS subclasses or close cousins.
Diagram
The shape of AQS
AQS is a small toolkit for building locks. It owns three things, and that is the whole framework:
- One integer, called
state, whose meaning the subclass gets to pick. - A FIFO queue of threads that are waiting to acquire the lock.
- The plumbing to park waiting threads, wake them up when the lock becomes free, and handle timeouts and interrupts.
The subclass tells AQS two things. "Can this thread acquire the lock right now?" That is tryAcquire. "Is the lock free again?" That is tryRelease. Everything else (the queue, the parking, the unparking, the timeout handling) is provided.
Whoever sits at the head of the queue and successfully calls tryAcquire becomes the lock holder. Everyone behind them stays parked until they reach the head and the lock becomes free.
What state means in real subclasses
The single integer is interpreted differently by every subclass. The choice of meaning is the entire design decision.
| Subclass | Meaning of state |
|---|---|
ReentrantLock | Hold count for the owning thread. 0 means free; N means held N times by the same thread. |
ReentrantReadWriteLock | High 16 bits = number of readers; low 16 bits = write hold count. |
Semaphore | Remaining permits. |
CountDownLatch | Remaining count. 0 means the latch is open; any positive value means closed. |
SimpleMutex (the example below) | 0 for free, 1 for held. |
Pick a meaning that fits the synchronizer being built. The integer is what AQS actually reasons about; everything else flows from how tryAcquire and tryRelease interpret it.
The wait queue, step by step
A walkthrough with three threads (A, B, C) all racing for the same mutex. State 0 means free, 1 means held.
Five things to notice in that walkthrough.
- The queue starts empty. AQS only allocates queue nodes when there is actual contention; the uncontended fast path is a single CAS on
state. - The first node in the queue is always a dummy head with no thread attached. This is a CLH-design trick: the dummy lets enqueue and dequeue both run in O(1) with no special case for "empty queue".
- When the lock holder releases, AQS unparks only the head's immediate successor. The other waiters stay parked. This avoids the thundering herd that would happen if every waiter woke up and raced for the lock.
- The unparked waiter wakes up, runs
tryAcquireagain, and either succeeds (and becomes the new head) or fails and parks again. - The fact that B becomes the "new dummy head" after acquiring is not a leak; the old node is repurposed as the dummy for the next round, which is why no allocation happens on the unlock path.
The three knobs to change
A subclass typically writes three things:
- What
statemeans. Pick a meaning that fits the problem. tryAcquire(int). Return true if the caller should hold the lock now, false to queue. UsecompareAndSetStateto flip the integer atomically.tryRelease(int). Updatestateand return true if a waiter should be woken. Return true only when the lock is truly free (not after every partial decrement).
For shared-mode synchronizers (Semaphore, CountDownLatch, the read side of ReadWriteLock), use tryAcquireShared and tryReleaseShared instead. The shared variants return an int: negative means "couldn't acquire, queue me," non-negative means "acquired, and there are N more permits available," which lets AQS wake further shared waiters in a chain.
Read the source once
AbstractQueuedSynchronizer.java is around 2,000 lines and one of the most thoughtful pieces of Java in the JDK. Read it once, even without intending to extend it. The comments explain corner cases that would otherwise have to be discovered the hard way.
Where AQS doesn't fit
- Cross-process locks. AQS is in-process. Use
FileLock, OS semaphores, or a coordination service. - Spinlocks for ultra-short critical sections. AQS parks; spinlocks busy-wait. For sub-microsecond sections under low contention, a spinlock can win. AQS is a poor fit.
- Lock-free data structures. Use
java.util.concurrent.atomicand CAS directly. AQS is a coordination primitive, not a memory model. - Real-time priority scheduling. AQS does not implement priority inheritance. The JVM does not have it; for real-time needs, consider a real-time JVM or move to native code.
Why ReentrantLock beat synchronized for new code
tryLock(timeout, unit)with a deadline.lockInterruptibly().- Fairness option.
- Multiple
Conditions on one lock (synchronized has one wait set per object). - Inspectable:
getHoldCount,getQueueLength,hasWaiters. - Java 21+: works with virtual threads without pinning the carrier. (synchronized used to pin too; JEP 491 in Java 24 fixed that case as well.)
The cost: every site must remember try/finally { lock.unlock(); }. The JVM cannot release ReentrantLock at scope exit because it is just a method call, not a bytecode instruction. That is the only ergonomic loss.
How to actually use AQS
For 99% of locks: don't. Use ReentrantLock, Semaphore, CountDownLatch, etc. They are AQS subclasses written by Doug Lea and they handle the corner cases.
The 1% case: a custom predicate that the standard types don't express. Examples:
- A semaphore where some threads can acquire when permits are exhausted (admin override).
- A latch that resets after the count goes to zero, with safe re-arm.
- A lock that allows a specific thread to bypass the queue.
- A synchronizer for a custom resource (e.g., GPU memory pool) where state is naturally an int.
For those, write the smallest subclass possible, push everything else into AQS, and write a JCStress test before trusting it.
Primitives by language
- AbstractQueuedSynchronizer (AQS)
- AbstractQueuedLongSynchronizer (long state, same machinery)
- LockSupport.park / unpark (the underlying parking primitive)
Implementation
A non-reentrant mutex in about 30 lines. tryAcquire checks state == 0 and tries to flip it to 1 with CAS. tryRelease sets state back to 0 and signals the next waiter. AQS handles queueing, parking, and unparking; the subclass only writes the predicate.
1 import java.util.concurrent.locks.AbstractQueuedSynchronizer;
2
3 class SimpleMutex {
4 private final Sync sync = new Sync();
5
6 public void lock() { sync.acquire(1); }
7 public void unlock() { sync.release(1); }
8 public boolean tryLock() { return sync.tryAcquire(1); }
9
10 private static final class Sync extends AbstractQueuedSynchronizer {
11 @Override
12 protected boolean tryAcquire(int unused) {
13 // state 0 = free, 1 = held
14 if (compareAndSetState(0, 1)) {
15 setExclusiveOwnerThread(Thread.currentThread());
16 return true;
17 }
18 return false;
19 }
20
21 @Override
22 protected boolean tryRelease(int unused) {
23 if (Thread.currentThread() != getExclusiveOwnerThread()) {
24 throw new IllegalMonitorStateException();
25 }
26 setExclusiveOwnerThread(null);
27 setState(0); // volatile write, makes prior writes visible
28 return true; // true => allow next waiter to acquire
29 }
30
31 @Override
32 protected boolean isHeldExclusively() {
33 return getExclusiveOwnerThread() == Thread.currentThread();
34 }
35 }
36 }Reentrancy is just bookkeeping on state. If the current thread already owns the lock, increment state and return true. Release decrements; only when state hits 0 do we release the owner. This is exactly how ReentrantLock's NonfairSync works, minus a fast-path optimization.
1 import java.util.concurrent.locks.AbstractQueuedSynchronizer;
2
3 class ReentrantMutex {
4 private final Sync sync = new Sync();
5 public void lock() { sync.acquire(1); }
6 public void unlock() { sync.release(1); }
7
8 private static final class Sync extends AbstractQueuedSynchronizer {
9 @Override
10 protected boolean tryAcquire(int acquires) {
11 Thread current = Thread.currentThread();
12 int c = getState();
13 if (c == 0) {
14 if (compareAndSetState(0, acquires)) {
15 setExclusiveOwnerThread(current);
16 return true;
17 }
18 } else if (current == getExclusiveOwnerThread()) {
19 setState(c + acquires); // already own it; just increment
20 return true;
21 }
22 return false;
23 }
24
25 @Override
26 protected boolean tryRelease(int releases) {
27 if (Thread.currentThread() != getExclusiveOwnerThread()) {
28 throw new IllegalMonitorStateException();
29 }
30 int c = getState() - releases;
31 boolean free = (c == 0);
32 if (free) setExclusiveOwnerThread(null);
33 setState(c);
34 return free; // only signal next waiter when fully released
35 }
36 }
37 }For shared mode, override tryAcquireShared / tryReleaseShared instead. Return value is the number of remaining permits if non-negative; negative means the acquire failed and the thread should queue. This is exactly Semaphore's strategy.
1 import java.util.concurrent.locks.AbstractQueuedSynchronizer;
2
3 class CountingSemaphore {
4 private final Sync sync;
5 public CountingSemaphore(int permits) { sync = new Sync(permits); }
6
7 public void acquire() throws InterruptedException { sync.acquireSharedInterruptibly(1); }
8 public void release() { sync.releaseShared(1); }
9
10 private static final class Sync extends AbstractQueuedSynchronizer {
11 Sync(int permits) { setState(permits); }
12
13 @Override
14 protected int tryAcquireShared(int acquires) {
15 for (;;) {
16 int available = getState();
17 int remaining = available - acquires;
18 if (remaining < 0 || compareAndSetState(available, remaining)) {
19 return remaining; // negative => fail; non-negative => permits left
20 }
21 }
22 }
23
24 @Override
25 protected boolean tryReleaseShared(int releases) {
26 for (;;) {
27 int current = getState();
28 int next = current + releases;
29 if (next < current) throw new Error("overflow");
30 if (compareAndSetState(current, next)) return true;
31 }
32 }
33 }
34 }Application code does not normally write this loop, but understanding it is the difference between knowing AQS and using it. acquire() calls tryAcquire; if that fails, it adds the current thread to the CLH-style queue and parks. When a release happens, AQS unparks the head of the queue, which retries tryAcquire and either succeeds or parks again.
1 // Simplified pseudocode of AbstractQueuedSynchronizer.acquire(int)
2 public final void acquire(int arg) {
3 if (!tryAcquire(arg)) {
4 Node node = addWaiter(Node.EXCLUSIVE); // append to CLH queue
5 boolean interrupted = false;
6 for (;;) {
7 Node prev = node.predecessor();
8 if (prev == head && tryAcquire(arg)) { // I'm next; try once
9 setHead(node);
10 prev.next = null; // help GC
11 if (interrupted) selfInterrupt();
12 return;
13 }
14 if (shouldParkAfterFailedAcquire(prev, node)) {
15 LockSupport.park(this); // parks; unparked by release()
16 if (Thread.interrupted()) interrupted = true;
17 }
18 }
19 }
20 }Key points
- •One volatile int state field. Subclasses interpret it: 0/1 for ReentrantLock, hold count for reentrancy, available permits for Semaphore, count for CountDownLatch.
- •FIFO wait queue (a CLH variant): each waiter is a Node with a Thread and a status. Spin briefly, then park.
- •Subclasses override tryAcquire / tryRelease (exclusive) or tryAcquireShared / tryReleaseShared (shared). Everything else (queueing, parking, signalling) is provided.
- •Supports interruptible, timed, and fair acquisition out of the box.
- •ConditionObject (the Condition impl) lives on top of AQS too; await/signal use a separate condition queue that migrates nodes back to the main wait queue on signal.
- •Java 21 added VirtualThread support: AQS parks via LockSupport, which knows how to unmount a virtual thread from its carrier.
Follow-up questions
▸Why one int instead of an object?
▸What is the CLH queue and why does AQS use a variant of it?
▸How does Condition.await fit into all this?
▸Why don't ReentrantLock and synchronized share an implementation?
▸Has AQS changed for virtual threads?
Gotchas
- !Forgetting to make state writes happen via setState/compareAndSetState. They are volatile-safe; direct writes to other fields are not.
- !In tryRelease, only return true when the lock is fully released (state == 0 for a reentrant mutex). Returning true early wakes a waiter that will then fail tryAcquire and re-park, wasting a context switch.
- !Mixing exclusive and shared modes in the same synchronizer is possible but tricky (see ReentrantReadWriteLock). Don't try without first reading the source.
- !Signal-on-Condition without holding the lock throws IllegalMonitorStateException. ConditionObject checks the AQS owner.
Common pitfalls
- Subclassing AQS and storing extra state in fields rather than encoding it in the int. The int is what AQS reasons about; extra fields can race.
- Using AQS for cross-process synchronization. AQS is in-process only; cross-process needs filesystem locks or OS primitives.
- Building yet another mutex from AQS in production code. ReentrantLock already exists, is well-tested, and handles edge cases (interruption, fairness, hand-off) that hand-rolled code will get wrong on the first try. Only roll a custom one when a custom predicate is required (e.g., 'acquire when free OR caller is admin').
Practice problems
APIs worth memorising
- AbstractQueuedSynchronizer.tryAcquire / tryRelease
- AbstractQueuedSynchronizer.tryAcquireShared / tryReleaseShared
- AbstractQueuedSynchronizer.acquireInterruptibly
- ConditionObject.await / signal / signalAll
- LockSupport.park / unpark
Every j.u.c lock is AQS underneath. ReentrantLock, ReentrantReadWriteLock, StampedLock (similar but not AQS), Semaphore, CountDownLatch, CyclicBarrier (uses ReentrantLock), FutureTask, ThreadPoolExecutor (worker locks), SynchronousQueue. Reading the AQS source explains a lot of behavior observable in those types.